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 <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
/**
|
|
* 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<string, unknown>)['name'] !== 'string' ||
|
|
typeof (parsed as Record<string, unknown>)['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;
|
|
}
|