Compare commits
2 Commits
feat/869-c
...
feat/869-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75235ef823 | ||
|
|
b4d26abacd |
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Mosaic lease broker daemon (framework tools/lease-broker/daemon.py)
|
||||||
|
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
||||||
|
After=default.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# The broker socket lives under the runtime directory so it disappears with
|
||||||
|
# the user session instead of surviving as stale state across logins.
|
||||||
|
# daemon.py's secure_parent() fails closed unless this directory is exactly
|
||||||
|
# 0700, so RuntimeDirectoryMode is not cosmetic.
|
||||||
|
RuntimeDirectory=mosaic-lease
|
||||||
|
RuntimeDirectoryMode=0700
|
||||||
|
# Remove loader and noninteractive-shell controls before ExecStart loads env,
|
||||||
|
# matching the tmux fleet units in this same directory.
|
||||||
|
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||||
|
ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin XDG_RUNTIME_DIR=%t /bin/bash --noprofile --norc %h/.config/mosaic/tools/lease-broker/start-lease-broker.sh
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
31
packages/mosaic/framework/tools/lease-broker/start-lease-broker.sh
Executable file
31
packages/mosaic/framework/tools/lease-broker/start-lease-broker.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Supervisor entry point for the Mosaic lease broker daemon (issue #869, C3).
|
||||||
|
#
|
||||||
|
# Resolves the broker socket path with the SAME precedence as
|
||||||
|
# `defaultLeaseBrokerSocket` in `packages/mosaic/src/commands/launch.ts`, so a
|
||||||
|
# gated runtime launched through that client always finds the socket this
|
||||||
|
# supervisor creates:
|
||||||
|
# 1. an explicit MOSAIC_LEASE_BROKER_SOCKET
|
||||||
|
# 2. "$XDG_RUNTIME_DIR/mosaic-lease/broker.sock"
|
||||||
|
# 3. "/run/user/<uid>/mosaic-lease/broker.sock"
|
||||||
|
#
|
||||||
|
# The state file is colocated next to the socket (same directory,
|
||||||
|
# "state.json"), mirroring how the broker already colocates its per-session
|
||||||
|
# generation files beside the socket.
|
||||||
|
#
|
||||||
|
# This script never installs, enables, or starts the systemd unit that calls
|
||||||
|
# it; it is only ever invoked BY that unit (or by a human/test harness that
|
||||||
|
# passes its own HOME/XDG_RUNTIME_DIR).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
|
||||||
|
if [ -n "${MOSAIC_LEASE_BROKER_SOCKET:-}" ]; then
|
||||||
|
SOCKET="$MOSAIC_LEASE_BROKER_SOCKET"
|
||||||
|
else
|
||||||
|
RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||||
|
SOCKET="$RUNTIME_DIR/mosaic-lease/broker.sock"
|
||||||
|
fi
|
||||||
|
STATE="$(dirname -- "$SOCKET")/state.json"
|
||||||
|
|
||||||
|
exec python3 "$SCRIPT_DIR/daemon.py" --socket "$SOCKET" --state "$STATE"
|
||||||
@@ -21,7 +21,6 @@ import { registerRestoreCommand } from './commands/restore.js';
|
|||||||
import { registerSkillCommand } from './commands/skill.js';
|
import { registerSkillCommand } from './commands/skill.js';
|
||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
import { registerFederationCommand } from './commands/federation.js';
|
import { registerFederationCommand } from './commands/federation.js';
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
@@ -79,10 +78,6 @@ Command Groups:
|
|||||||
|
|
||||||
registerLaunchCommands(program);
|
registerLaunchCommands(program);
|
||||||
|
|
||||||
// ─── lease activation capability probe (hidden; #869 Point-1 C1) ────────
|
|
||||||
|
|
||||||
registerLeaseCapabilityProbe(program);
|
|
||||||
|
|
||||||
// ─── login ──────────────────────────────────────────────────────────────
|
// ─── login ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
@@ -806,14 +806,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
process.exit(0); // Unreachable but satisfies never
|
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'];
|
if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET'];
|
||||||
const runtimeDir = env['XDG_RUNTIME_DIR'];
|
const runtimeDir = env['XDG_RUNTIME_DIR'];
|
||||||
if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock');
|
if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock');
|
||||||
@@ -902,12 +895,7 @@ function delegateToScript(scriptPath: string, args: string[], env?: Record<strin
|
|||||||
* bundled in the @mosaicstack/mosaic npm package (always matches the installed
|
* bundled in the @mosaicstack/mosaic npm package (always matches the installed
|
||||||
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
|
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
|
||||||
*/
|
*/
|
||||||
/**
|
function resolveTool(...segments: string[]): string {
|
||||||
* Exported so the C1 activation probe (lease-activation-probe.ts) can resolve
|
|
||||||
* the same lease-broker launcher/daemon artifacts execLeaseGatedRuntime()
|
|
||||||
* uses, for detection-only supervisor presence checks.
|
|
||||||
*/
|
|
||||||
export function resolveTool(...segments: string[]): string {
|
|
||||||
try {
|
try {
|
||||||
const req = createRequire(import.meta.url);
|
const req = createRequire(import.meta.url);
|
||||||
const mosaicPkg = dirname(req.resolve('@mosaicstack/mosaic/package.json'));
|
const mosaicPkg = dirname(req.resolve('@mosaicstack/mosaic/package.json'));
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import {
|
|
||||||
LEASE_ACTIVATION_CAPABILITY,
|
|
||||||
LEASE_CAPABILITY_PROBE_COMMAND,
|
|
||||||
defaultCapabilityProbe,
|
|
||||||
defaultResolveCliEntry,
|
|
||||||
defaultSupervisorProbe,
|
|
||||||
leaseEnforcementActivatable,
|
|
||||||
registerLeaseCapabilityProbe,
|
|
||||||
type LeaseActivationCapability,
|
|
||||||
type SupervisorProbeResult,
|
|
||||||
} from './lease-activation-probe.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Red-first tests for issue #869 Point-1 C1 — leaseEnforcementActivatable().
|
|
||||||
*
|
|
||||||
* Root cause under test: #828 shipped the lease broker's ENFORCEMENT half
|
|
||||||
* (hooks) and ACTIVATION half (execLeaseGatedRuntime + a running daemon.py
|
|
||||||
* broker) on different channels, and they drifted — the published CLI
|
|
||||||
* tarball lacked the activation half even though it existed in source. The
|
|
||||||
* predicate here must say NO when either half of activation is unavailable,
|
|
||||||
* and only YES when both are genuinely present — never based on "does the
|
|
||||||
* source file exist", but on a real capability signal + real supervisor
|
|
||||||
* detection.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const compatibleCapability: LeaseActivationCapability = { ...LEASE_ACTIVATION_CAPABILITY };
|
|
||||||
const presentSupervisor: SupervisorProbeResult = {
|
|
||||||
supervisorPresent: true,
|
|
||||||
socketPath: '/run/user/1000/mosaic-lease/broker.sock',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('leaseEnforcementActivatable', () => {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
237
packages/mosaic/src/lease-broker/broker-supervisor.spec.ts
Normal file
237
packages/mosaic/src/lease-broker/broker-supervisor.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { createServer, type Server } from 'node:net';
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyBrokerSupervisor,
|
||||||
|
checkBrokerSupervisorHealth,
|
||||||
|
isBrokerSupervisorHealthy,
|
||||||
|
resolveBrokerSupervisorPaths,
|
||||||
|
resolveLeaseBrokerSocketPath,
|
||||||
|
type BrokerSupervisorPaths,
|
||||||
|
} from './broker-supervisor.js';
|
||||||
|
|
||||||
|
const REAL_FRAMEWORK_ROOT = new URL('../../framework/', import.meta.url).pathname;
|
||||||
|
|
||||||
|
const cleanupDirs: string[] = [];
|
||||||
|
const cleanupServers: Server[] = [];
|
||||||
|
|
||||||
|
async function tempDir(prefix: string): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||||
|
cleanupDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const server of cleanupServers.splice(0)) {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
for (const dir of cleanupDirs.splice(0)) {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveLeaseBrokerSocketPath', () => {
|
||||||
|
it('honors an explicit MOSAIC_LEASE_BROKER_SOCKET override', () => {
|
||||||
|
expect(resolveLeaseBrokerSocketPath({ MOSAIC_LEASE_BROKER_SOCKET: '/tmp/explicit.sock' })).toBe(
|
||||||
|
'/tmp/explicit.sock',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to $XDG_RUNTIME_DIR/mosaic-lease/broker.sock', () => {
|
||||||
|
expect(resolveLeaseBrokerSocketPath({ XDG_RUNTIME_DIR: '/run/user/1000' })).toBe(
|
||||||
|
join('/run/user/1000', 'mosaic-lease', 'broker.sock'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to /run/user/<uid>/mosaic-lease/broker.sock as a last resort', () => {
|
||||||
|
expect(resolveLeaseBrokerSocketPath({}, 4242)).toBe(
|
||||||
|
join('/run/user', '4242', 'mosaic-lease', 'broker.sock'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the explicit override over XDG_RUNTIME_DIR', () => {
|
||||||
|
expect(
|
||||||
|
resolveLeaseBrokerSocketPath({
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET: '/explicit.sock',
|
||||||
|
XDG_RUNTIME_DIR: '/run/user/1000',
|
||||||
|
}),
|
||||||
|
).toBe('/explicit.sock');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveBrokerSupervisorPaths', () => {
|
||||||
|
it('colocates the state file next to the resolved socket', () => {
|
||||||
|
const paths = resolveBrokerSupervisorPaths({
|
||||||
|
mosaicHome: '/home/x/.config/mosaic',
|
||||||
|
frameworkRoot: '/repo/framework',
|
||||||
|
env: { XDG_RUNTIME_DIR: '/run/user/1000' },
|
||||||
|
});
|
||||||
|
expect(paths.socketPath).toBe(join('/run/user/1000', 'mosaic-lease', 'broker.sock'));
|
||||||
|
expect(paths.statePath).toBe(join('/run/user/1000', 'mosaic-lease', 'state.json'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('targets the systemd --user dir under the given home, not mosaicHome', () => {
|
||||||
|
const paths = resolveBrokerSupervisorPaths({
|
||||||
|
mosaicHome: '/somewhere-else/.config/mosaic',
|
||||||
|
frameworkRoot: '/repo/framework',
|
||||||
|
homeDir: '/home/canary',
|
||||||
|
env: {},
|
||||||
|
uid: 0,
|
||||||
|
});
|
||||||
|
expect(paths.systemdUserDir).toBe(join('/home/canary', '.config', 'systemd', 'user'));
|
||||||
|
expect(paths.unitTargetPath).toBe(
|
||||||
|
join('/home/canary', '.config', 'systemd', 'user', 'mosaic-lease-broker.service'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a pure function: identical options resolve to identical paths', () => {
|
||||||
|
const options = {
|
||||||
|
mosaicHome: '/h/.config/mosaic',
|
||||||
|
frameworkRoot: '/repo/framework',
|
||||||
|
env: { XDG_RUNTIME_DIR: '/run/user/1000' },
|
||||||
|
};
|
||||||
|
expect(resolveBrokerSupervisorPaths(options)).toEqual(resolveBrokerSupervisorPaths(options));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyBrokerSupervisor', () => {
|
||||||
|
async function fakePaths(): Promise<BrokerSupervisorPaths> {
|
||||||
|
const home = await tempDir('mosaic-broker-supervisor-home-');
|
||||||
|
const mosaicHome = join(home, '.config', 'mosaic');
|
||||||
|
const runtimeDir = await tempDir('mosaic-broker-supervisor-runtime-');
|
||||||
|
return resolveBrokerSupervisorPaths({
|
||||||
|
mosaicHome,
|
||||||
|
frameworkRoot: REAL_FRAMEWORK_ROOT,
|
||||||
|
homeDir: home,
|
||||||
|
env: { XDG_RUNTIME_DIR: runtimeDir },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders a unit that references the installed wrapper script and hardens the runtime dir', async () => {
|
||||||
|
const paths = await fakePaths();
|
||||||
|
const unitSource = await readFile(paths.unitSourcePath, 'utf8');
|
||||||
|
expect(unitSource).toContain('ExecStart=');
|
||||||
|
expect(unitSource).toContain('%h/.config/mosaic/tools/lease-broker/start-lease-broker.sh');
|
||||||
|
expect(unitSource).toContain('RuntimeDirectory=mosaic-lease');
|
||||||
|
expect(unitSource).toContain('RuntimeDirectoryMode=0700');
|
||||||
|
expect(unitSource).toContain('Restart=on-failure');
|
||||||
|
expect(unitSource).toContain('WantedBy=default.target');
|
||||||
|
// No ambient environment file preload, matching the other fleet units'
|
||||||
|
// strict-parsing convention.
|
||||||
|
expect(unitSource).not.toMatch(/^Environment(File)?=/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializes the unit, wrapper script, and daemon sources on first apply', async () => {
|
||||||
|
const paths = await fakePaths();
|
||||||
|
|
||||||
|
const result = await applyBrokerSupervisor(paths);
|
||||||
|
|
||||||
|
expect(result.installedFiles).toContain(paths.unitTargetPath);
|
||||||
|
expect(result.installedFiles).toContain(paths.wrapperTargetPath);
|
||||||
|
for (const target of paths.daemonTargetPaths) {
|
||||||
|
expect(result.installedFiles).toContain(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitTargetContent = await readFile(paths.unitTargetPath, 'utf8');
|
||||||
|
const unitSourceContent = await readFile(paths.unitSourcePath, 'utf8');
|
||||||
|
expect(unitTargetContent).toBe(unitSourceContent);
|
||||||
|
|
||||||
|
const wrapperMode = (await stat(paths.wrapperTargetPath)).mode & 0o777;
|
||||||
|
expect(wrapperMode).toBe(0o755);
|
||||||
|
|
||||||
|
for (const target of paths.daemonTargetPaths) {
|
||||||
|
await expect(stat(target)).resolves.toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent: applying twice reproduces identical files with no error', async () => {
|
||||||
|
const paths = await fakePaths();
|
||||||
|
|
||||||
|
await applyBrokerSupervisor(paths);
|
||||||
|
const firstUnit = await readFile(paths.unitTargetPath, 'utf8');
|
||||||
|
const firstWrapper = await readFile(paths.wrapperTargetPath, 'utf8');
|
||||||
|
const firstWrapperMode = (await stat(paths.wrapperTargetPath)).mode & 0o777;
|
||||||
|
|
||||||
|
await expect(applyBrokerSupervisor(paths)).resolves.toBeDefined();
|
||||||
|
|
||||||
|
const secondUnit = await readFile(paths.unitTargetPath, 'utf8');
|
||||||
|
const secondWrapper = await readFile(paths.wrapperTargetPath, 'utf8');
|
||||||
|
const secondWrapperMode = (await stat(paths.wrapperTargetPath)).mode & 0o777;
|
||||||
|
|
||||||
|
expect(secondUnit).toBe(firstUnit);
|
||||||
|
expect(secondWrapper).toBe(firstWrapper);
|
||||||
|
expect(secondWrapperMode).toBe(firstWrapperMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches the real host: only writes under the supplied temp dirs', async () => {
|
||||||
|
const paths = await fakePaths();
|
||||||
|
await applyBrokerSupervisor(paths);
|
||||||
|
expect(paths.systemdUserDir.startsWith(tmpdir())).toBe(true);
|
||||||
|
expect(paths.mosaicHome.startsWith(tmpdir())).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkBrokerSupervisorHealth / isBrokerSupervisorHealthy', () => {
|
||||||
|
async function fakeHealthPaths(): Promise<
|
||||||
|
Pick<BrokerSupervisorPaths, 'unitTargetPath' | 'socketPath'>
|
||||||
|
> {
|
||||||
|
const runtimeDir = await tempDir('mosaic-broker-supervisor-health-');
|
||||||
|
await mkdir(join(runtimeDir, 'systemd-user'), { recursive: true });
|
||||||
|
return {
|
||||||
|
unitTargetPath: join(runtimeDir, 'systemd-user', 'mosaic-lease-broker.service'),
|
||||||
|
socketPath: join(runtimeDir, 'broker.sock'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reports unhealthy when neither the unit nor the socket exist', async () => {
|
||||||
|
const paths = await fakeHealthPaths();
|
||||||
|
|
||||||
|
const health = await checkBrokerSupervisorHealth(paths);
|
||||||
|
|
||||||
|
expect(health).toEqual({ unitInstalled: false, socketPresent: false, healthy: false });
|
||||||
|
expect(await isBrokerSupervisorHealthy(paths)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unhealthy when the unit is installed but no socket is listening', async () => {
|
||||||
|
const paths = await fakeHealthPaths();
|
||||||
|
await writeFile(paths.unitTargetPath, '[Unit]\n');
|
||||||
|
|
||||||
|
const health = await checkBrokerSupervisorHealth(paths);
|
||||||
|
|
||||||
|
expect(health.unitInstalled).toBe(true);
|
||||||
|
expect(health.socketPresent).toBe(false);
|
||||||
|
expect(health.healthy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports healthy=true once a real Unix socket exists at the resolved path, and false again once removed', async () => {
|
||||||
|
const paths = await fakeHealthPaths();
|
||||||
|
|
||||||
|
const server = createServer();
|
||||||
|
cleanupServers.push(server);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(paths.socketPath, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await isBrokerSupervisorHealthy(paths)).toBe(true);
|
||||||
|
const health = await checkBrokerSupervisorHealth(paths);
|
||||||
|
expect(health.socketPresent).toBe(true);
|
||||||
|
expect(health.healthy).toBe(true);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
await rm(paths.socketPath, { force: true });
|
||||||
|
|
||||||
|
expect(await isBrokerSupervisorHealthy(paths)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not confuse a stale regular file at the socket path with a live socket', async () => {
|
||||||
|
const paths = await fakeHealthPaths();
|
||||||
|
await writeFile(paths.socketPath, 'not actually a socket');
|
||||||
|
|
||||||
|
expect(await isBrokerSupervisorHealthy(paths)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
223
packages/mosaic/src/lease-broker/broker-supervisor.ts
Normal file
223
packages/mosaic/src/lease-broker/broker-supervisor.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Activation-side supervisor for the Mosaic lease broker (issue #869, Point-1
|
||||||
|
* C3). #828 shipped fail-closed enforcement hooks (`mutator-gate.py`,
|
||||||
|
* `receipt-observer-client.py`) with nothing that guaranteed `daemon.py` was
|
||||||
|
* running or that its socket existed before a gated runtime started. This
|
||||||
|
* module:
|
||||||
|
*
|
||||||
|
* - resolves the broker socket/state paths and the on-disk locations of the
|
||||||
|
* supervisor artifacts, deterministically and consistently with
|
||||||
|
* `defaultLeaseBrokerSocket` in `../commands/launch.ts`;
|
||||||
|
* - idempotently applies (materializes) a systemd `--user` unit plus the
|
||||||
|
* wrapper script and daemon sources it execs, mirroring the tmux fleet
|
||||||
|
* unit convention in `framework/systemd/user/`;
|
||||||
|
* - exposes a health predicate other cards (e.g. the C1 activation probe)
|
||||||
|
* can call to learn whether a broker supervisor is present and healthy.
|
||||||
|
*
|
||||||
|
* `applyBrokerSupervisor` only writes files under the paths it is given. It
|
||||||
|
* never runs `systemctl`, never starts `daemon.py`, and never touches a real
|
||||||
|
* host's `~/.config` unless the caller explicitly resolves paths there.
|
||||||
|
* Enabling/starting the unit is a separate, later, out-of-scope step.
|
||||||
|
*/
|
||||||
|
import { chmod, copyFile, mkdir, stat } from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const UNIT_NAME = 'mosaic-lease-broker.service';
|
||||||
|
const WRAPPER_SCRIPT_NAME = 'start-lease-broker.sh';
|
||||||
|
|
||||||
|
/** Co-located modules `daemon.py` imports at runtime; kept alongside it. */
|
||||||
|
const DAEMON_SOURCE_FILE_NAMES = [
|
||||||
|
'daemon.py',
|
||||||
|
'lease_generation.py',
|
||||||
|
'normative_fragments.py',
|
||||||
|
'receipt_challenge.py',
|
||||||
|
'receipt_observer.py',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface ResolveBrokerSupervisorPathsOptions {
|
||||||
|
/** `~/.config/mosaic` (or an override) — where installed tool copies live. */
|
||||||
|
mosaicHome: string;
|
||||||
|
/** Root of the checked-out `framework/` directory (canonical file source). */
|
||||||
|
frameworkRoot: string;
|
||||||
|
/** Defaults to `process.env`; pass a fake for tests. */
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
/** Defaults to `os.homedir()`; pass a temp dir in tests. */
|
||||||
|
homeDir?: string;
|
||||||
|
/** Defaults to `process.getuid()` (or 0); pass a fake for tests. */
|
||||||
|
uid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrokerSupervisorPaths {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly frameworkRoot: string;
|
||||||
|
readonly systemdUserDir: string;
|
||||||
|
readonly leaseBrokerToolsDir: string;
|
||||||
|
readonly unitSourcePath: string;
|
||||||
|
readonly unitTargetPath: string;
|
||||||
|
readonly wrapperSourcePath: string;
|
||||||
|
readonly wrapperTargetPath: string;
|
||||||
|
readonly daemonSourcePaths: readonly string[];
|
||||||
|
readonly daemonTargetPaths: readonly string[];
|
||||||
|
/**
|
||||||
|
* Resolved with the same precedence as `defaultLeaseBrokerSocket` in
|
||||||
|
* `../commands/launch.ts`: an explicit `MOSAIC_LEASE_BROKER_SOCKET`, else
|
||||||
|
* `$XDG_RUNTIME_DIR/mosaic-lease/broker.sock`, else
|
||||||
|
* `/run/user/<uid>/mosaic-lease/broker.sock`.
|
||||||
|
*/
|
||||||
|
readonly socketPath: string;
|
||||||
|
/** Colocated next to the socket, matching the broker's own generation-file convention. */
|
||||||
|
readonly statePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the lease broker socket path alone, with the same precedence as
|
||||||
|
* `defaultLeaseBrokerSocket` in `../commands/launch.ts`. Exported so callers
|
||||||
|
* (and tests) can assert the two stay in agreement without importing the CLI
|
||||||
|
* command module.
|
||||||
|
*/
|
||||||
|
export function resolveLeaseBrokerSocketPath(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
uid: number = typeof process.getuid === 'function' ? process.getuid() : 0,
|
||||||
|
): string {
|
||||||
|
const explicit = env['MOSAIC_LEASE_BROKER_SOCKET'];
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const runtimeDir = env['XDG_RUNTIME_DIR'];
|
||||||
|
if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock');
|
||||||
|
return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve every path the supervisor apply/health functions need, deterministically. */
|
||||||
|
export function resolveBrokerSupervisorPaths(
|
||||||
|
options: ResolveBrokerSupervisorPathsOptions,
|
||||||
|
): BrokerSupervisorPaths {
|
||||||
|
const { mosaicHome, frameworkRoot } = options;
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
const homeDir = options.homeDir ?? homedir();
|
||||||
|
const systemdUserDir = join(homeDir, '.config', 'systemd', 'user');
|
||||||
|
const leaseBrokerToolsDir = join(mosaicHome, 'tools', 'lease-broker');
|
||||||
|
const frameworkLeaseBrokerDir = join(frameworkRoot, 'tools', 'lease-broker');
|
||||||
|
const socketPath = resolveLeaseBrokerSocketPath(env, options.uid);
|
||||||
|
const statePath = join(dirname(socketPath), 'state.json');
|
||||||
|
|
||||||
|
return {
|
||||||
|
mosaicHome,
|
||||||
|
frameworkRoot,
|
||||||
|
systemdUserDir,
|
||||||
|
leaseBrokerToolsDir,
|
||||||
|
unitSourcePath: join(frameworkRoot, 'systemd', 'user', UNIT_NAME),
|
||||||
|
unitTargetPath: join(systemdUserDir, UNIT_NAME),
|
||||||
|
wrapperSourcePath: join(frameworkLeaseBrokerDir, WRAPPER_SCRIPT_NAME),
|
||||||
|
wrapperTargetPath: join(leaseBrokerToolsDir, WRAPPER_SCRIPT_NAME),
|
||||||
|
daemonSourcePaths: DAEMON_SOURCE_FILE_NAMES.map((name) => join(frameworkLeaseBrokerDir, name)),
|
||||||
|
daemonTargetPaths: DAEMON_SOURCE_FILE_NAMES.map((name) => join(leaseBrokerToolsDir, name)),
|
||||||
|
socketPath,
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyBrokerSupervisorResult {
|
||||||
|
readonly installedFiles: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently materialize the supervisor unit, its wrapper script, and the
|
||||||
|
* daemon sources it execs. Safe to call on every reseed: every write is a
|
||||||
|
* deterministic overwrite of the same target path from the same source, so a
|
||||||
|
* second call reproduces identical bytes/modes and never errors.
|
||||||
|
*
|
||||||
|
* Never runs `systemctl`; the caller decides separately whether/when to
|
||||||
|
* `daemon-reload`/`enable`/`start` the installed unit.
|
||||||
|
*/
|
||||||
|
export async function applyBrokerSupervisor(
|
||||||
|
paths: BrokerSupervisorPaths,
|
||||||
|
): Promise<ApplyBrokerSupervisorResult> {
|
||||||
|
await mkdir(paths.leaseBrokerToolsDir, { recursive: true });
|
||||||
|
await mkdir(paths.systemdUserDir, { recursive: true });
|
||||||
|
|
||||||
|
const installedFiles: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < paths.daemonSourcePaths.length; index += 1) {
|
||||||
|
const source = paths.daemonSourcePaths[index];
|
||||||
|
const target = paths.daemonTargetPaths[index];
|
||||||
|
if (source === undefined || target === undefined) continue;
|
||||||
|
await copyFile(source, target);
|
||||||
|
await chmod(target, 0o644);
|
||||||
|
installedFiles.push(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
await copyFile(paths.wrapperSourcePath, paths.wrapperTargetPath);
|
||||||
|
await chmod(paths.wrapperTargetPath, 0o755);
|
||||||
|
installedFiles.push(paths.wrapperTargetPath);
|
||||||
|
|
||||||
|
await copyFile(paths.unitSourcePath, paths.unitTargetPath);
|
||||||
|
await chmod(paths.unitTargetPath, 0o644);
|
||||||
|
installedFiles.push(paths.unitTargetPath);
|
||||||
|
|
||||||
|
return { installedFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrokerSupervisorHealth {
|
||||||
|
/** Whether the systemd unit file has been materialized at its target path. */
|
||||||
|
readonly unitInstalled: boolean;
|
||||||
|
/** Whether a Unix domain socket currently exists at the resolved socket path. */
|
||||||
|
readonly socketPresent: boolean;
|
||||||
|
/**
|
||||||
|
* The signal other cards (e.g. C1's activation probe) should treat as
|
||||||
|
* "a broker supervisor is present and healthy". Presence of a live socket
|
||||||
|
* is the authoritative signal: a gated runtime can only ever succeed by
|
||||||
|
* connecting to it, so this is what fail-closed callers must check.
|
||||||
|
*/
|
||||||
|
readonly healthy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report the supervisor's on-disk/health signals. Never throws for an
|
||||||
|
* absent unit or socket — both simply report `false`; unexpected filesystem
|
||||||
|
* errors (permission issues, etc.) still propagate.
|
||||||
|
*/
|
||||||
|
export async function checkBrokerSupervisorHealth(
|
||||||
|
paths: Pick<BrokerSupervisorPaths, 'unitTargetPath' | 'socketPath'>,
|
||||||
|
): Promise<BrokerSupervisorHealth> {
|
||||||
|
const [unitInstalled, socketPresent] = await Promise.all([
|
||||||
|
pathExists(paths.unitTargetPath),
|
||||||
|
isUnixSocket(paths.socketPath),
|
||||||
|
]);
|
||||||
|
return { unitInstalled, socketPresent, healthy: socketPresent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience boolean form of {@link checkBrokerSupervisorHealth} for simple call sites. */
|
||||||
|
export async function isBrokerSupervisorHealthy(
|
||||||
|
paths: Pick<BrokerSupervisorPaths, 'unitTargetPath' | 'socketPath'>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return (await checkBrokerSupervisorHealth(paths)).healthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isEnoent(error)) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isUnixSocket(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const info = await stat(path);
|
||||||
|
return info.isSocket();
|
||||||
|
} catch (error) {
|
||||||
|
if (isEnoent(error)) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnoent(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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$/, '');
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user