diff --git a/.changeset/24150-cloud-connection-runtime-version.md b/.changeset/24150-cloud-connection-runtime-version.md new file mode 100644 index 00000000000..e60fe3fcbca --- /dev/null +++ b/.changeset/24150-cloud-connection-runtime-version.md @@ -0,0 +1,15 @@ +--- +'@objectstack/cloud-connection': patch +--- + +fix(cloud-connection): with `OS_RUNTIME_VERSION` unset, the server reports to the cloud the release `GET /api/v1/discovery` reports, not the Cloud Connection plugin's own `0.3.0` + +Clause-②: no + +`CloudConnectionPlugin` sends the server's version to the cloud in two places: as `runtime_version` on the approval links `POST /api/v1/cloud-connection/bind/start` returns, which the cloud's approval page shows next to the server's name, and as `runtime_version` in the registration request `POST /api/v1/cloud-connection/bind/poll` sends to the cloud's `/bind`. With `OS_RUNTIME_VERSION` unset, both carried the plugin's own `version` field, `0.3.0`. That string names the plugin, not the release the server runs, so an approver was shown `0.3.0` while discovery on the same server reported `18.0.0-next.*`. + +- **Unset now:** both places carry the `version` the server's discovery document reports. The plugin reads it from the discovery document itself, through the `protocol` service, each time it builds the approval links or the registration request, so the two answers cannot drift. +- **Set:** a set `OS_RUNTIME_VERSION` is sent verbatim, as before. +- **No discovery document:** on a server with no `protocol` service, or when reading it fails, neither place carries a version. The approval links have no `runtime_version` parameter and the registration request has no `runtime_version` key. Before, both carried `0.3.0`. + +What changes for you: nothing to rewrite. `OS_RUNTIME_VERSION` is now only needed to report a version other than the release, such as an image tag or a commit. diff --git a/content/docs/deployment/cloud-connection.mdx b/content/docs/deployment/cloud-connection.mdx index f5e38ef1fe9..fdf03357b78 100644 --- a/content/docs/deployment/cloud-connection.mdx +++ b/content/docs/deployment/cloud-connection.mdx @@ -168,11 +168,11 @@ explicit one before you connect if you want a readable, stable name. Whether the cloud account also lets you rename the entry directly is decided by the cloud account. -The version shown next to the name is `OS_RUNTIME_VERSION` when the deployment -sets it. Unset, the server sends the Cloud Connection plugin's own version -string, which is not your server's release, so set `OS_RUNTIME_VERSION` if -approvers should see the release you run. That fallback is a known defect, -tracked in [#24150](https://github.com/objectstack-ai/objectstack/issues/24150). +The version shown next to the name is the release your server runs: the +`version` its discovery document (`GET /api/v1/discovery`) reports. You do not +need to set anything for it. `OS_RUNTIME_VERSION` is an override: set it to +report something else, such as an image tag or a commit, and the server sends +that value as it is. ## Disconnect [#disconnect] diff --git a/packages/cloud-connection/src/cloud-connection-plugin.ts b/packages/cloud-connection/src/cloud-connection-plugin.ts index 44de963e5af..1b75c89c549 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.ts @@ -167,6 +167,15 @@ interface EnvironmentKernel { getService?(name: string): T; } +/** + * [#24150] The slice of the `protocol` service this surface reads: the + * discovery document's `version`, which is the release `GET + * /api/v1/discovery` reports for this server. + */ +interface DiscoveryVersionSource { + getDiscovery?(): Promise<{ version?: unknown }>; +} + /** * A signed-in caller of this surface, and the ADR-0069 posture its session * carries (`authGate`, present only for a session the policy holds). @@ -270,6 +279,35 @@ export class CloudConnectionPlugin implements Plugin { return cred ? { Authorization: `Bearer ${cred}` } : {}; }; + /** + * [#24150] The release this server reports to the cloud as + * `runtime_version`: on the approval link `bind/start` returns, + * and in the registration claim `bind/poll` sends to `/bind`. + * + * A set `OS_RUNTIME_VERSION` is the operator's stamp and wins, as + * it always has. Unset, the value is the one `GET + * /api/v1/discovery` reports, read from the discovery document + * itself through the `protocol` service when the handler runs — + * never a copy of discovery's resolver, so the two answers cannot + * drift. ⛔ Not this plugin's own `version`: that names the + * plugin, not the release the approver is authorizing. + * + * A read that yields nothing (no `protocol` service, or one that + * throws) sends nothing; both sites already drop an empty value. + * No version is honest, while a wrong one is what the approval + * page would show as fact. + */ + const reportedRuntimeVersion = async (): Promise => { + const stamped = process.env.OS_RUNTIME_VERSION; + if (stamped !== undefined) return stamped || undefined; + try { + const discovery = await ctx.getService('protocol')?.getDiscovery?.(); + return typeof discovery?.version === 'string' && discovery.version ? discovery.version : undefined; + } catch { + return undefined; + } + }; + const hostOf = (c: any): string => { try { return new URL(c.req.url).hostname; } catch { return ''; } }; @@ -571,12 +609,12 @@ export class CloudConnectionPlugin implements Plugin { // WHAT they are authorizing. Display-only — informed // consent, not an authenticity proof (the page carries // the "only approve if you started this" warning). + const ver = await reportedRuntimeVersion(); const withContext = (uri: unknown): string | undefined => { if (typeof uri !== 'string' || !uri) return undefined; try { const u = new URL(uri); try { u.searchParams.set('runtime_name', hostname()); } catch { /* no hostname */ } - const ver = (process.env.OS_RUNTIME_VERSION ?? this.version) || ''; if (ver) u.searchParams.set('runtime_version', ver); return u.toString(); } catch { return uri; } @@ -663,6 +701,7 @@ export class CloudConnectionPlugin implements Plugin { // the device; a stored runtime_id keeps the identity // stable across re-binds (claim verified cloud-side). const stored = this.store.read(); + const runtimeVersion = await reportedRuntimeVersion(); const bindResp = await fetch(`${cloudUrl}/api/v1/cloud-connection/bind`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(cloudApiKey ? { Authorization: `Bearer ${cloudApiKey}` } : {}) }, @@ -670,7 +709,7 @@ export class CloudConnectionPlugin implements Plugin { ...(environmentId ? { environment_id: environmentId } : {}), ...(stored?.runtimeId ? { runtime_id: stored.runtimeId } : {}), name: (() => { try { return hostname(); } catch { return undefined; } })(), - runtime_version: (process.env.OS_RUNTIME_VERSION ?? this.version) || undefined, + runtime_version: runtimeVersion, token: accessToken, scope: deviceScope, }), diff --git a/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts b/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts new file mode 100644 index 00000000000..4a18234c408 --- /dev/null +++ b/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24150] The release this server reports to the cloud is the release + * discovery reports. + * + * `runtime_version` reaches the cloud at two sites, both driven here through + * the routes `CloudConnectionPlugin` really mounts (`start()` + + * `kernel:ready`): the approval links `bind/start` returns (the page an + * approver reads to see WHAT they are authorizing), and the registration claim + * `bind/poll` sends to the control plane's `/bind`, captured at a stand-in + * control plane. + * + * - With `OS_RUNTIME_VERSION` unset, both sites carry the `version` the real + * discovery producer (`ObjectStackProtocolImplementation.getDiscovery()`, + * the one `GET /api/v1/discovery` serves) returns on the same harness. The + * expected value is READ from that producer, never written here, so a + * constant at either site fails whatever constant it picks. + * - A set `OS_RUNTIME_VERSION` is sent verbatim, with or without a discovery + * producer on the kernel: the operator's stamp is the plugin's own read. + * - With no discovery read (no `protocol` service, or one that throws), + * neither site sends a version. Before this, both sent the plugin's own + * `version` field, which names the plugin, not the server's release. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { CloudConnectionPlugin } from './cloud-connection-plugin.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; + +const CC = '/api/v1/cloud-connection'; +const ORIGIN = 'https://env.example.test'; +const CLOUD = 'http://cloud.test'; + +/** Holds `setup.access`, which the two bind doors demand. */ +const ADMIN = 'u_admin'; +const GRANTS: Record = { + sys_user_permission_set: [{ id: 'ups_1', user_id: ADMIN, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], +}; + +/** + * The discovery producer `GET /api/v1/discovery` serves, over the minimal + * engine `discovery-version.test.ts` in `@objectstack/metadata-protocol` uses. + */ +function discoveryProducer() { + const engine = { registry: { getObject: () => undefined, getRegisteredTypes: () => [] } }; + return new ObjectStackProtocolImplementation(engine as any, () => new Map()); +} + +/** What the discovery producer reports, read on this harness. */ +async function discoveryRelease(): Promise { + return (await discoveryProducer().getDiscovery()).version; +} + +type Handler = (c: any) => Promise; +type Mode = 'single' | 'multi'; +/** `real` mounts the discovery producer; `none` mounts no `protocol` service; `throws` mounts one whose read fails. */ +type Discovery = 'real' | 'none' | 'throws'; + +function makeC(path: string, body?: unknown) { + const url = `${ORIGIN}${path}`; + const headers = new Headers(); + return { + req: { + url, + raw: new Request(url, { headers }), + header: (n: string) => headers.get(n) ?? undefined, + json: async () => body ?? {}, + }, + json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }), + }; +} + +let dir: string; +const ORIGINAL_STAMP = process.env.OS_RUNTIME_VERSION; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cc-runtime-version-')); + delete process.env.OS_RUNTIME_VERSION; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (ORIGINAL_STAMP === undefined) delete process.env.OS_RUNTIME_VERSION; + else process.env.OS_RUNTIME_VERSION = ORIGINAL_STAMP; + vi.unstubAllGlobals(); +}); + +async function mount(mode: Mode, discovery: Discovery) { + const routes = new Map(); + const rawApp = { + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + }; + const hooks = new Map(); + const auth = { api: { getSession: async () => ({ user: { id: ADMIN }, session: { id: 'sess_admin' } }) } }; + const objectql = bindCatalogFromTables({ find: async (object: string) => GRANTS[object] ?? [] }, GRANTS); + const protocol = discovery === 'real' + ? discoveryProducer() + : discovery === 'throws' + ? { getDiscovery: async () => { throw new Error('discovery unavailable'); } } + : undefined; + const envKernelServices: Record = { auth, objectql }; + const services: Record = { + ...(mode === 'single' + ? { auth, objectql } + : { + 'env-registry': { resolveByHostname: async () => ({ environmentId: 'env-1' }) }, + 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async (name: string) => envKernelServices[name] }) }, + }), + ...(protocol ? { protocol } : {}), + }; + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (name in services) return services[name]; + throw new Error(`service ${name} not registered`); + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + // The stand-in control plane: a device code with both approval links, a + // granted token, and a `/bind` that records the claim it was sent. + const bindBodies: any[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + const path = new URL(String(url)).pathname; + let body: unknown = { success: true, data: {} }; + if (path.endsWith('/device/code')) { + body = { + device_code: 'dc_1', user_code: 'ABCD-EFGH', interval: 5, expires_in: 600, + verification_uri: `${CLOUD}/device`, + verification_uri_complete: `${CLOUD}/device?user_code=ABCD-EFGH`, + }; + } else if (path.endsWith('/device/token')) { + body = { access_token: 'at_1' }; + } else if (path.endsWith('/cloud-connection/bind')) { + bindBodies.push(JSON.parse(String(init?.body))); + body = { success: true, data: { runtime_token: 'oscc_new', runtime_id: 'rt_1', connection: { runtime_id: 'rt_1' } } }; + } + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + })); + const plugin = new CloudConnectionPlugin({ + controlPlaneUrl: CLOUD, + credentialPath: join(dir, 'cloud-connection.json'), + ...(mode === 'single' ? { singleEnvironment: true, environmentId: 'env-1' } : {}), + }); + await plugin.start(ctx as any); + await hooks.get('kernel:ready')?.(); + + /** `runtime_version` on each approval link `bind/start` answered with (`null` = no such parameter). */ + const approvalLinkVersions = async () => { + const res = await routes.get(`POST ${CC}/bind/start`)!(makeC(`${CC}/bind/start`)); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + const { verification_uri, verification_uri_complete } = res.payload.data; + return [verification_uri, verification_uri_complete].map((uri: string) => new URL(uri).searchParams.get('runtime_version')); + }; + /** The registration claim `bind/poll` sent the control plane's `/bind`. */ + const bindClaim = async () => { + const res = await routes.get(`POST ${CC}/bind/poll`)!(makeC(`${CC}/bind/poll`, { device_code: 'dc_1' })); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(bindBodies, 'the control plane received exactly one bind').toHaveLength(1); + return bindBodies[0]; + }; + return { plugin, approvalLinkVersions, bindClaim }; +} + +for (const mode of ['single', 'multi'] as const) { + describe(`[#24150] runtime_version is the release discovery reports (${mode}-environment)`, () => { + it('OS_RUNTIME_VERSION unset: both approval links carry the discovery release', async () => { + const { plugin, approvalLinkVersions } = await mount(mode, 'real'); + const release = await discoveryRelease(); + // Not vacuous: discovery reports a real release, and it is not the + // plugin's own version field (which the defect sent instead). + expect(typeof release === 'string' && release.length > 0, String(release)).toBe(true); + expect(release).not.toBe(plugin.version); + + expect(await approvalLinkVersions()).toEqual([release, release]); + }); + + it('OS_RUNTIME_VERSION unset: the bind claim sent to the control plane carries the discovery release', async () => { + const { bindClaim } = await mount(mode, 'real'); + const release = await discoveryRelease(); + + expect((await bindClaim()).runtime_version).toBe(release); + }); + + for (const discovery of ['real', 'none'] as const) { + it(`CONTROL: a set OS_RUNTIME_VERSION is sent verbatim at both sites (discovery producer: ${discovery})`, async () => { + const stamp = `stamp-24150-${mode}-${discovery}-not-a-release`; + process.env.OS_RUNTIME_VERSION = stamp; + const { approvalLinkVersions, bindClaim } = await mount(mode, discovery); + + expect(await approvalLinkVersions()).toEqual([stamp, stamp]); + expect((await bindClaim()).runtime_version).toBe(stamp); + }); + } + + for (const discovery of ['none', 'throws'] as const) { + it(`no discovery read (${discovery === 'none' ? 'no protocol service' : 'the read throws'}): neither site sends a version`, async () => { + const { approvalLinkVersions, bindClaim } = await mount(mode, discovery); + + expect(await approvalLinkVersions()).toEqual([null, null]); + const claim = await bindClaim(); + expect('runtime_version' in claim, JSON.stringify(claim)).toBe(false); + }); + } + }); +} diff --git a/packages/cloud-connection/src/connection-credential-store.test.ts b/packages/cloud-connection/src/connection-credential-store.test.ts index a5b73c92d90..a0630254f87 100644 --- a/packages/cloud-connection/src/connection-credential-store.test.ts +++ b/packages/cloud-connection/src/connection-credential-store.test.ts @@ -66,9 +66,17 @@ const ADMIN_GRANTS: Record = { sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], }; +/** + * [#24150] With `OS_RUNTIME_VERSION` unset, the bind claim's `runtime_version` + * is the release the discovery document reports, read through the `protocol` + * service. The suites below only need one to be present; the value itself is + * pinned against the real producer in `cloud-connection-runtime-version.pin.test.ts`. + */ +const DISCOVERY = { getDiscovery: async () => ({ version: 'release-from-discovery' }) }; function makeCtx(rawApp: any, explicit: Record = {}) { const services: Record = { objectql: bindCatalogFromTables({ find: async (object: string) => ADMIN_GRANTS[object] ?? [] }, ADMIN_GRANTS), + protocol: DISCOVERY, ...explicit, }; const hooks = new Map();