forked from mosaicstack/stack
Adds a real activation-capability probe so a downstream install-ordering guard (C2, out of scope here) can refuse to wire lease-broker enforcement (PreToolUse/Stop hooks) on a host that cannot actually activate it — the root cause of #828's version-skew brick, where the published CLI tarball lagged the enforcement reseed and every tool call denied with GATE_UNAVAILABLE. - LEASE_ACTIVATION_CAPABILITY {name, version}: versioned signal owned by the activation half (execLeaseGatedRuntime), independent of npm semver. - Hidden `mosaic __lease-capability` subcommand: prints that capability from the actually-resolvable built CLI artifact, not source-tree presence. - leaseEnforcementActivatable(): pure predicate, true iff a compatible capability is advertised AND the broker supervisor (launcher + daemon.py artifacts, socket path) resolves. Detection only — never starts the broker. Both inputs are injectable for testing. - C-REGRESS: added a vitest spec that runs the two test-locked fail-closed gate cases in runtime_tools_unittest.py directly, proving the gate's fail-closed-on-absent-identity behavior is unchanged by this card. Part of #869 (Point-1 C1). Co-Authored-By: Claude Opus 4.8 <[email protected]>
192 lines
8.4 KiB
TypeScript
192 lines
8.4 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));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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<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;
|
|
}
|