Skip to content

Commit 669296b

Browse files
fix(cloud-connection): report the release discovery reports as runtime_version when OS_RUNTIME_VERSION is unset (#24231)
Fixes #24150 Clause-②: no ## What changed `CloudConnectionPlugin` sends the server's version to the cloud at two sites: `runtime_version` on the approval links `POST /api/v1/cloud-connection/bind/start` returns (the page the approver reads), and `runtime_version` in the registration claim `POST /api/v1/cloud-connection/bind/poll` sends to the control plane's `/bind`. With `OS_RUNTIME_VERSION` unset, both sent the plugin's own `version` field, `'0.3.0'`. Both sites now call one closure, `reportedRuntimeVersion()`, defined inside the `kernel:ready` hook beside `credential()`: - **Set `OS_RUNTIME_VERSION`:** sent verbatim, with the same `??` set-ness as before (a set but empty value still sends nothing). - **Unset:** it calls `ctx.getService('protocol').getDiscovery()` when the handler runs and sends that document's `version`, the field `GET /api/v1/discovery` serves. The value is read per call and not cached at `init`. - **No discovery read** (no `protocol` service, or `getDiscovery()` throws): nothing is sent. The approval links get no `runtime_version` parameter and the bind body gets no `runtime_version` key. The plugin's own `readonly version = '0.3.0'` stays, because it is the plugin's version. No dependency was added and no export either. No `packages/metadata-protocol` path is touched, and `resolveDiscoveryVersion` is neither imported nor copied. The `#rename` paragraph of `content/docs/deployment/cloud-connection.mdx` now says the version is the release discovery reports, and that `OS_RUNTIME_VERSION` is an override. ## Mechanism assumptions, measured 1. **Which producer serves `GET /api/v1/discovery`, and do the two agree?** Measured on a real `pnpm dev -- --fresh -p PORT` boot of `examples/app-showcase` at `c90a9776d` (main `b9ac35cbf` plus this diff). `OS_RUNTIME_VERSION` was unset and `OS_CLOUD_URL` pointed at a stand-in control plane on loopback. - `GET /api/v1/discovery` answered unenveloped and carried `apiName`. That is `@objectstack/rest`'s discovery route over `protocol.getDiscovery()`: the dispatcher cedes `/api/v1/discovery` when `com.objectstack.rest.api` is mounted. `version` = `18.0.0-next.3`. - `GET /.well-known/objectstack`, which is dispatcher-owned (`HttpDispatcher.getDiscoveryInfo()`), answered enveloped with no `apiName`. `version` = `18.0.0-next.3`. - **The two agree.** Both read `OS_RUNTIME_VERSION` first and then their own package version, and `@objectstack/metadata-protocol` and `@objectstack/runtime` are in one changeset `fixed` group. 2. **Holds.** The plugin reaches the release through `ctx.getService('protocol')` and `getDiscovery()`, read at handler time. No copy of the resolver is made. 3. **Not triggered.** An in-process read exists. 4. **When the read fails, nothing is sent.** The four-axis reasons are below. Same boot, with this diff: - **`bind/start`:** `verification_uri` and `verification_uri_complete` both carry `runtime_version=18.0.0-next.3`. - **`bind/poll`:** the stand-in control plane received one `/bind` request, carrying `"runtime_version": "18.0.0-next.3"`. The before reading is the card's own, taken by the dev of #24066 on main `9ffe8af09`: `runtime_version=0.3.0` at both sites while discovery reported `18.0.0-next.2`. ### What to send when the discovery read fails Options: **A**, send nothing; **B**, send `'unknown'`; **C**, send `0.3.0` (the old behaviour); **D**, send the plugin's own package version, which needs a third copy of the resolver. - **Real business need:** the field exists so an approver sees what they are authorizing. C shows a wrong release as fact. B stores a literal `'unknown'` in the cloud's bind record. A shows nothing, and both sites already handle an empty value: a set but empty `OS_RUNTIME_VERSION` already sent nothing. - **Long-term soundness:** A and B keep one resolver, discovery's. D is the third copy the dispatch asked to avoid. - **Keeping AI from writing it wrong:** A keeps an absent value distinguishable from a real one. B and C are plausible strings. - **No scope spread at the startup stage:** A adds no surface. **Chosen: A.** Discovery's own resolver still answers `'unknown'` when its `package.json` cannot be read. The plugin forwards whatever discovery reports. ## Tests - **New pin**, `packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts`, 12 cases, both resolution modes. It drives the mounted `bind/start` and `bind/poll` routes against a stand-in control plane. Each expected value is read from a real `ObjectStackProtocolImplementation.getDiscovery()` on the same harness, never written as a literal, and the pin also asserts that value is not the plugin's own `version`. - **Unset:** both approval links and the bind claim carry the discovery release. - **Control:** a set `OS_RUNTIME_VERSION` is sent verbatim at both sites, with and without a discovery producer on the kernel. - **No discovery read:** neither site sends a version, whether the `protocol` service is missing or its read throws. - **`connection-credential-store.test.ts`:** its shared harness gains a `protocol` service. Two of its bind cases assert that a version is present, and on a harness with no discovery producer only the old constant satisfied that. - **On `a217f4bc6`** (this branch with `origin/main` `d3d064447` merged): - `pnpm --filter @objectstack/cloud-connection exec vitest run --maxWorkers=2`: 52 files, 613 tests passed, exit 0. - `pnpm --filter @objectstack/cloud-connection run typecheck`: exit 0. `--listFiles` shows the new pin in both tsc programs. - `test:repo`: core 86 passed, types 11, objectql 5, rest 262 passed and 14 skipped, runtime 808. Every exit was 0. - **On `c90a9776d`, before the merge:** spec `test:repo` passed 1115 tests in 67 files, exit 0. The merge did not touch the changed files. ## Ablation (one pass, on the committed fix) The mutation went through `scripts/ablation-replace.mjs` in WRAP mode, with a trap that restores the file. It replaced `if (stamped !== undefined) return stamped || undefined;` with `return (stamped ?? this.version) || undefined;`, which is the old `(process.env.OS_RUNTIME_VERSION ?? this.version) || undefined` fallback, restored. - **On disk:** the anchor count went 1 → 0 and the replacement count 0 → 1, read while the file was mutated. - **Red leg:** `vitest run src/cloud-connection-runtime-version.pin.test.ts` exited 1, with 8 failed and 4 passed. The four controls stayed green. Two of the failures: `expected [ '0.3.0', '0.3.0' ] to deeply equal [ '18.0.0-next.3', '18.0.0-next.3' ]` and `expected '0.3.0' to be '18.0.0-next.3'`. - **Restore:** the blob after restore equals the HEAD blob `1b75c89c5` and `git diff HEAD` is empty. - **Green leg:** 12 passed, exit 0. ## Gates (final head `a217f4bc6`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 93 commands. All 93 ran and exited 0. - `--ran` reconciliation: `93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN`. - `pnpm exec eslint --no-inline-config` on the three changed TypeScript files: exit 0. The `.mdx` and `.md` files have no matching eslint configuration. - The families CI runs over a whole root, and the type-check lanes, are left to this PR's check runs. ## Acceptance notes - **File surface.** The claim named one pin beside the plugin. `connection-credential-store.test.ts`, in the same directory, also changed: test-only, a `protocol` service added to its shared harness (see Tests). - **`OS_RUNTIME_VERSION` set to an empty string.** The plugin sends nothing, as before, which keeps the ruling that a set value wins exactly as it does now. Discovery's resolver treats an empty value as unset and reports the package version. The two disagree only on that config. Carrier: none. - **Multi-tenant hosts.** The read is the host kernel's `protocol` service, while REST's discovery resolves the environment's kernel per request. Both call the same `resolveDiscoveryVersion()` of the one installed module, so the value is the same within one process. On a host with no `protocol` service, nothing is sent. Not measured on a multi-tenant host, whose composition lives in the cloud repository. Carrier: none. - **Environment-variables page.** The Reference paragraph of `content/docs/deployment/cloud-connection.mdx`, which this card does not touch, says the flow's settings, `OS_RUNTIME_VERSION` among them, are listed on that page. The page's cloud-control-plane table has no `OS_RUNTIME_VERSION` row. Docs only. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 194a42d commit 669296b

5 files changed

Lines changed: 279 additions & 7 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
---
4+
5+
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`
6+
7+
Clause-②: no
8+
9+
`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.*`.
10+
11+
- **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.
12+
- **Set:** a set `OS_RUNTIME_VERSION` is sent verbatim, as before.
13+
- **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`.
14+
15+
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.

‎content/docs/deployment/cloud-connection.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,11 @@ explicit one before you connect if you want a readable, stable name. Whether
168168
the cloud account also lets you rename the entry directly is decided by the
169169
cloud account.
170170

171-
The version shown next to the name is `OS_RUNTIME_VERSION` when the deployment
172-
sets it. Unset, the server sends the Cloud Connection plugin's own version
173-
string, which is not your server's release, so set `OS_RUNTIME_VERSION` if
174-
approvers should see the release you run. That fallback is a known defect,
175-
tracked in [#24150](https://github.com/objectstack-ai/objectstack/issues/24150).
171+
The version shown next to the name is the release your server runs: the
172+
`version` its discovery document (`GET /api/v1/discovery`) reports. You do not
173+
need to set anything for it. `OS_RUNTIME_VERSION` is an override: set it to
174+
report something else, such as an image tag or a commit, and the server sends
175+
that value as it is.
176176

177177
## Disconnect [#disconnect]
178178

‎packages/cloud-connection/src/cloud-connection-plugin.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,15 @@ interface EnvironmentKernel {
167167
getService?<T = unknown>(name: string): T;
168168
}
169169

170+
/**
171+
* [#24150] The slice of the `protocol` service this surface reads: the
172+
* discovery document's `version`, which is the release `GET
173+
* /api/v1/discovery` reports for this server.
174+
*/
175+
interface DiscoveryVersionSource {
176+
getDiscovery?(): Promise<{ version?: unknown }>;
177+
}
178+
170179
/**
171180
* A signed-in caller of this surface, and the ADR-0069 posture its session
172181
* carries (`authGate`, present only for a session the policy holds).
@@ -270,6 +279,35 @@ export class CloudConnectionPlugin implements Plugin {
270279
return cred ? { Authorization: `Bearer ${cred}` } : {};
271280
};
272281

282+
/**
283+
* [#24150] The release this server reports to the cloud as
284+
* `runtime_version`: on the approval link `bind/start` returns,
285+
* and in the registration claim `bind/poll` sends to `/bind`.
286+
*
287+
* A set `OS_RUNTIME_VERSION` is the operator's stamp and wins, as
288+
* it always has. Unset, the value is the one `GET
289+
* /api/v1/discovery` reports, read from the discovery document
290+
* itself through the `protocol` service when the handler runs —
291+
* never a copy of discovery's resolver, so the two answers cannot
292+
* drift. ⛔ Not this plugin's own `version`: that names the
293+
* plugin, not the release the approver is authorizing.
294+
*
295+
* A read that yields nothing (no `protocol` service, or one that
296+
* throws) sends nothing; both sites already drop an empty value.
297+
* No version is honest, while a wrong one is what the approval
298+
* page would show as fact.
299+
*/
300+
const reportedRuntimeVersion = async (): Promise<string | undefined> => {
301+
const stamped = process.env.OS_RUNTIME_VERSION;
302+
if (stamped !== undefined) return stamped || undefined;
303+
try {
304+
const discovery = await ctx.getService<DiscoveryVersionSource>('protocol')?.getDiscovery?.();
305+
return typeof discovery?.version === 'string' && discovery.version ? discovery.version : undefined;
306+
} catch {
307+
return undefined;
308+
}
309+
};
310+
273311
const hostOf = (c: any): string => {
274312
try { return new URL(c.req.url).hostname; } catch { return ''; }
275313
};
@@ -571,12 +609,12 @@ export class CloudConnectionPlugin implements Plugin {
571609
// WHAT they are authorizing. Display-only — informed
572610
// consent, not an authenticity proof (the page carries
573611
// the "only approve if you started this" warning).
612+
const ver = await reportedRuntimeVersion();
574613
const withContext = (uri: unknown): string | undefined => {
575614
if (typeof uri !== 'string' || !uri) return undefined;
576615
try {
577616
const u = new URL(uri);
578617
try { u.searchParams.set('runtime_name', hostname()); } catch { /* no hostname */ }
579-
const ver = (process.env.OS_RUNTIME_VERSION ?? this.version) || '';
580618
if (ver) u.searchParams.set('runtime_version', ver);
581619
return u.toString();
582620
} catch { return uri; }
@@ -663,14 +701,15 @@ export class CloudConnectionPlugin implements Plugin {
663701
// the device; a stored runtime_id keeps the identity
664702
// stable across re-binds (claim verified cloud-side).
665703
const stored = this.store.read();
704+
const runtimeVersion = await reportedRuntimeVersion();
666705
const bindResp = await fetch(`${cloudUrl}/api/v1/cloud-connection/bind`, {
667706
method: 'POST',
668707
headers: { 'Content-Type': 'application/json', ...(cloudApiKey ? { Authorization: `Bearer ${cloudApiKey}` } : {}) },
669708
body: JSON.stringify({
670709
...(environmentId ? { environment_id: environmentId } : {}),
671710
...(stored?.runtimeId ? { runtime_id: stored.runtimeId } : {}),
672711
name: (() => { try { return hostname(); } catch { return undefined; } })(),
673-
runtime_version: (process.env.OS_RUNTIME_VERSION ?? this.version) || undefined,
712+
runtime_version: runtimeVersion,
674713
token: accessToken,
675714
scope: deviceScope,
676715
}),
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24150] The release this server reports to the cloud is the release
5+
* discovery reports.
6+
*
7+
* `runtime_version` reaches the cloud at two sites, both driven here through
8+
* the routes `CloudConnectionPlugin` really mounts (`start()` +
9+
* `kernel:ready`): the approval links `bind/start` returns (the page an
10+
* approver reads to see WHAT they are authorizing), and the registration claim
11+
* `bind/poll` sends to the control plane's `/bind`, captured at a stand-in
12+
* control plane.
13+
*
14+
* - With `OS_RUNTIME_VERSION` unset, both sites carry the `version` the real
15+
* discovery producer (`ObjectStackProtocolImplementation.getDiscovery()`,
16+
* the one `GET /api/v1/discovery` serves) returns on the same harness. The
17+
* expected value is READ from that producer, never written here, so a
18+
* constant at either site fails whatever constant it picks.
19+
* - A set `OS_RUNTIME_VERSION` is sent verbatim, with or without a discovery
20+
* producer on the kernel: the operator's stamp is the plugin's own read.
21+
* - With no discovery read (no `protocol` service, or one that throws),
22+
* neither site sends a version. Before this, both sent the plugin's own
23+
* `version` field, which names the plugin, not the server's release.
24+
*/
25+
26+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
27+
import { mkdtempSync, rmSync } from 'node:fs';
28+
import { join } from 'node:path';
29+
import { tmpdir } from 'node:os';
30+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
31+
import { CloudConnectionPlugin } from './cloud-connection-plugin.js';
32+
import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
33+
34+
const CC = '/api/v1/cloud-connection';
35+
const ORIGIN = 'https://env.example.test';
36+
const CLOUD = 'http://cloud.test';
37+
38+
/** Holds `setup.access`, which the two bind doors demand. */
39+
const ADMIN = 'u_admin';
40+
const GRANTS: Record<string, any[]> = {
41+
sys_user_permission_set: [{ id: 'ups_1', user_id: ADMIN, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }],
42+
sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }],
43+
};
44+
45+
/**
46+
* The discovery producer `GET /api/v1/discovery` serves, over the minimal
47+
* engine `discovery-version.test.ts` in `@objectstack/metadata-protocol` uses.
48+
*/
49+
function discoveryProducer() {
50+
const engine = { registry: { getObject: () => undefined, getRegisteredTypes: () => [] } };
51+
return new ObjectStackProtocolImplementation(engine as any, () => new Map());
52+
}
53+
54+
/** What the discovery producer reports, read on this harness. */
55+
async function discoveryRelease(): Promise<unknown> {
56+
return (await discoveryProducer().getDiscovery()).version;
57+
}
58+
59+
type Handler = (c: any) => Promise<any>;
60+
type Mode = 'single' | 'multi';
61+
/** `real` mounts the discovery producer; `none` mounts no `protocol` service; `throws` mounts one whose read fails. */
62+
type Discovery = 'real' | 'none' | 'throws';
63+
64+
function makeC(path: string, body?: unknown) {
65+
const url = `${ORIGIN}${path}`;
66+
const headers = new Headers();
67+
return {
68+
req: {
69+
url,
70+
raw: new Request(url, { headers }),
71+
header: (n: string) => headers.get(n) ?? undefined,
72+
json: async () => body ?? {},
73+
},
74+
json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }),
75+
};
76+
}
77+
78+
let dir: string;
79+
const ORIGINAL_STAMP = process.env.OS_RUNTIME_VERSION;
80+
beforeEach(() => {
81+
dir = mkdtempSync(join(tmpdir(), 'cc-runtime-version-'));
82+
delete process.env.OS_RUNTIME_VERSION;
83+
});
84+
afterEach(() => {
85+
rmSync(dir, { recursive: true, force: true });
86+
if (ORIGINAL_STAMP === undefined) delete process.env.OS_RUNTIME_VERSION;
87+
else process.env.OS_RUNTIME_VERSION = ORIGINAL_STAMP;
88+
vi.unstubAllGlobals();
89+
});
90+
91+
async function mount(mode: Mode, discovery: Discovery) {
92+
const routes = new Map<string, Handler>();
93+
const rawApp = {
94+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
95+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
96+
};
97+
const hooks = new Map<string, any>();
98+
const auth = { api: { getSession: async () => ({ user: { id: ADMIN }, session: { id: 'sess_admin' } }) } };
99+
const objectql = bindCatalogFromTables({ find: async (object: string) => GRANTS[object] ?? [] }, GRANTS);
100+
const protocol = discovery === 'real'
101+
? discoveryProducer()
102+
: discovery === 'throws'
103+
? { getDiscovery: async () => { throw new Error('discovery unavailable'); } }
104+
: undefined;
105+
const envKernelServices: Record<string, unknown> = { auth, objectql };
106+
const services: Record<string, unknown> = {
107+
...(mode === 'single'
108+
? { auth, objectql }
109+
: {
110+
'env-registry': { resolveByHostname: async () => ({ environmentId: 'env-1' }) },
111+
'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async (name: string) => envKernelServices[name] }) },
112+
}),
113+
...(protocol ? { protocol } : {}),
114+
};
115+
const ctx = {
116+
hook: (e: string, h: any) => hooks.set(e, h),
117+
getService: (name: string) => {
118+
if (name === 'http-server') return { getRawApp: () => rawApp };
119+
if (name in services) return services[name];
120+
throw new Error(`service ${name} not registered`);
121+
},
122+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
123+
};
124+
// The stand-in control plane: a device code with both approval links, a
125+
// granted token, and a `/bind` that records the claim it was sent.
126+
const bindBodies: any[] = [];
127+
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
128+
const path = new URL(String(url)).pathname;
129+
let body: unknown = { success: true, data: {} };
130+
if (path.endsWith('/device/code')) {
131+
body = {
132+
device_code: 'dc_1', user_code: 'ABCD-EFGH', interval: 5, expires_in: 600,
133+
verification_uri: `${CLOUD}/device`,
134+
verification_uri_complete: `${CLOUD}/device?user_code=ABCD-EFGH`,
135+
};
136+
} else if (path.endsWith('/device/token')) {
137+
body = { access_token: 'at_1' };
138+
} else if (path.endsWith('/cloud-connection/bind')) {
139+
bindBodies.push(JSON.parse(String(init?.body)));
140+
body = { success: true, data: { runtime_token: 'oscc_new', runtime_id: 'rt_1', connection: { runtime_id: 'rt_1' } } };
141+
}
142+
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
143+
}));
144+
const plugin = new CloudConnectionPlugin({
145+
controlPlaneUrl: CLOUD,
146+
credentialPath: join(dir, 'cloud-connection.json'),
147+
...(mode === 'single' ? { singleEnvironment: true, environmentId: 'env-1' } : {}),
148+
});
149+
await plugin.start(ctx as any);
150+
await hooks.get('kernel:ready')?.();
151+
152+
/** `runtime_version` on each approval link `bind/start` answered with (`null` = no such parameter). */
153+
const approvalLinkVersions = async () => {
154+
const res = await routes.get(`POST ${CC}/bind/start`)!(makeC(`${CC}/bind/start`));
155+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
156+
const { verification_uri, verification_uri_complete } = res.payload.data;
157+
return [verification_uri, verification_uri_complete].map((uri: string) => new URL(uri).searchParams.get('runtime_version'));
158+
};
159+
/** The registration claim `bind/poll` sent the control plane's `/bind`. */
160+
const bindClaim = async () => {
161+
const res = await routes.get(`POST ${CC}/bind/poll`)!(makeC(`${CC}/bind/poll`, { device_code: 'dc_1' }));
162+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
163+
expect(bindBodies, 'the control plane received exactly one bind').toHaveLength(1);
164+
return bindBodies[0];
165+
};
166+
return { plugin, approvalLinkVersions, bindClaim };
167+
}
168+
169+
for (const mode of ['single', 'multi'] as const) {
170+
describe(`[#24150] runtime_version is the release discovery reports (${mode}-environment)`, () => {
171+
it('OS_RUNTIME_VERSION unset: both approval links carry the discovery release', async () => {
172+
const { plugin, approvalLinkVersions } = await mount(mode, 'real');
173+
const release = await discoveryRelease();
174+
// Not vacuous: discovery reports a real release, and it is not the
175+
// plugin's own version field (which the defect sent instead).
176+
expect(typeof release === 'string' && release.length > 0, String(release)).toBe(true);
177+
expect(release).not.toBe(plugin.version);
178+
179+
expect(await approvalLinkVersions()).toEqual([release, release]);
180+
});
181+
182+
it('OS_RUNTIME_VERSION unset: the bind claim sent to the control plane carries the discovery release', async () => {
183+
const { bindClaim } = await mount(mode, 'real');
184+
const release = await discoveryRelease();
185+
186+
expect((await bindClaim()).runtime_version).toBe(release);
187+
});
188+
189+
for (const discovery of ['real', 'none'] as const) {
190+
it(`CONTROL: a set OS_RUNTIME_VERSION is sent verbatim at both sites (discovery producer: ${discovery})`, async () => {
191+
const stamp = `stamp-24150-${mode}-${discovery}-not-a-release`;
192+
process.env.OS_RUNTIME_VERSION = stamp;
193+
const { approvalLinkVersions, bindClaim } = await mount(mode, discovery);
194+
195+
expect(await approvalLinkVersions()).toEqual([stamp, stamp]);
196+
expect((await bindClaim()).runtime_version).toBe(stamp);
197+
});
198+
}
199+
200+
for (const discovery of ['none', 'throws'] as const) {
201+
it(`no discovery read (${discovery === 'none' ? 'no protocol service' : 'the read throws'}): neither site sends a version`, async () => {
202+
const { approvalLinkVersions, bindClaim } = await mount(mode, discovery);
203+
204+
expect(await approvalLinkVersions()).toEqual([null, null]);
205+
const claim = await bindClaim();
206+
expect('runtime_version' in claim, JSON.stringify(claim)).toBe(false);
207+
});
208+
}
209+
});
210+
}

‎packages/cloud-connection/src/connection-credential-store.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,17 @@ const ADMIN_GRANTS: Record<string, any[]> = {
6666
sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }],
6767
sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }],
6868
};
69+
/**
70+
* [#24150] With `OS_RUNTIME_VERSION` unset, the bind claim's `runtime_version`
71+
* is the release the discovery document reports, read through the `protocol`
72+
* service. The suites below only need one to be present; the value itself is
73+
* pinned against the real producer in `cloud-connection-runtime-version.pin.test.ts`.
74+
*/
75+
const DISCOVERY = { getDiscovery: async () => ({ version: 'release-from-discovery' }) };
6976
function makeCtx(rawApp: any, explicit: Record<string, any> = {}) {
7077
const services: Record<string, any> = {
7178
objectql: bindCatalogFromTables({ find: async (object: string) => ADMIN_GRANTS[object] ?? [] }, ADMIN_GRANTS),
79+
protocol: DISCOVERY,
7280
...explicit,
7381
};
7482
const hooks = new Map<string, any>();

0 commit comments

Comments
 (0)