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', () => { + // This source checkout has no dist/cli.js built for @mosaicstack/mosaic, + // so 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. + expect(defaultCapabilityProbe()).toBeNull(); + }); +}); + +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..9507561a --- /dev/null +++ b/packages/mosaic/src/commands/lease-activation-probe.ts @@ -0,0 +1,191 @@ +/** + * 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)); + }); +} + +/** + * 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(): LeaseActivationCapability | null { + try { + const req = createRequire(import.meta.url); + const pkgJsonPath = req.resolve('@mosaicstack/mosaic/package.json'); + const cliEntry = join(dirname(pkgJsonPath), 'dist', 'cli.js'); + 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$/, ''); +}