From db90da347e29df54e93a6739cd0a5c92ba3a78c7 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 23 Jul 2026 17:14:57 +0000 Subject: [PATCH] feat(869-c1): activation-capability probe (Part of #869) Part of #869 Mos (id-11) Gate-16 merge: independent 3-round APPROVE @c5a2bcc5, author id2 != approver id11, CI green wp1973. Co-authored-by: jason.woltje Co-committed-by: jason.woltje --- packages/mosaic/src/cli.ts | 5 + packages/mosaic/src/commands/launch.ts | 16 +- .../commands/lease-activation-probe.spec.ts | 243 ++++++++++++++++++ .../src/commands/lease-activation-probe.ts | 232 +++++++++++++++++ .../fail-closed-regression.spec.ts | 41 +++ 5 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 packages/mosaic/src/commands/lease-activation-probe.spec.ts create mode 100644 packages/mosaic/src/commands/lease-activation-probe.ts create mode 100644 packages/mosaic/src/mutator-gate/fail-closed-regression.spec.ts diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 4bccd371..0b884cac 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -21,6 +21,7 @@ import { registerRestoreCommand } from './commands/restore.js'; import { registerSkillCommand } from './commands/skill.js'; // prdy is registered via launch.ts import { registerLaunchCommands } from './commands/launch.js'; +import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js'; import { registerAuthCommand } from './commands/auth.js'; import { registerFederationCommand } from './commands/federation.js'; import { registerGatewayCommand } from './commands/gateway.js'; @@ -78,6 +79,10 @@ Command Groups: registerLaunchCommands(program); +// ─── lease activation capability probe (hidden; #869 Point-1 C1) ──────── + +registerLeaseCapabilityProbe(program); + // ─── login ────────────────────────────────────────────────────────────── program diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 2a696820..6f4e5041 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -806,7 +806,14 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev process.exit(0); // Unreachable but satisfies never } -function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string { +/** + * Resolve the lease broker's control socket path. Exported (in addition to + * being used internally by execLeaseGatedRuntime) so the C1 activation probe + * (lease-activation-probe.ts) can perform the same resolution when checking + * whether the broker supervisor is reachable — detection only, this never + * connects to the socket itself. + */ +export function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string { if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET']; const runtimeDir = env['XDG_RUNTIME_DIR']; if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock'); @@ -895,7 +902,12 @@ function delegateToScript(scriptPath: string, args: string[], env?: Record { + it('is false when the activation capability is absent (null)', () => { + const result = leaseEnforcementActivatable({ + getCapability: () => null, + probeSupervisor: () => presentSupervisor, + }); + expect(result).toBe(false); + }); + + it('is false when the activation capability name does not match', () => { + const result = leaseEnforcementActivatable({ + getCapability: () => ({ + name: 'some-other-capability', + version: LEASE_ACTIVATION_CAPABILITY.version, + }), + probeSupervisor: () => presentSupervisor, + }); + expect(result).toBe(false); + }); + + it('is false when the activation capability version is incompatible (stale/newer build)', () => { + const result = leaseEnforcementActivatable({ + getCapability: () => ({ + name: LEASE_ACTIVATION_CAPABILITY.name, + version: LEASE_ACTIVATION_CAPABILITY.version + 1, + }), + probeSupervisor: () => presentSupervisor, + }); + expect(result).toBe(false); + }); + + it('is false when the supervisor artifacts (launcher/daemon) are not present', () => { + const result = leaseEnforcementActivatable({ + getCapability: () => compatibleCapability, + probeSupervisor: () => ({ + supervisorPresent: false, + socketPath: presentSupervisor.socketPath, + }), + }); + expect(result).toBe(false); + }); + + it('is false when the supervisor socket path is not resolvable', () => { + const result = leaseEnforcementActivatable({ + getCapability: () => compatibleCapability, + probeSupervisor: () => ({ supervisorPresent: true, socketPath: null }), + }); + expect(result).toBe(false); + }); + + it('is false when BOTH capability and supervisor are absent', () => { + const result = leaseEnforcementActivatable({ + getCapability: () => null, + probeSupervisor: () => ({ supervisorPresent: false, socketPath: null }), + }); + expect(result).toBe(false); + }); + + it('is true when a compatible capability AND a resolvable supervisor are both present', () => { + const result = leaseEnforcementActivatable({ + getCapability: () => compatibleCapability, + probeSupervisor: () => presentSupervisor, + }); + expect(result).toBe(true); + }); + + it('uses the real default probes when no deps are injected (does not throw)', () => { + // No live broker / built CLI is guaranteed in a test environment, so this + // only asserts the predicate degrades to a safe boolean rather than + // throwing — the fail-closed behavior itself is covered by the injected + // cases above. + expect(() => leaseEnforcementActivatable()).not.toThrow(); + expect(typeof leaseEnforcementActivatable()).toBe('boolean'); + }); +}); + +describe('defaultCapabilityProbe', () => { + it('returns null (fail-closed) when no built CLI artifact is resolvable', () => { + // Deterministic regardless of ambient host state (e.g. a host that has + // already run `pnpm build`, which would otherwise make this pass or fail + // depending on whether dist/cli.js happens to exist) — inject a resolver + // pointing at a path that cannot exist, rather than relying on this + // checkout being unbuilt. The probe must report "no capability" rather + // than fabricate one from source-tree presence — this is the exact + // distinction #828's version skew needed: source existing is not the + // same as the published artifact advertising the capability. + const result = defaultCapabilityProbe({ + resolveCliEntry: () => '/nonexistent/mosaic-lease-activation-probe-test/cli.js', + }); + expect(result).toBeNull(); + }); + + describe('positive path — injected resolver, isolated scratch dir (never the real dist/)', () => { + // A prior version of this test staged the stub cli.js at the package's + // REAL resolved dist/ path and relied on afterEach to clean up "only + // what it created" — which meant a host with a real pre-built + // dist/cli.js (ordinary `pnpm build && pnpm test`) would have its real + // ~26KB compiled CLI silently overwritten by an 87-byte stub, with no + // restoration of the original content. That is exactly the kind of + // build-artifact corruption #869 exists to prevent. This version uses + // dependency injection exclusively: defaultCapabilityProbe() is never + // called with its default resolver here, so it can never touch the real + // package dist/ at all — proven below by asserting that path's + // existence is unchanged by the test. + it('returns the real {name, version} capability from a stub cli.js in a temp dir, and leaves the real dist/ untouched', () => { + const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const realDistDir = join(packageRoot, 'dist'); + const realDistPreexisted = existsSync(realDistDir); + + const scratchDir = mkdtempSync(join(tmpdir(), 'mosaic-lease-capability-probe-')); + try { + const scratchCliPath = join(scratchDir, 'cli.js'); + // Minimal stand-in for the built CLI's hidden __lease-capability + // subcommand — prints exactly what registerLeaseCapabilityProbe() + // wires the real `mosaic __lease-capability` command to print. + writeFileSync( + scratchCliPath, + `process.stdout.write(JSON.stringify(${JSON.stringify(LEASE_ACTIVATION_CAPABILITY)}));\n`, + ); + + const result = defaultCapabilityProbe({ resolveCliEntry: () => scratchCliPath }); + expect(result).toEqual(LEASE_ACTIVATION_CAPABILITY); + + // The real package dist/ must be byte-for-byte untouched: this test + // never invokes the default resolver, so the path's mere existence + // (created or not) must be unchanged by having run this test. + expect(existsSync(realDistDir)).toBe(realDistPreexisted); + } finally { + rmSync(scratchDir, { recursive: true, force: true }); + } + }); + }); +}); + +describe('defaultResolveCliEntry', () => { + it('resolves the bare "@mosaicstack/mosaic" specifier (the exported "." entry), never the non-exported "./package.json" subpath', () => { + // Fully isolated from the real filesystem/package state (no dependency + // on whether @mosaicstack/mosaic has been built on this host) via an + // injected fake resolver that mirrors Node's real behavior: the "." + // export resolves fine, but "./package.json" is NOT in package.json's + // `exports` map, so real `require.resolve` throws + // ERR_PACKAGE_PATH_NOT_EXPORTED for it. This is genuinely red-first + // against the reviewer-found bug: the old implementation resolved the + // "./package.json" subpath here, which this fake throws on — the new + // implementation must resolve only the bare specifier. + const requestedSpecifiers: string[] = []; + const fakeResolve = (specifier: string): string => { + requestedSpecifiers.push(specifier); + if (specifier === '@mosaicstack/mosaic') return '/fake/pkg/dist/index.js'; + throw new Error(`ERR_PACKAGE_PATH_NOT_EXPORTED: ${specifier}`); + }; + + const result = defaultResolveCliEntry(fakeResolve); + + expect(result).toBe(join('/fake/pkg/dist', 'cli.js')); + expect(requestedSpecifiers).toEqual(['@mosaicstack/mosaic']); + }); +}); + +describe('defaultSupervisorProbe', () => { + it('returns a well-shaped result without starting or connecting to anything', () => { + const result = defaultSupervisorProbe({}); + expect(typeof result.supervisorPresent).toBe('boolean'); + expect(result.socketPath === null || typeof result.socketPath === 'string').toBe(true); + }); + + it('resolves a socket path from an explicit MOSAIC_LEASE_BROKER_SOCKET override', () => { + const result = defaultSupervisorProbe({ MOSAIC_LEASE_BROKER_SOCKET: '/tmp/explicit.sock' }); + expect(result.socketPath).toBe('/tmp/explicit.sock'); + }); +}); + +describe('registerLeaseCapabilityProbe', () => { + it('registers a hidden subcommand named __lease-capability', () => { + const program = new Command(); + program.exitOverride(); + registerLeaseCapabilityProbe(program); + + const registered = program.commands.find((c) => c.name() === LEASE_CAPABILITY_PROBE_COMMAND); + expect(registered).toBeDefined(); + // Commander exposes "hidden" only as help-output suppression (no public + // getter) — assert the observable behavior instead of a private field. + expect(program.helpInformation()).not.toContain(LEASE_CAPABILITY_PROBE_COMMAND); + }); + + it('prints the capability constant as JSON when invoked', () => { + const program = new Command(); + program.exitOverride(); + registerLeaseCapabilityProbe(program); + + let written = ''; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string) => { + written += chunk; + return true; + }) as typeof process.stdout.write; + + try { + program.parse(['node', 'mosaic', LEASE_CAPABILITY_PROBE_COMMAND]); + } finally { + process.stdout.write = originalWrite; + } + + expect(JSON.parse(written)).toEqual(LEASE_ACTIVATION_CAPABILITY); + }); +}); diff --git a/packages/mosaic/src/commands/lease-activation-probe.ts b/packages/mosaic/src/commands/lease-activation-probe.ts new file mode 100644 index 00000000..7c634d22 --- /dev/null +++ b/packages/mosaic/src/commands/lease-activation-probe.ts @@ -0,0 +1,232 @@ +/** + * Lease-enforcement activation probe (issue #869, Point-1 card C1). + * + * Root cause this exists to guard against (#828 version skew): the + * ENFORCEMENT half of the lease broker (PreToolUse/Stop hooks — + * `mutator-gate.py`, `receipt-observer-client.py` — wired via the framework + * reseed) and the ACTIVATION half (`execLeaseGatedRuntime()` in `launch.ts`, + * which chains the runtime through `launch-runtime.py`, injects + * `MOSAIC_LEASE_*`, and requires a running `daemon.py` broker) ship on + * different channels. When the published CLI tarball lags behind an + * enforcement reseed, the gate correctly fails CLOSED on absent identity — + * but every tool call then denies with GATE_UNAVAILABLE. That fail-closed + * behavior is intentional and must not change (see the C-REGRESS note in + * `runtime_tools_unittest.py`); this module exists so a downstream + * install-ordering guard (C2, out of scope here) can refuse to WIRE + * enforcement in the first place on a host that cannot ACTIVATE it. + * + * `leaseEnforcementActivatable()` answers one narrow question: "if + * enforcement were wired right now, could activation actually satisfy it?" + * It is a real capability probe — not a "does the source file exist" check + * — and both of its inputs are injectable so tests can drive every branch + * without a live broker or an installed CLI on PATH. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import type { Command } from 'commander'; +import { defaultLeaseBrokerSocket, resolveTool } from './launch.js'; + +// ─── Capability signal (owned by the activation half) ────────────────────── + +/** + * Versioned identity for the activation contract `execLeaseGatedRuntime()` + * implements. OWNED by the activation half of the lease broker. Bump + * `version` only when the activation contract itself changes (env vars + * injected, chaining behavior, socket protocol, etc.) — deliberately + * independent of the package's npm semver, because #828 happened precisely + * because the npm version was NOT bumped even though the shipped artifact + * fell out of sync. A build that cannot advertise this exact + * `{ name, version }` pair does not implement the contract a caller is + * relying on, whatever its package.json claims. + */ +export interface LeaseActivationCapability { + readonly name: string; + readonly version: number; +} + +export const LEASE_ACTIVATION_CAPABILITY: LeaseActivationCapability = { + name: 'lease-runtime-activation', + version: 1, +}; + +/** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */ +export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability'; + +function capabilityMatches(candidate: LeaseActivationCapability | null): boolean { + return ( + candidate !== null && + candidate.name === LEASE_ACTIVATION_CAPABILITY.name && + candidate.version === LEASE_ACTIVATION_CAPABILITY.version + ); +} + +/** + * Register the hidden `__lease-capability` probe subcommand. Prints the + * capability this BUILD advertises as compact JSON to stdout and exits 0. + * Deliberately undocumented (hidden from `--help`): it is an internal signal + * for {@link defaultCapabilityProbe}, not a user-facing command. + */ +export function registerLeaseCapabilityProbe(program: Command): void { + program + .command(LEASE_CAPABILITY_PROBE_COMMAND, { hidden: true }) + .description('Internal: print the lease-activation capability this build advertises') + .action(() => { + process.stdout.write(JSON.stringify(LEASE_ACTIVATION_CAPABILITY)); + }); +} + +/** Injectable Node module resolver — matches `require.resolve`'s signature + * narrowly (specifier in, absolute path out, or throws). Defaults to the + * real `createRequire(import.meta.url).resolve`. Injectable so tests can + * exercise WHICH specifier {@link defaultResolveCliEntry} resolves (the + * reviewer-found bug was resolving the wrong one) without depending on + * whether `@mosaicstack/mosaic` has actually been built on the test host — + * and without ever touching the real package's `dist/` to find out. */ +export type ModuleResolver = (specifier: string) => string; + +/** + * Resolve the CLI's built entrypoint (`dist/cli.js`). Resolves via the + * package's "." export (already present in package.json's `exports` map) + * rather than a "./package.json" subpath — the latter is NOT exported, so + * `require.resolve('@mosaicstack/mosaic/package.json')` throws + * ERR_PACKAGE_PATH_NOT_EXPORTED on every real install. The "." export + * resolves to `dist/index.js`; `cli.js` is its sibling in the same built + * `dist/` directory (see package.json's `bin.mosaic`). + * + * Exported standalone (and injectable via {@link CapabilityProbeDeps}) so + * tests can exercise this resolution logic in isolation, or point + * {@link defaultCapabilityProbe} at a scratch directory instead of ever + * touching the real installed package's `dist/` — a test corrupting a real + * build artifact is exactly the artifact-integrity failure class this card + * exists to prevent (#828). + */ +export function defaultResolveCliEntry( + resolve: ModuleResolver = createRequire(import.meta.url).resolve, +): string { + const mainEntry = resolve('@mosaicstack/mosaic'); + return join(dirname(mainEntry), 'cli.js'); +} + +/** Injectable inputs for {@link defaultCapabilityProbe}. */ +export interface CapabilityProbeDeps { + /** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to + * {@link defaultResolveCliEntry}. Inject to point at an isolated scratch + * location in tests — never at the real package's `dist/`. */ + resolveCliEntry?: () => string; +} + +/** + * Real capability lookup. Resolves the installed `@mosaicstack/mosaic` + * package's BUILT entrypoint (`dist/cli.js` — the published artifact a user + * actually runs, not this TypeScript source file) and executes its hidden + * `__lease-capability` probe subcommand out-of-process. A build that lacks + * the subcommand, fails to execute, or reports an incompatible + * `{ name, version }` is treated as having NO activation capability. + * + * This is the check that would have caught #828's version skew: the + * source-tree activation half existed, but the published tarball's `dist/` + * did not carry it, so this probe — reading the actually-resolvable built + * artifact rather than trusting source-tree presence — would report null. + */ +export function defaultCapabilityProbe( + deps: CapabilityProbeDeps = {}, +): LeaseActivationCapability | null { + try { + const resolveCliEntry = deps.resolveCliEntry ?? defaultResolveCliEntry; + const cliEntry = resolveCliEntry(); + if (!existsSync(cliEntry)) return null; + + const output = execFileSync(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], { + encoding: 'utf-8', + timeout: 2000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + + const parsed: unknown = JSON.parse(output); + if ( + typeof parsed !== 'object' || + parsed === null || + typeof (parsed as Record)['name'] !== 'string' || + typeof (parsed as Record)['version'] !== 'number' + ) { + return null; + } + const candidate = parsed as { name: string; version: number }; + return { name: candidate.name, version: candidate.version }; + } catch { + return null; + } +} + +// ─── Supervisor / socket resolution (detection only) ─────────────────────── + +/** Detection-only supervisor/socket probe result. Never starts the broker + * and never connects to the socket — presence and path resolution only. */ +export interface SupervisorProbeResult { + /** The lease-broker supervisor artifacts (launcher + daemon) are present. */ + readonly supervisorPresent: boolean; + /** Resolved broker socket path, or null if it could not be resolved. */ + readonly socketPath: string | null; +} + +/** + * Real supervisor/socket resolution: checks that the lease-broker's launcher + * (`launch-runtime.py`) and supervisor (`daemon.py`) artifacts resolve on + * disk via the same tool-resolution `execLeaseGatedRuntime()` uses, and that + * a broker socket path resolves via the same logic as + * `defaultLeaseBrokerSocket()`. Detection only — this never starts the + * daemon and never connects to the socket. + */ +export function defaultSupervisorProbe( + env: NodeJS.ProcessEnv = process.env, +): SupervisorProbeResult { + const launcherPath = resolveTool('lease-broker', 'launch-runtime.py'); + const daemonPath = resolveTool('lease-broker', 'daemon.py'); + const supervisorPresent = existsSync(launcherPath) && existsSync(daemonPath); + + let socketPath: string | null = null; + try { + const resolved = defaultLeaseBrokerSocket(env); + socketPath = resolved.trim().length > 0 ? resolved : null; + } catch { + socketPath = null; + } + + return { supervisorPresent, socketPath }; +} + +// ─── Predicate ─────────────────────────────────────────────────────────── + +/** Injectable inputs for {@link leaseEnforcementActivatable}, so tests (and + * downstream callers such as the C2 install-ordering guard) can drive every + * branch without a live broker or an installed CLI on PATH. */ +export interface ActivationProbeDeps { + getCapability?: () => LeaseActivationCapability | null; + probeSupervisor?: () => SupervisorProbeResult; +} + +/** + * True IFF lease enforcement can actually be ACTIVATED on this host: + * + * (a) the resolvable CLI advertises a {@link LeaseActivationCapability} + * compatible with {@link LEASE_ACTIVATION_CAPABILITY}, AND + * (b) the broker supervisor is resolvable — launcher + `daemon.py` + * artifacts present AND a broker socket path resolves. + * + * Pure/testable: both probes default to the real, side-effect-free lookups + * above but can be injected, so this predicate never itself starts a broker + * or performs enforcement — it only reports whether activation *could* + * satisfy enforcement if wired. + */ +export function leaseEnforcementActivatable(deps: ActivationProbeDeps = {}): boolean { + const getCapability = deps.getCapability ?? defaultCapabilityProbe; + const probeSupervisor = deps.probeSupervisor ?? defaultSupervisorProbe; + + if (!capabilityMatches(getCapability())) return false; + + const supervisor = probeSupervisor(); + return supervisor.supervisorPresent && supervisor.socketPath !== null; +} diff --git a/packages/mosaic/src/mutator-gate/fail-closed-regression.spec.ts b/packages/mosaic/src/mutator-gate/fail-closed-regression.spec.ts new file mode 100644 index 00000000..9e6b1420 --- /dev/null +++ b/packages/mosaic/src/mutator-gate/fail-closed-regression.spec.ts @@ -0,0 +1,41 @@ +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** + * C-REGRESS (issue #869, Point-1) — proves the fail-closed gate is untouched + * by the C1 activation probe added alongside this test. + * + * `mutator-gate.py`'s fail-closed-on-absent-identity behavior is INTENTIONAL + * and TEST-LOCKED: #869 C1 gates the WIRING decision for enforcement (via + * `leaseEnforcementActivatable()`), it does not — and must not — touch the + * gate's own runtime denial behavior. This spec runs the two test-locked + * cases from `runtime_tools_unittest.py` directly (rather than merely + * re-asserting the same logic in TypeScript) so a regression in the actual + * Python gate is caught here too, not just documented in prose. + */ + +const MUTATOR_GATE_DIR = new URL('.', import.meta.url).pathname; +const UNITTEST_FILE = join(MUTATOR_GATE_DIR, 'runtime_tools_unittest.py'); + +const LOCKED_TEST_CASES = [ + 'ExecutableEntrypointTest.test_gate_entrypoint_denies_when_identity_environment_is_absent', + 'MutatorGateTest.test_environment_generation_and_request_failures_deny', +] as const; + +describe('mutator-gate fail-closed behavior (C-REGRESS, unchanged by #869 C1)', () => { + it.each(LOCKED_TEST_CASES)('%s still passes', (testCase) => { + const result = spawnSync('python3', ['-m', 'unittest', `${moduleName()}.${testCase}`, '-v'], { + cwd: MUTATOR_GATE_DIR, + encoding: 'utf-8', + }); + + expect(result.status, `stderr:\n${result.stderr}`).toBe(0); + }); +}); + +function moduleName(): string { + // runtime_tools_unittest.py, addressed as a bare module name for `python3 -m unittest`. + return UNITTEST_FILE.split('/').pop()!.replace(/\.py$/, ''); +}