/** * 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; }