Skip to content

Commit 98ea344

Browse files
Elon Muskclaude
andauthored
fix(runtime): version on /health and the discovery payload is derived, not a hardcoded '1.0.0' literal (#10993) (#11242)
Resolve `version` once at HttpDispatcher construction from an injected OS_RUNTIME_VERSION stamp, falling back to the resolved @objectstack/runtime package version -- reusing the env var cloud-connection-plugin.ts already reads for the same purpose. Fixes the identical defect on both the /health handler and the discovery payload (getDiscoveryInfo()), which share the same "System Identity" field in DiscoverySchema. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 824a996 commit 98ea344

6 files changed

Lines changed: 266 additions & 4 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): `version` on `GET /health` and the discovery payload is derived, not a hardcoded `'1.0.0'` literal (#10993)
6+
7+
Both `HttpDispatcher`'s `/health` handler and its discovery payload
8+
(`getDiscoveryInfo()`, served at `/`, `/discovery`, and everywhere else
9+
`DiscoverySchema` is answered) reported a literal `version: '1.0.0'` —
10+
unrelated to the package, the build, or anything actually running, and
11+
identical on every deployment (single-env, EE, every hosted tenant). A field
12+
that looks authoritative and lies is worse than no field: `cloud`
13+
(objectstack-ai/cloud#1537) needed `/health` to name the serving artifact
14+
after a production container served a three-month-old image behind four
15+
green deploys, could not use it because the value never changed, and
16+
resorted to a container-stamped response header instead.
17+
18+
`HttpDispatcher` now resolves `version` once at construction
19+
(`resolveRuntimeVersion()`, `packages/runtime/src/runtime-version.ts`):
20+
21+
1. `OS_RUNTIME_VERSION`, if the host injects one (an image tag, a git sha, a
22+
release version) — the SAME env var `cloud-connection-plugin.ts` already
23+
reads for a device-bind approval URL, reused rather than inventing a
24+
second name for the same value.
25+
2. Otherwise, `@objectstack/runtime`'s own resolved `package.json` version.
26+
3. `'unknown'` only if both are unavailable — never a plausible-looking
27+
literal a caller could mistake for real identity.
28+
29+
The liveness contract is unchanged: `/health` still checks nothing beyond
30+
"this process is executing code" (framework#3756); this only changes where
31+
one field's VALUE comes from. No schema widening — `DiscoverySchema` already
32+
declared `version: z.string()`.

‎packages/runtime/src/http-dispatcher.root.test.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11

22
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import { createRequire } from 'node:module';
34
import { HttpDispatcher } from './http-dispatcher';
45
import { ObjectKernel } from '@objectstack/core';
56

7+
// [#10993] `version` on both the root ("") and `/discovery` payloads is no
8+
// longer the literal '1.0.0' — it is `@objectstack/runtime`'s own resolved
9+
// package version (no `OS_RUNTIME_VERSION` stamp injected below), read the
10+
// same way the production code does so this assertion can never silently
11+
// drift from what `resolveRuntimeVersion()` actually returns.
12+
const RUNTIME_PACKAGE_VERSION = (
13+
createRequire(import.meta.url)('../package.json') as { version: string }
14+
).version;
15+
616
describe('HttpDispatcher Root Handling', () => {
717
let kernel: ObjectKernel;
818
let dispatcher: HttpDispatcher;
919

1020
beforeEach(() => {
21+
// No build stamp injected — these assertions exercise the package-
22+
// version FALLBACK path of resolveRuntimeVersion() (#10993).
23+
delete process.env.OS_RUNTIME_VERSION;
24+
1125
// Mock minimal Kernel structure
1226
kernel = {
1327
services: {},
@@ -37,7 +51,7 @@ describe('HttpDispatcher Root Handling', () => {
3751
expect(data).toBeDefined();
3852
// getDiscoveryInfo returns 'name' not 'apiName'
3953
expect(data.name).toBe('ObjectOS');
40-
expect(data.version).toBe('1.0.0');
54+
expect(data.version).toBe(RUNTIME_PACKAGE_VERSION);
4155
expect(data.routes).toBeDefined();
4256
// Since we passed empty prefix in dispatch code (hardcoded), routes should be relative
4357
expect(data.routes.metadata).toBe('/meta');
@@ -54,7 +68,7 @@ describe('HttpDispatcher Root Handling', () => {
5468
const data = result.response?.body?.data;
5569
expect(data).toBeDefined();
5670
expect(data.name).toBe('ObjectOS');
57-
expect(data.version).toBe('1.0.0');
71+
expect(data.version).toBe(RUNTIME_PACKAGE_VERSION);
5872
expect(data.routes).toBeDefined();
5973
});
6074

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #10993 — `version` on `GET /health` and in the discovery payload
5+
* (`registerBuiltinDomains()` / `getDiscoveryInfo()` in `./http-dispatcher.ts`)
6+
* must be DERIVED: an injected `OS_RUNTIME_VERSION` stamp, falling back to
7+
* the resolved `@objectstack/runtime` package version — never the `'1.0.0'`
8+
* literal both sites hardcoded before this fix.
9+
*
10+
* Every assertion here drives the REAL `HttpDispatcher.dispatch()` path
11+
* (never the source text or `resolveRuntimeVersion()` in isolation), so it
12+
* fails if either handler stops actually reading the derived value — the
13+
* anti-vacuity requirement #10993 calls out by name. A reverse-verification
14+
* pass (temporarily restoring the `'1.0.0'` literal on both sites) confirmed
15+
* this file goes red at exactly that restoration and names the failing site
16+
* — see the PR description for the transcript; that step is deliberately
17+
* NOT encoded as a test here, since a test cannot un-revert itself mid-run.
18+
*/
19+
20+
import { describe, it, expect, vi, afterEach } from 'vitest';
21+
import { createRequire } from 'node:module';
22+
import { HttpDispatcher } from './http-dispatcher';
23+
import { ObjectKernel } from '@objectstack/core';
24+
25+
/**
26+
* The real fallback value, read the same way `resolveRuntimeVersion()` reads
27+
* it — so this assertion tracks the production code instead of independently
28+
* guessing a version string that could drift from it.
29+
*/
30+
const RUNTIME_PACKAGE_VERSION = (
31+
createRequire(import.meta.url)('../package.json') as { version: string }
32+
).version;
33+
34+
function newDispatcher(): HttpDispatcher {
35+
const kernel = {
36+
services: {},
37+
context: { getService: vi.fn() },
38+
} as unknown as ObjectKernel;
39+
return new HttpDispatcher(kernel);
40+
}
41+
42+
async function healthVersion(dispatcher: HttpDispatcher): Promise<unknown> {
43+
const res = await dispatcher.dispatch('GET', '/health', undefined, undefined, {} as any);
44+
return res.response?.body?.data?.version;
45+
}
46+
47+
async function discoveryVersion(dispatcher: HttpDispatcher): Promise<unknown> {
48+
const res = await dispatcher.dispatch('GET', '/discovery', undefined, {}, {} as any);
49+
return res.response?.body?.data?.version;
50+
}
51+
52+
describe('HttpDispatcher — served `version` is derived, not a literal (#10993)', () => {
53+
const ORIGINAL_STAMP = process.env.OS_RUNTIME_VERSION;
54+
55+
afterEach(() => {
56+
if (ORIGINAL_STAMP === undefined) delete process.env.OS_RUNTIME_VERSION;
57+
else process.env.OS_RUNTIME_VERSION = ORIGINAL_STAMP;
58+
});
59+
60+
// A value with no plausible relationship to '1.0.0' or the package
61+
// version — if either handler answered anything else, it would prove
62+
// the handler is not actually reading the injected stamp.
63+
const INJECTED_STAMP = 'stamp-9f3c7a1-cloud1537-could-not-be-a-coincidence';
64+
65+
it('serves the injected OS_RUNTIME_VERSION stamp verbatim on /health', async () => {
66+
process.env.OS_RUNTIME_VERSION = INJECTED_STAMP;
67+
const dispatcher = newDispatcher();
68+
69+
expect(await healthVersion(dispatcher)).toBe(INJECTED_STAMP);
70+
});
71+
72+
it('serves the SAME injected stamp on the discovery payload — one derived source, two callers', async () => {
73+
process.env.OS_RUNTIME_VERSION = INJECTED_STAMP;
74+
const dispatcher = newDispatcher();
75+
76+
expect(await discoveryVersion(dispatcher)).toBe(INJECTED_STAMP);
77+
});
78+
79+
it('falls back to the resolved package version — not a literal, not undefined — when no stamp is injected', async () => {
80+
delete process.env.OS_RUNTIME_VERSION;
81+
const dispatcher = newDispatcher();
82+
83+
const health = await healthVersion(dispatcher);
84+
const discovery = await discoveryVersion(dispatcher);
85+
86+
expect(health).toBe(RUNTIME_PACKAGE_VERSION);
87+
expect(discovery).toBe(RUNTIME_PACKAGE_VERSION);
88+
expect(health).not.toBe('1.0.0');
89+
expect(health).not.toBeUndefined();
90+
});
91+
92+
it('resolves the stamp/fallback ONCE at construction — a later env change does not retroactively affect an already-constructed dispatcher', async () => {
93+
delete process.env.OS_RUNTIME_VERSION;
94+
const dispatcher = newDispatcher();
95+
expect(await healthVersion(dispatcher)).toBe(RUNTIME_PACKAGE_VERSION);
96+
97+
process.env.OS_RUNTIME_VERSION = 'late-stamp-must-not-apply-to-the-existing-instance';
98+
expect(await healthVersion(dispatcher)).toBe(RUNTIME_PACKAGE_VERSION);
99+
100+
// A freshly constructed dispatcher DOES pick up the now-set stamp —
101+
// confirms the previous assertion is about construction-time
102+
// resolution, not about the stamp never being read at all.
103+
expect(await healthVersion(newDispatcher())).toBe('late-stamp-must-not-apply-to-the-existing-instance');
104+
});
105+
});

‎packages/runtime/src/http-dispatcher.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } f
99
import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts';
1010
import { readServiceSelfInfo, DispatcherErrorCode, resolveDiscoveryEnvironment } from '@objectstack/spec/api';
1111
import { apiErrorResponse } from './error-envelope.js';
12+
import { resolveRuntimeVersion } from './runtime-version.js';
1213
import type { ExecutionContext } from '@objectstack/spec/kernel';
1314
import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from './domain-handler-registry.js';
1415
// `import * as actionExec from './action-execution.js'` was dropped in #4936:
@@ -258,6 +259,16 @@ export class HttpDispatcher {
258259
* `this.kernel` (#5155).
259260
*/
260261
private readonly defaultKernel: ObjectKernel;
262+
/**
263+
* The value served as `version` on `GET /health` and in the discovery
264+
* payload (#10993) — an injected `OS_RUNTIME_VERSION` stamp, falling back
265+
* to the resolved `@objectstack/runtime` package version. Resolved ONCE
266+
* here, at construction, not per request: it names the running process,
267+
* which cannot change over the dispatcher's lifetime. See
268+
* {@link resolveRuntimeVersion} for the derivation and the fallback
269+
* order.
270+
*/
271+
private readonly runtimeVersion: string;
261272
private defaultProject?: { environmentId: string; orgId?: string };
262273
private kernelResolver?: KernelResolver;
263274
private scopeManager?: EnvironmentScopeManager;
@@ -304,6 +315,7 @@ export class HttpDispatcher {
304315
*/
305316
constructor(kernel: ObjectKernel, _envRegistryIgnored?: unknown, options?: HttpDispatcherOptions) {
306317
this.defaultKernel = kernel;
318+
this.runtimeVersion = resolveRuntimeVersion();
307319
const resolveService = (name: string): any => {
308320
try { return (kernel as any).getService?.(name); } catch { return undefined; }
309321
};
@@ -558,7 +570,7 @@ export class HttpDispatcher {
558570
response: this.success({
559571
status: 'ok',
560572
timestamp: new Date().toISOString(),
561-
version: '1.0.0',
573+
version: this.runtimeVersion,
562574
uptime: typeof process !== 'undefined' ? process.uptime() : undefined,
563575
}),
564576
}),
@@ -1407,7 +1419,13 @@ export class HttpDispatcher {
14071419

14081420
return {
14091421
name: 'ObjectOS',
1410-
version: '1.0.0',
1422+
// [#10993] Was a hardcoded '1.0.0' literal — the identical defect
1423+
// as `/health`'s `version` (same field name, same "System
1424+
// Identity" contract in `DiscoverySchema`, same artifact-identity
1425+
// purpose), fixed the same way: {@link resolveRuntimeVersion},
1426+
// resolved once at dispatcher construction. See
1427+
// `packages/runtime/src/runtime-version.ts`.
1428+
version: this.runtimeVersion,
14111429
// [#4828] Mapped, not passed through. `DiscoverySchema.environment`
14121430
// is an ENUM (`production|sandbox|development`) and this used to be
14131431
// `getEnv('NODE_ENV', 'development')` raw — so `NODE_ENV=test` (what
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Resolves the value `HttpDispatcher` serves as `version` on `GET /health`
5+
* and in the discovery payload (`registerBuiltinDomains()` /
6+
* `getDiscoveryInfo()` in `./http-dispatcher.ts`).
7+
*
8+
* #10993 — both surfaces served a hardcoded `'1.0.0'` literal, unrelated to
9+
* the package, the build, or anything else actually running. cloud#1537
10+
* needed the SERVING PROCESS to name the artifact it runs (a production
11+
* container served a three-month-old image behind four green deploys, and
12+
* boot-freshness checks alone could not see it); `/health` was the natural
13+
* home but the field never changed, so cloud stamped its own response
14+
* header (`x-objectstack-build-sha`) instead. Fixed per the #10993 triage
15+
* ruling: an injected build stamp / env var, read once at dispatcher
16+
* construction, falling back to the resolved `@objectstack/runtime` package
17+
* version — never a literal. The liveness contract itself is unchanged
18+
* (framework#3756): this file only supplies the VALUE, `/health` still
19+
* checks nothing beyond "this process is executing code".
20+
*
21+
* `OS_RUNTIME_VERSION` is not a new name: `cloud-connection-plugin.ts`
22+
* already reads it (with a per-plugin literal fallback) to name the runtime
23+
* in a device-bind approval URL and the cloud-side bind payload. Reusing it
24+
* here — with a real package-version fallback instead of a fixed string —
25+
* keeps one env var meaning one thing across the codebase, per AGENTS.md
26+
* Prime Directive #9's `OS_{DOMAIN}_{NAME}` config-value shape (`RUNTIME` is
27+
* the domain, `VERSION` the value; no `_ENABLED`/`_ALLOW_`/`_SKIP_` shape
28+
* applies — this is a value, not a flag or an escape hatch).
29+
*/
30+
31+
import { createRequire } from 'node:module';
32+
import { getEnv } from '@objectstack/core';
33+
34+
/**
35+
* `null` = not yet resolved: `undefined` (a legitimate resolved outcome —
36+
* the read failed) would compare equal to "unset" if used as the sentinel.
37+
*/
38+
let cachedPackageVersion: string | undefined | null = null;
39+
40+
/**
41+
* `@objectstack/runtime`'s own installed version, read from its
42+
* `package.json`. That file sits one directory above both `src/` (tests
43+
* running against source) and the bundled `dist/index.{js,cjs}` (tsup,
44+
* single-entry, `splitting: false`, so the whole package collapses into one
45+
* file per format) — `../package.json` resolves the same package.json from
46+
* either shape. `createRequire` (not a static `import … with { type:
47+
* "json" }`) matches the resolution style already used for this exact kind
48+
* of read elsewhere in the repo (`packages/cli/src/utils/spec-version.ts`)
49+
* and needs no JSON-module-assertion support from the build target.
50+
*/
51+
function resolvePackageVersion(): string | undefined {
52+
if (cachedPackageVersion !== null) return cachedPackageVersion;
53+
try {
54+
const require = createRequire(import.meta.url);
55+
const pkg = require('../package.json') as { version?: unknown };
56+
cachedPackageVersion = typeof pkg.version === 'string' && pkg.version.length > 0
57+
? pkg.version
58+
: undefined;
59+
} catch {
60+
cachedPackageVersion = undefined;
61+
}
62+
return cachedPackageVersion;
63+
}
64+
65+
/**
66+
* The version this runtime process should report of itself.
67+
*
68+
* 1. `OS_RUNTIME_VERSION` — an operator/build-pipeline-injected stamp (image
69+
* tag, git sha, release version). Read live (not memoized): a construction
70+
* time env read is exactly what "at kernel construction" calls for, and
71+
* tests that set/unset the variable around constructing a fresh
72+
* `HttpDispatcher` must see it take effect without a stale cache.
73+
* 2. The resolved `@objectstack/runtime` package version, when no stamp was
74+
* injected.
75+
* 3. `'unknown'` — only if BOTH of the above are unavailable (the package's
76+
* own `package.json` is unreadable). Honest about not knowing, rather than
77+
* a plausible-looking literal a caller could mistake for real identity —
78+
* the exact failure mode #10993 exists to close.
79+
*/
80+
export function resolveRuntimeVersion(): string {
81+
return getEnv('OS_RUNTIME_VERSION') || resolvePackageVersion() || 'unknown';
82+
}

‎packages/runtime/tsup.config.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ export default defineConfig({
1010
dts: !process.env.OS_SKIP_DTS,
1111
format: ['esm', 'cjs'],
1212
target: 'es2020',
13+
// [#10993] `runtime-version.ts` reads its own `package.json` via
14+
// `createRequire(import.meta.url)` — correct as written for the ESM
15+
// output, but esbuild EMPTIES `import.meta` in a CJS bundle (measured:
16+
// `packages/lint`'s own dist already warns `[empty-import-meta]` on the
17+
// dependency that does this without the shim). `shims: true` makes tsup
18+
// rewrite `import.meta.url` in the CJS build to a real `__filename`-derived
19+
// value instead (its `assets/cjs_shims.js`), so `require('@objectstack/
20+
// runtime')` resolves the SAME package.json `dist/index.js` does. Need-based
21+
// injection — nothing here references `__dirname`/`__filename`, so the ESM
22+
// build's shim path is a no-op.
23+
shims: true,
1324
// Mark driver packages as external so they are resolved at runtime, not bundled
1425
external: [
1526
'@objectstack/driver-memory',

0 commit comments

Comments
 (0)