Compare commits

..
Author SHA1 Message Date
Jason Woltje 15561263cc docs: close installation and control-link gates
ci/woodpecker/pr/ci Pipeline was successful
2026-08-13 12:09:03 -05:00
Jason Woltje 44b244f5c0 docs: restore active Fleet and KBN authority paths
ci/woodpecker/pr/ci Pipeline was successful
2026-08-13 11:41:54 -05:00
Jason Woltje f2661d2c6e Merge documentation information architecture into next
ci/woodpecker/pr/ci Pipeline failed
2026-08-13 11:20:25 -05:00
Jason Woltje 95d48b02cb docs: record quarantine stop boundary 2026-08-13 10:22:32 -05:00
Jason Woltje d6fa67982e docs: migrate Native Kanban review evidence 2026-08-13 10:21:04 -05:00
Jason Woltje 1237216e63 docs: archive standalone legacy designs 2026-08-13 10:19:49 -05:00
Jason Woltje 49a8ff73fd docs: archive Matrix MACP draft RFCs 2026-08-13 10:18:38 -05:00
Jason Woltje 32be7e547a docs: archive unreferenced work records 2026-08-13 10:16:57 -05:00
Jason Woltje 9152bb2b14 docs: archive legacy implementation plans 2026-08-13 10:15:13 -05:00
Jason Woltje 9c7fb4eda6 docs: archive monorepo consolidation planning 2026-08-13 10:13:54 -05:00
Jason Woltje 063de8cd85 docs: correct Discord durability boundary 2026-08-10 18:51:04 -05:00
Jason Woltje 11ffe65c97 docs: close navigation and mission archive gaps 2026-08-10 18:46:42 -05:00
Jason Woltje dcaf01c789 docs: anchor final migration review lanes 2026-08-10 18:36:10 -05:00
Jason Woltje 7ddd2f5e1d docs: replace stale sitemap inventory 2026-08-10 18:35:46 -05:00
Jason Woltje 16920c4a6f docs: integrate second autonomous migration wave 2026-08-10 18:33:02 -05:00
Jason Woltje 6b3ebce343 docs: migrate lease broker operations guide 2026-08-10 18:30:00 -05:00
Jason Woltje 7fa0f65a60 docs: add current web dashboard guide 2026-08-10 18:28:55 -05:00
Jason Woltje f4faa3f819 docs: add Discord channel audience guides 2026-08-10 18:28:55 -05:00
Jason Woltje 0692d999f6 docs: migrate optional AI egress RFC 2026-08-10 18:25:38 -05:00
Jason Woltje fa35c6abed docs: track second migration wave 2026-08-10 18:18:05 -05:00
Jason Woltje 53d4ea6ec6 docs: integrate autonomous migration slices 2026-08-10 18:16:14 -05:00
Jason Woltje 39987a5b61 docs: migrate upgrade recovery admin guide 2026-08-10 18:13:46 -05:00
Jason Woltje 00bdf8b28c docs: migrate channel protocol with evidence boundaries 2026-08-10 18:13:46 -05:00
Jason Woltje 631567d5f7 docs(mos): promote runtime portability M1 docs 2026-08-10 18:12:07 -05:00
Jason Woltje 9f741874bd docs: migrate historical report evidence 2026-08-10 18:09:00 -05:00
Jason Woltje 430b4d5f5d docs: track migration implementation lanes 2026-08-10 18:04:19 -05:00
Jason Woltje 404db8cd70 docs: record finish-line audit outcomes 2026-08-10 18:03:08 -05:00
Jason Woltje c0262e8856 docs: migrate Discord review evidence 2026-08-10 17:59:31 -05:00
Jason Woltje 306985990c docs: track canonical reports normally 2026-08-10 17:51:06 -05:00
Jason Woltje 2082ac061b docs: anchor autonomous migration orchestration 2026-08-10 17:49:39 -05:00
Jason Woltje 18ee6eb33d docs: complete lease broker architecture slice 2026-08-10 17:43:46 -05:00
Jason Woltje 76f1f1c8d3 docs: reconcile catalog link count 2026-08-10 17:41:50 -05:00
Jason Woltje 0da1deb83f docs: promote lease broker architecture contracts 2026-08-10 17:41:40 -05:00
Jason Woltje 4aa67e8dff docs: record parallel migration audits 2026-08-10 17:09:43 -05:00
Jason Woltje 7425edb80f docs: migrate SSO provider runbook 2026-08-10 17:01:40 -05:00
Jason Woltje 571a3d54b5 docs: create user quickstart 2026-08-10 16:40:14 -05:00
Jason Woltje bcd174f89e docs: archive stale TUI records 2026-08-10 16:20:26 -05:00
Jason Woltje 94fc3e55f5 docs: migrate performance evidence report 2026-08-10 16:16:31 -05:00
Jason Woltje 46d29d82e9 docs: add current sitemap scaffold 2026-08-10 16:08:38 -05:00
Jason Woltje d210c2d7ea docs: scaffold guide and API indexes 2026-08-10 16:06:42 -05:00
Jason Woltje 48531755eb docs: catalog and audit documentation 2026-08-10 15:45:53 -05:00
Jason Woltje 9a1cc63383 [docs] Add docs/.obsidian to gitignore 2026-08-10 15:29:14 -05:00
Jason Woltje 0830e2e3ae docs: codify documentation structure 2026-08-10 15:22:33 -05:00
Jason Woltje 205cc0d7a1 docs: define documentation information architecture 2026-08-10 15:15:02 -05:00
Jason Woltje 698655d40a [docs] Rename the QUICKSTART document. 2026-08-10 14:57:45 -05:00
Jason Woltje dcad7de033 [docs] Create blank QUICK_START.md document. 2026-08-10 14:54:28 -05:00
Jason Woltje cd4409abc3 Move old docs into _old_structure dir. Create blank GETTING_STARTED.md document 2026-08-10 14:53:21 -05:00
8 changed files with 38 additions and 655 deletions
-71
View File
@@ -1,71 +0,0 @@
# REPORT A1207
Date: 2026-08-13
Branch: `fix/869-lease-probe-timeout`
Starting head: `2373a5ad345fb316ad2460f6390baab1f45ba08f`
Base: `216cd72226cd9ee17eea461cfe7cd0e010a22f02`
## What changed
- Added Python behavior tests using isolated temporary directories and marker-writing fake `mosaic` executables. They prove that the supplied `PATH` wins over ambient `os.environ["PATH"]`, and that absent or empty supplied `PATH` values do not search ambient paths, platform defaults, or the current directory.
- Bound Python override behavior with executable fakes: a valid `MOSAIC_LEASE_VERSION_PROBE_COMMAND` wins over supplied and ambient `PATH`; an invalid override returns `None` without PATH fallback.
- Added a Python runner binding test that captures kwargs and requires `timeout=10.0`. Existing timeout, transport-error, and nonzero-exit checks remain fail-closed with `None`.
- Added the optional TypeScript dependency-injection seam `CapabilityProbeExecFile`, defaulting to the existing real `execFileSync` implementation. Production callers have no behavior change.
- Added TypeScript tests that capture child-process options and require exactly `timeout: 10_000`. Injected timeout, spawn-error, nonzero-exit, unparseable JSON, and malformed-object cases all return `null`.
- Removed the ambient no-dependency TypeScript smoke case that could execute a built checkout's real CLI. Default resolver and supervisor behavior retain their isolated tests, while capability transport tests now use an isolated artifact or the injected transport.
No Python production code changed relative to `2373a5ad`. The only production delta is the optional TypeScript child-process injection seam.
## Hermeticity incident and correction
An initial ambient-lookup mutation run exposed that the pre-existing Python "not resolvable" test left ambient process PATH uncontrolled. On this host, that mutation resolved and executed the host `mosaic` capability probe. A post-build intermediate TypeScript run also let the pre-existing no-dependency smoke case execute the checkout's built `dist/cli.js` capability probe. No `claude` process was run. I then isolated the Python test's ambient PATH, removed the TypeScript ambient smoke case, repeated the PATH mutation using only marker-writing temporary fakes, and repeated the final suites without either real probe path.
## Mutation evidence
Each mutation was applied independently, its focused suite was run, and the production source was restored before the final run.
| Mutation | Result | Reddened test name(s) |
| ------------------------------------------------------------------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shutil.which("mosaic", path=environ.get("PATH", ""))` to ambient `shutil.which("mosaic")` | RED, three failures | `ProbeActivationCapabilityTest.test_supplied_path_wins_over_ambient_process_path`; `ProbeActivationCapabilityTest.test_absent_or_empty_supplied_path_never_falls_back_or_executes` for both absent and empty PATH subtests |
| Python `PROBE_TIMEOUT_SECONDS: 10.0` to `2.0` | RED, one failure | `ProbeActivationCapabilityTest.test_probe_passes_ten_second_timeout_to_runner` |
| TypeScript `LEASE_CAPABILITY_PROBE_TIMEOUT_MS: 10_000` to `2_000` | RED, one failure | `defaultCapabilityProbe > passes the exact ten-second timeout to the injected child-process transport` |
## Final test run
Dependencies were installed first with `pnpm install --frozen-lockfile`. Workspace dependencies were then built with `pnpm --filter '@mosaicstack/mosaic...' run build` so package type declarations were available.
```text
$ cd packages/mosaic && python3 src/mutator-gate/version_coupling_unittest.py
...................
----------------------------------------------------------------------
Ran 19 tests in 0.007s
OK
$ pnpm exec vitest run src/commands/lease-activation-probe.spec.ts
✓ src/commands/lease-activation-probe.spec.ts (20 tests) 80ms
Test Files 1 passed (1)
Tests 20 passed (20)
```
```text
$ pnpm exec prettier --check packages/mosaic/src/commands/lease-activation-probe.ts packages/mosaic/src/commands/lease-activation-probe.spec.ts
Checking formatting...
All matched files use Prettier code style!
$ pnpm --filter @mosaicstack/mosaic lint
> eslint src
$ pnpm --filter @mosaicstack/mosaic typecheck
> tsc --noEmit
$ python3 -m py_compile packages/mosaic/src/mutator-gate/version_coupling_unittest.py packages/mosaic/framework/tools/lease-broker/activation_version_gate.py
$ git diff --check
```
All commands above exited zero.
## Ambiguities skipped
None.
@@ -1,74 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// homedir/platform are read at call time, so they can be stubbed per case.
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
homedir: () => '/home/tester',
platform: () => mockPlatform,
};
});
let mockPlatform: NodeJS.Platform = 'linux';
const { getShellProfilePath, detectShell } = await import('../../src/platform/detect.js');
describe('getShellProfilePath', () => {
const originalShell = process.env['SHELL'];
const originalZdotdir = process.env['ZDOTDIR'];
beforeEach(() => {
mockPlatform = 'linux';
delete process.env['ZDOTDIR'];
});
afterEach(() => {
if (originalShell === undefined) delete process.env['SHELL'];
else process.env['SHELL'] = originalShell;
if (originalZdotdir === undefined) delete process.env['ZDOTDIR'];
else process.env['ZDOTDIR'] = originalZdotdir;
});
// The regression this guards: setupPath() in stages/finalize.ts appends the
// PATH export to whatever this returns. A line written to ~/.bashrc is
// unreachable to `bash -lc`, systemd units and agent seats, because Debian's
// default .bashrc returns early for non-interactive shells — so an install
// reported success and left `mosaic: command not found`. Same for .zshrc,
// which zsh only reads for interactive shells.
it('never targets an interactive-only rc file', () => {
for (const shell of ['/bin/bash', '/usr/bin/zsh']) {
process.env['SHELL'] = shell;
const profile = getShellProfilePath();
expect(profile).not.toMatch(/\.bashrc$/);
expect(profile).not.toMatch(/\.zshrc$/);
}
});
it('uses ~/.profile for bash', () => {
process.env['SHELL'] = '/bin/bash';
expect(getShellProfilePath()).toBe('/home/tester/.profile');
});
it('uses ~/.zshenv for zsh', () => {
process.env['SHELL'] = '/usr/bin/zsh';
expect(getShellProfilePath()).toBe('/home/tester/.zshenv');
});
it('honours ZDOTDIR for zsh', () => {
process.env['SHELL'] = '/usr/bin/zsh';
process.env['ZDOTDIR'] = '/custom/zdot';
expect(getShellProfilePath()).toBe('/custom/zdot/.zshenv');
});
it('falls back to ~/.profile for an unknown shell', () => {
process.env['SHELL'] = '/bin/somethingelse';
expect(detectShell()).toBe('unknown');
expect(getShellProfilePath()).toBe('/home/tester/.profile');
});
it('still routes fish to its own config', () => {
process.env['SHELL'] = '/usr/bin/fish';
expect(getShellProfilePath()).toBe('/home/tester/.config/fish/config.fish');
});
});
@@ -62,14 +62,7 @@ EXPECTED_ACTIVATION_CAPABILITY: Final[ActivationCapability] = {
# capability as compact JSON. # capability as compact JSON.
LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability" LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability"
# Budget for the out-of-process `mosaic __lease-capability` probe. The CLI PROBE_TIMEOUT_SECONDS: Final = 2.0
# is a Node program whose cold start alone measures 2.2-2.3s on a mid-range
# workstation (sb-it-1-dt, 2026-08-13), so a 2s budget made every launch on
# such hosts fail closed with the #869 skew message even though the
# capability matched. The timeout only bounds the pathological hang case —
# the happy path returns as soon as the probe exits — so a generous budget
# costs nothing on healthy hosts.
PROBE_TIMEOUT_SECONDS: Final = 10.0
# Override hook: a full shell-style command line (parsed with `shlex.split`) # Override hook: a full shell-style command line (parsed with `shlex.split`)
# to run INSTEAD of resolving `mosaic` on PATH and appending the probe # to run INSTEAD of resolving `mosaic` on PATH and appending the probe
@@ -95,13 +88,7 @@ def _resolve_probe_command(environ: Mapping[str, str]) -> list[str] | None:
if override: if override:
parsed = shlex.split(override) parsed = shlex.split(override)
return parsed or None return parsed or None
# Resolve against the PROVIDED environment's PATH, not the ambient resolved = shutil.which("mosaic")
# os.environ. Before this, a test passing a hermetic environ still
# resolved (and spawned) the host's real `mosaic` — masked only on hosts
# where the real probe happened to exceed the old 2s timeout. No PATH in
# the provided environment means nothing is resolvable (fail-closed),
# matching the probe's overall contract.
resolved = shutil.which("mosaic", path=environ.get("PATH", ""))
if resolved is None: if resolved is None:
return None return None
return [resolved, LEASE_CAPABILITY_PROBE_COMMAND] return [resolved, LEASE_CAPABILITY_PROBE_COMMAND]
@@ -7,13 +7,11 @@ import { fileURLToPath } from 'node:url';
import { import {
LEASE_ACTIVATION_CAPABILITY, LEASE_ACTIVATION_CAPABILITY,
LEASE_CAPABILITY_PROBE_COMMAND, LEASE_CAPABILITY_PROBE_COMMAND,
LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
defaultCapabilityProbe, defaultCapabilityProbe,
defaultResolveCliEntry, defaultResolveCliEntry,
defaultSupervisorProbe, defaultSupervisorProbe,
leaseEnforcementActivatable, leaseEnforcementActivatable,
registerLeaseCapabilityProbe, registerLeaseCapabilityProbe,
type CapabilityProbeExecFile,
type LeaseActivationCapability, type LeaseActivationCapability,
type SupervisorProbeResult, type SupervisorProbeResult,
} from './lease-activation-probe.js'; } from './lease-activation-probe.js';
@@ -37,17 +35,6 @@ const presentSupervisor: SupervisorProbeResult = {
socketPath: '/run/user/1000/mosaic-lease/broker.sock', socketPath: '/run/user/1000/mosaic-lease/broker.sock',
}; };
function withScratchCli<T>(run: (cliPath: string) => T): T {
const scratchDir = mkdtempSync(join(tmpdir(), 'mosaic-lease-capability-probe-'));
try {
const cliPath = join(scratchDir, 'cli.js');
writeFileSync(cliPath, '// isolated fake; injected execFile means this is never executed\n');
return run(cliPath);
} finally {
rmSync(scratchDir, { recursive: true, force: true });
}
}
describe('leaseEnforcementActivatable', () => { describe('leaseEnforcementActivatable', () => {
it('is false when the activation capability is absent (null)', () => { it('is false when the activation capability is absent (null)', () => {
const result = leaseEnforcementActivatable({ const result = leaseEnforcementActivatable({
@@ -113,6 +100,15 @@ describe('leaseEnforcementActivatable', () => {
}); });
expect(result).toBe(true); 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', () => { describe('defaultCapabilityProbe', () => {
@@ -131,61 +127,6 @@ describe('defaultCapabilityProbe', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('passes the exact ten-second timeout to the injected child-process transport', () => {
withScratchCli((cliPath) => {
let captured:
| {
file: string;
args: string[];
options: Parameters<CapabilityProbeExecFile>[2];
}
| undefined;
const execFile: CapabilityProbeExecFile = (file, args, options) => {
captured = { file, args, options };
return JSON.stringify(LEASE_ACTIVATION_CAPABILITY);
};
const result = defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile });
expect(result).toEqual(LEASE_ACTIVATION_CAPABILITY);
expect(captured).toEqual({
file: process.execPath,
args: [cliPath, LEASE_CAPABILITY_PROBE_COMMAND],
options: {
encoding: 'utf-8',
timeout: 10_000,
stdio: ['ignore', 'pipe', 'ignore'],
},
});
expect(captured?.options.timeout).toBe(LEASE_CAPABILITY_PROBE_TIMEOUT_MS);
});
});
it.each([
['timeout', Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' })],
['spawn error', Object.assign(new Error('spawn failed'), { code: 'ENOENT' })],
['nonzero exit', Object.assign(new Error('child exited 1'), { status: 1 })],
])('returns null (fail-closed) on child-process %s', (_failure, error) => {
withScratchCli((cliPath) => {
const execFile: CapabilityProbeExecFile = () => {
throw error;
};
expect(defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile })).toBeNull();
});
});
it.each([
['unparseable JSON', 'not-json'],
['malformed object', JSON.stringify({ name: LEASE_ACTIVATION_CAPABILITY.name })],
])('returns null (fail-closed) on %s output', (_failure, output) => {
withScratchCli((cliPath) => {
const execFile: CapabilityProbeExecFile = () => output;
expect(defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile })).toBeNull();
});
});
describe('positive path — injected resolver, isolated scratch dir (never the real dist/)', () => { 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 // 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 // REAL resolved dist/ path and relied on afterEach to clean up "only
@@ -55,19 +55,6 @@ export const LEASE_ACTIVATION_CAPABILITY: LeaseActivationCapability = {
/** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */ /** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */
export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability'; export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability';
/**
* Budget for the out-of-process capability probe. The probe launches a fresh
* Node process on the built CLI entrypoint, whose cold start alone measures
* 2.2-2.3s on a mid-range workstation (sb-it-1-dt, 2026-08-13) — so the
* previous 2s budget made the probe time out and report NO capability on
* such hosts, failing every launch with the #869 skew message even though
* the capability matched. The timeout only bounds the pathological hang
* case; the happy path returns as soon as the probe exits. Mirrors
* PROBE_TIMEOUT_SECONDS in the enforcement half
* (framework/tools/lease-broker/activation_version_gate.py).
*/
export const LEASE_CAPABILITY_PROBE_TIMEOUT_MS = 10_000;
function capabilityMatches(candidate: LeaseActivationCapability | null): boolean { function capabilityMatches(candidate: LeaseActivationCapability | null): boolean {
return ( return (
candidate !== null && candidate !== null &&
@@ -123,28 +110,12 @@ export function defaultResolveCliEntry(
return join(dirname(mainEntry), 'cli.js'); return join(dirname(mainEntry), 'cli.js');
} }
/** Narrow injectable seam for the synchronous child process used by the
* capability probe. */
export type CapabilityProbeExecFile = (
file: string,
args: string[],
options: {
encoding: BufferEncoding;
timeout: number;
stdio: ['ignore', 'pipe', 'ignore'];
},
) => string;
/** Injectable inputs for {@link defaultCapabilityProbe}. */ /** Injectable inputs for {@link defaultCapabilityProbe}. */
export interface CapabilityProbeDeps { export interface CapabilityProbeDeps {
/** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to /** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to
* {@link defaultResolveCliEntry}. Inject to point at an isolated scratch * {@link defaultResolveCliEntry}. Inject to point at an isolated scratch
* location in tests — never at the real package's `dist/`. */ * location in tests — never at the real package's `dist/`. */
resolveCliEntry?: () => string; resolveCliEntry?: () => string;
/** Execute the resolved CLI entrypoint. Defaults to the real
* `execFileSync`. Inject so transport behavior and options can be tested
* without spawning a process. */
execFile?: CapabilityProbeExecFile;
} }
/** /**
@@ -168,10 +139,9 @@ export function defaultCapabilityProbe(
const cliEntry = resolveCliEntry(); const cliEntry = resolveCliEntry();
if (!existsSync(cliEntry)) return null; if (!existsSync(cliEntry)) return null;
const execFile: CapabilityProbeExecFile = deps.execFile ?? execFileSync; const output = execFileSync(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
const output = execFile(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
encoding: 'utf-8', encoding: 'utf-8',
timeout: LEASE_CAPABILITY_PROBE_TIMEOUT_MS, timeout: 2000,
stdio: ['ignore', 'pipe', 'ignore'], stdio: ['ignore', 'pipe', 'ignore'],
}); });
@@ -24,15 +24,11 @@ from __future__ import annotations
import importlib.util import importlib.util
import io import io
import os
import shlex
import subprocess import subprocess
import sys import sys
import tempfile
import unittest import unittest
from contextlib import redirect_stderr from contextlib import redirect_stderr
from pathlib import Path from pathlib import Path
from unittest import mock
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker" TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
@@ -61,20 +57,6 @@ def matching_capability() -> dict[str, object]:
return dict(VERSION_GATE.EXPECTED_ACTIVATION_CAPABILITY) return dict(VERSION_GATE.EXPECTED_ACTIVATION_CAPABILITY)
def write_fake_mosaic(directory: Path, marker: Path) -> Path:
directory.mkdir(parents=True, exist_ok=True)
executable = directory / "mosaic"
executable.write_text(
"#!/bin/sh\n"
f"printf '%s\\n' executed >> {shlex.quote(str(marker))}\n"
"printf '%s\\n' "
"'{\"name\":\"lease-runtime-activation\",\"version\":1}'\n",
encoding="utf-8",
)
executable.chmod(0o755)
return executable
class AssertActivationCapabilityMatchesTest(unittest.TestCase): class AssertActivationCapabilityMatchesTest(unittest.TestCase):
"""Unit-level coverage of `activation_version_gate.py`'s own assertion, """Unit-level coverage of `activation_version_gate.py`'s own assertion,
isolated from the launch-runtime.py seam it is wired into below.""" isolated from the launch-runtime.py seam it is wired into below."""
@@ -128,102 +110,11 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
handling — never spawns a real `mosaic` process.""" handling — never spawns a real `mosaic` process."""
def test_returns_none_when_mosaic_is_not_resolvable_on_path(self) -> None: def test_returns_none_when_mosaic_is_not_resolvable_on_path(self) -> None:
# Keep even a deliberate ambient-lookup mutation away from any host result = VERSION_GATE.default_probe_activation_capability(
# installation. The dedicated hermeticity tests below provide fake {"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
# ambient executables and markers. )
with mock.patch.dict(
os.environ, {"PATH": "/nonexistent-ambient-bin-dir-for-869-c4-test"}
):
result = VERSION_GATE.default_probe_activation_capability(
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
)
self.assertIsNone(result) self.assertIsNone(result)
def test_supplied_path_wins_over_ambient_process_path(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
supplied_marker = root / "supplied.marker"
ambient_marker = root / "ambient.marker"
supplied_bin = root / "supplied-bin"
ambient_bin = root / "ambient-bin"
write_fake_mosaic(supplied_bin, supplied_marker)
write_fake_mosaic(ambient_bin, ambient_marker)
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
result = VERSION_GATE.default_probe_activation_capability(
{"PATH": str(supplied_bin)}
)
self.assertEqual(result, matching_capability())
self.assertTrue(supplied_marker.exists())
self.assertFalse(ambient_marker.exists())
def test_absent_or_empty_supplied_path_never_falls_back_or_executes(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
ambient_marker = root / "ambient.marker"
current_directory_marker = root / "current-directory.marker"
ambient_bin = root / "ambient-bin"
current_directory = root / "current-directory"
write_fake_mosaic(ambient_bin, ambient_marker)
write_fake_mosaic(current_directory, current_directory_marker)
original_directory = Path.cwd()
try:
os.chdir(current_directory)
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
for supplied_environment in ({}, {"PATH": ""}):
with self.subTest(environ=supplied_environment):
result = VERSION_GATE.default_probe_activation_capability(
supplied_environment
)
self.assertIsNone(result)
self.assertFalse(ambient_marker.exists())
self.assertFalse(current_directory_marker.exists())
finally:
os.chdir(original_directory)
def test_valid_override_wins_and_invalid_override_does_not_fall_back_to_path(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
supplied_marker = root / "supplied.marker"
ambient_marker = root / "ambient.marker"
override_marker = root / "override.marker"
supplied_bin = root / "supplied-bin"
ambient_bin = root / "ambient-bin"
override_bin = root / "override-bin"
write_fake_mosaic(supplied_bin, supplied_marker)
write_fake_mosaic(ambient_bin, ambient_marker)
override_executable = write_fake_mosaic(override_bin, override_marker)
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
result = VERSION_GATE.default_probe_activation_capability(
{
"PATH": str(supplied_bin),
VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: str(override_executable),
}
)
self.assertEqual(result, matching_capability())
self.assertTrue(override_marker.exists())
self.assertFalse(supplied_marker.exists())
self.assertFalse(ambient_marker.exists())
override_marker.unlink()
result = VERSION_GATE.default_probe_activation_capability(
{
"PATH": str(supplied_bin),
VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: str(
root / "invalid-override" / "mosaic"
),
}
)
self.assertIsNone(result)
self.assertFalse(override_marker.exists())
self.assertFalse(supplied_marker.exists())
self.assertFalse(ambient_marker.exists())
def test_override_command_is_parsed_and_the_probe_subcommand_is_not_double_appended( def test_override_command_is_parsed_and_the_probe_subcommand_is_not_double_appended(
self, self,
) -> None: ) -> None:
@@ -244,29 +135,6 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
self.assertEqual(result, {"name": "lease-runtime-activation", "version": 1}) self.assertEqual(result, {"name": "lease-runtime-activation", "version": 1})
self.assertEqual(captured, [["/fake/mosaic", "__lease-capability"]]) self.assertEqual(captured, [["/fake/mosaic", "__lease-capability"]])
def test_probe_passes_ten_second_timeout_to_runner(self) -> None:
captured_argv: list[str] = []
captured_kwargs: dict[str, object] = {}
class FakeCompleted:
returncode = 0
stdout = '{"name": "lease-runtime-activation", "version": 1}'
def fake_run(argv: list[str], **kwargs: object) -> FakeCompleted:
captured_argv.extend(argv)
captured_kwargs.update(kwargs)
return FakeCompleted()
result = VERSION_GATE.default_probe_activation_capability(
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
run=fake_run,
)
self.assertEqual(result, matching_capability())
self.assertEqual(captured_argv, ["/fake/mosaic"])
self.assertEqual(captured_kwargs["timeout"], 10.0)
self.assertEqual(captured_kwargs["check"], False)
def test_fails_closed_on_nonzero_exit_malformed_json_and_missing_fields(self) -> None: def test_fails_closed_on_nonzero_exit_malformed_json_and_missing_fields(self) -> None:
class NonZeroExit: class NonZeroExit:
returncode = 1 returncode = 1
@@ -306,7 +174,7 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
def test_fails_closed_on_timeout_and_transport_error(self) -> None: def test_fails_closed_on_timeout_and_transport_error(self) -> None:
def timeout_run(*_args: object, **_kwargs: object) -> None: def timeout_run(*_args: object, **_kwargs: object) -> None:
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=10.0) raise subprocess.TimeoutExpired(cmd="mosaic", timeout=2.0)
def oserror_run(*_args: object, **_kwargs: object) -> None: def oserror_run(*_args: object, **_kwargs: object) -> None:
raise OSError("no such file or directory") raise OSError("no such file or directory")
+6 -8
View File
@@ -1,3 +1,4 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { homedir, platform } from 'node:os'; import { homedir, platform } from 'node:os';
@@ -21,18 +22,15 @@ export function getShellProfilePath(): string | null {
const shell = detectShell(); const shell = detectShell();
switch (shell) { switch (shell) {
// Both of these deliberately avoid the interactive-only rc files.
// Debian's default .bashrc returns early for non-interactive shells, so a
// PATH line appended to it never runs for `bash -lc`, systemd units, or
// agent seats — an install could report success and still leave `mosaic`
// unreachable. .profile is read by login shells and sources .bashrc for
// interactive ones, so one line covers both; .zshenv is zsh's equivalent.
case 'zsh': { case 'zsh': {
const zdotdir = process.env['ZDOTDIR'] ?? home; const zdotdir = process.env['ZDOTDIR'] ?? home;
return join(zdotdir, '.zshenv'); return join(zdotdir, '.zshrc');
} }
case 'bash': case 'bash': {
const bashrc = join(home, '.bashrc');
if (existsSync(bashrc)) return bashrc;
return join(home, '.profile'); return join(home, '.profile');
}
case 'fish': case 'fish':
return join(home, '.config', 'fish', 'config.fish'); return join(home, '.config', 'fish', 'config.fish');
default: default:
+15 -251
View File
@@ -309,87 +309,6 @@ require_cmd() {
fi fi
} }
# True if any shell rc file already puts $1 on PATH.
#
# Each file is tested for existence first and grepped one at a time, rather than
# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file
# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q
# matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless.
# On the 2 path the caller reads "not present yet" and appends a duplicate PATH
# line on every single install.
path_entry_exists() {
local dir="$1" rc_file
for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do
if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then
return 0
fi
done
return 1
}
# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this
# process. An `export` here reaches only the installer; every directory the
# install leaves behind has to be written down somewhere a later shell reads.
#
# Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for
# non-interactive shells, so a PATH line appended to the bottom of it is
# unreachable to `bash -lc`, to systemd units, and to every agent seat — the
# exact consumers that need these binaries. ~/.profile is read by login shells
# and Debian's .profile sources .bashrc for interactive ones, so a single line
# there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc.
#
# $1 = directory to add, $2 = label for the comment line.
# Returns 1 (having warned) if the profile could not be written.
persist_on_path() {
local dir="$1" label="$2" profile
if path_entry_exists "$dir"; then
return 0
fi
if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
profile="$HOME/.zshenv"
else
profile="$HOME/.profile"
fi
# Probe writability in a subshell. A redirection failure on a special built-in
# aborts the shell it runs in, so it has to be a child; and the redirection on
# the subshell is what silences the "Permission denied" the shell would
# otherwise print ahead of our own message.
if ! ( : >>"$profile" ) 2>/dev/null; then
warn "$dir is not on your PATH and $profile could not be written"
dim " Add to your shell rc: export PATH=\"$dir:\$PATH\""
return 1
fi
{
echo ""
echo "# $label"
echo "export PATH=\"$dir:\$PATH\""
} >>"$profile"
ok "Added $dir to PATH in $profile"
return 0
}
# Persist $PREFIX/bin on PATH instead of only warning about it.
#
# The warning it replaces was the last step of an otherwise successful install,
# so the installer reported success and left `mosaic: command not found` — an
# unattended install had no operator to read the advice and act on it.
ensure_prefix_on_path() {
if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then
return
fi
if path_entry_exists "$PREFIX/bin"; then
warn "$PREFIX/bin is in your shell profile but not in this shell"
elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then
return
fi
dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)"
}
installed_cli_version() { installed_cli_version() {
local json local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
@@ -597,175 +516,8 @@ install_next_cli_from_registry() {
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}" ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
} }
# ─── node bootstrap ───────────────────────────────────────────────────────────
#
# Nothing on a greenfield host installs Node.js, yet this installer and the CLI
# it installs both hard-require it. Measured on a clean Debian 13 image: the
# installer stopped at `require_cmd node` with "Required command not found" and
# nothing was installed, with no hint of how to proceed.
#
# Inlined rather than factored into a sibling file on purpose: this script is
# fetched standalone by curl and has nothing to source.
#
# No-op when a suitable node is already on PATH, so it never fights an
# operator's nvm/fnm/distro node.
NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}"
NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}"
NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}"
NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}"
# Major version of the node at $1, or empty if it will not run.
node_major_of() {
local candidate="$1" version
version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0
printf '%s' "${version%%.*}"
}
node_is_suitable() {
local major
major="$(node_major_of "$1")"
[[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]]
}
install_node() {
local node_os node_arch tarball release_url work_dir extracted target node_bin
case "$(uname -s)" in
Linux) node_os="linux" ;;
Darwin) node_os="darwin" ;;
*) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
esac
# Linux here means glibc. Node's official linux-x64 build is dynamically
# linked against glibc, so on musl (Alpine) the binary will not exec — but it
# fails visibly: node_is_suitable rejects it and ensure_node exits with
# "install Node.js manually". No silent breakage, just a wasted download.
# A musl host needs the unofficial build, which is out of scope here.
case "$(uname -m)" in
x86_64|amd64) node_arch="x64" ;;
aarch64|arm64) node_arch="arm64" ;;
armv7l) node_arch="armv7l" ;;
*) fail "Unsupported architecture '$(uname -m)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
esac
# .tar.gz rather than the smaller .tar.xz: gzip is universally present, xz is
# not, and a minimal image is exactly the case this exists to handle.
tarball="node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}.tar.gz"
release_url="${NODE_DIST_BASE}/${NODE_BOOTSTRAP_VERSION}"
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-XXXXXX")"
info "Installing Node.js $NODE_BOOTSTRAP_VERSION ($node_os-$node_arch) to $NODE_ROOT"
if ! curl -fsSL "${release_url}/${tarball}" -o "$work_dir/$tarball"; then
fail "Download failed: ${release_url}/${tarball}"
rm -rf "$work_dir"; return 1
fi
# Trust assumption, stated so nobody has to infer it: this verifies INTEGRITY
# (the tarball matches the manifest), not AUTHENTICITY (the manifest is
# genuinely Node's). The only thing establishing that is TLS to
# $NODE_DIST_BASE. Node publishes SHASUMS256.txt.sig signed by its release
# keys and we do not check it, which is on par with nvm but means pointing
# MOSAIC_NODE_DIST_BASE at an untrusted mirror has no signature backstop.
# Tracked as a hardening follow-up (raised by scooby in the #1229 review).
if ! curl -fsSL "${release_url}/SHASUMS256.txt" -o "$work_dir/SHASUMS256.txt"; then
fail "Could not fetch SHASUMS256.txt; refusing to install an unverified runtime."
rm -rf "$work_dir"; return 1
fi
# Keep only our artifact's line, so a missing entry is an error not a pass.
if ! grep " ${tarball}\$" "$work_dir/SHASUMS256.txt" >"$work_dir/expected.sha256"; then
fail "$tarball has no entry in SHASUMS256.txt; refusing to install."
rm -rf "$work_dir"; return 1
fi
if ! (cd "$work_dir" && verify_sha256 expected.sha256); then
fail "Checksum mismatch for $tarball; refusing to install."
rm -rf "$work_dir"; return 1
fi
ok "Checksum verified"
tar xzf "$work_dir/$tarball" -C "$work_dir"
extracted="$work_dir/node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}"
if [[ ! -x "$extracted/bin/node" ]]; then
fail "Extracted archive has no bin/node"
rm -rf "$work_dir"; return 1
fi
mkdir -p "$NODE_ROOT"
target="$NODE_ROOT/$NODE_BOOTSTRAP_VERSION"
rm -rf "$target.incoming"
mv "$extracted" "$target.incoming"
rm -rf "$target"
mv "$target.incoming" "$target"
ln -sfn "$NODE_BOOTSTRAP_VERSION" "$NODE_ROOT/current"
rm -rf "$work_dir"
node_bin="$NODE_ROOT/current/bin"
if ! node_is_suitable "$node_bin/node"; then
fail "Installed node at $node_bin/node did not run"
return 1
fi
export PATH="$node_bin:$PATH"
ok "Node.js $(node -v) installed with npm $(npm -v 2>/dev/null || echo '?')"
return 0
}
# Make the Mosaic-managed Node reachable from the next shell as well as this
# one. Measured on a greenfield canary run: without this the install finished
# rc=0, wrote $PREFIX/bin to ~/.profile, and the next login shell found `mosaic`
# and then died on `env: 'node': No such file or directory` — the CLI is a Node
# script, so a CLI on PATH without its runtime is a successful install that
# produces a broken command.
persist_node_on_path() {
persist_on_path "$NODE_ROOT/current/bin" "Mosaic-managed Node.js" || true
}
ensure_node() {
if command -v node &>/dev/null && node_is_suitable node; then
return 0
fi
# A previous run may have installed one that is not on this shell's PATH.
if node_is_suitable "$NODE_ROOT/current/bin/node"; then
export PATH="$NODE_ROOT/current/bin:$PATH"
persist_node_on_path
return 0
fi
if [[ "${MOSAIC_SKIP_NODE_BOOTSTRAP:-0}" == "1" ]]; then
fail "No suitable Node.js and MOSAIC_SKIP_NODE_BOOTSTRAP=1; refusing to download."
echo " Install Node.js >= $NODE_MIN_MAJOR yourself, then re-run this script."
exit 1
fi
require_cmd curl
require_cmd tar
# sha256sum on Linux, shasum on macOS. Verification is not optional: without a
# checksum this would install an unauthenticated runtime.
if command -v sha256sum &>/dev/null; then
verify_sha256() { sha256sum -c --status "$1"; }
elif command -v shasum &>/dev/null; then
verify_sha256() { shasum -a 256 -c --status "$1"; }
else
fail "sha256sum or shasum required to verify the Node.js download"
exit 1
fi
if ! install_node; then
fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run."
exit 1
fi
persist_node_on_path
}
# ─── preflight ──────────────────────────────────────────────────────────────── # ─── preflight ────────────────────────────────────────────────────────────────
ensure_node
require_cmd node require_cmd node
require_cmd npm require_cmd npm
@@ -930,7 +682,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ensure_monorepo ensure_monorepo
install_cli_from_source install_cli_from_source
ensure_prefix_on_path # PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
elif is_next_registry_lane; then elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}" info "Next mode — trying fast npm @next install from ${REGISTRY}"
if install_next_cli_from_registry; then if install_next_cli_from_registry; then
@@ -943,7 +699,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi fi
ensure_prefix_on_path # PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
else else
if [[ -z "$LATEST" ]]; then if [[ -z "$LATEST" ]]; then
warn "Could not reach registry at $REGISTRY — skipping npm CLI." warn "Could not reach registry at $REGISTRY — skipping npm CLI."
@@ -961,7 +721,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ok "CLI is at or ahead of registry ($CURRENT$LATEST)." ok "CLI is at or ahead of registry ($CURRENT$LATEST)."
fi fi
ensure_prefix_on_path # PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
fi fi
fi fi