Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8272dfb7de | ||
|
|
2373a5ad34 |
@@ -0,0 +1,71 @@
|
|||||||
|
# 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.
|
||||||
@@ -451,24 +451,15 @@ describe('AppModule federation gating', (): void => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it(
|
it(
|
||||||
'rejects an invalid explicit monorepo-root dotenv tier with a typed startup refusal',
|
'attributes an invalid monorepo-root dotenv tier to the default',
|
||||||
async (): Promise<void> => {
|
async (): Promise<void> => {
|
||||||
const failure = await loadModuleGraphFromDotenv({
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
||||||
expectedProcessTier: 'invalid',
|
expectedProcessTier: 'invalid',
|
||||||
}).then(
|
|
||||||
(): undefined => undefined,
|
|
||||||
(error: unknown): unknown => error,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(failure).toBeInstanceOf(Error);
|
|
||||||
expect(failure).toMatchObject({
|
|
||||||
name: 'MosaicConfigEnvironmentError',
|
|
||||||
code: 'invalid_storage_tier',
|
|
||||||
});
|
});
|
||||||
expect((failure as Error).message).toBe(
|
|
||||||
'Invalid MOSAIC_STORAGE_TIER; expected "local", "standalone", or "federated".',
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
);
|
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
||||||
},
|
},
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { resolveChatRuntimeMode } from './chat-runtime.js';
|
|
||||||
|
|
||||||
interface TypedSelectionFailure {
|
|
||||||
readonly name: string;
|
|
||||||
readonly code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('#1182 fail closed — a wrong answer must not be read as no answer', () => {
|
|
||||||
it('FL-07 rejects an invalid explicit CHAT_HARNESS_RUNTIME before embedded construction', () => {
|
|
||||||
let embeddedConstructionCount = 0;
|
|
||||||
let failure: unknown;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mode = resolveChatRuntimeMode({ CHAT_HARNESS_RUNTIME: 'pi-rpc-typo' });
|
|
||||||
if (mode === 'legacy') {
|
|
||||||
embeddedConstructionCount += 1;
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
failure = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect
|
|
||||||
.soft(failure, 'invalid explicit runtime must produce a typed selection failure')
|
|
||||||
.toMatchObject({
|
|
||||||
name: 'ChatRuntimeConfigurationError',
|
|
||||||
code: 'invalid_chat_harness_runtime',
|
|
||||||
} satisfies TypedSelectionFailure);
|
|
||||||
expect(
|
|
||||||
embeddedConstructionCount,
|
|
||||||
'invalid explicit runtime must fail before the embedded runtime is constructed',
|
|
||||||
).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([{}, { CHAT_HARNESS_RUNTIME: '' }])(
|
|
||||||
'preserves the documented transitional legacy default for true absence: %j',
|
|
||||||
(env) => {
|
|
||||||
expect(resolveChatRuntimeMode(env)).toBe('legacy');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('anti-drift: runtime selection has no invalid-enum-to-legacy catch-all', () => {
|
|
||||||
const source = readFileSync(new URL('./chat-runtime.ts', import.meta.url), 'utf8');
|
|
||||||
|
|
||||||
expect(
|
|
||||||
source.includes("env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy'"),
|
|
||||||
'closed runtime enums must distinguish invalid explicit input from absence',
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -41,28 +41,14 @@ export class ChatRuntimeUnavailableError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Typed startup refusal for an invalid explicit chat-runtime selection. */
|
|
||||||
export class ChatRuntimeConfigurationError extends Error {
|
|
||||||
readonly code = 'invalid_chat_harness_runtime' as const;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super('Invalid CHAT_HARNESS_RUNTIME; expected "legacy" or "pi-rpc".');
|
|
||||||
this.name = 'ChatRuntimeConfigurationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the process-wide chat runtime mode from the environment. Only true
|
* Resolves the process-wide chat runtime mode from the environment. Anything other
|
||||||
* absence (unset or empty) retains the documented transitional legacy default;
|
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
|
||||||
* any other explicit value must be a member of the closed runtime enum.
|
|
||||||
*/
|
*/
|
||||||
export function resolveChatRuntimeMode(
|
export function resolveChatRuntimeMode(
|
||||||
env: Record<string, string | undefined> = process.env,
|
env: Record<string, string | undefined> = process.env,
|
||||||
): ChatRuntimeMode {
|
): ChatRuntimeMode {
|
||||||
const runtime = env['CHAT_HARNESS_RUNTIME'];
|
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
|
||||||
if (runtime === undefined || runtime === '') return 'legacy';
|
|
||||||
if (runtime === 'legacy' || runtime === 'pi-rpc') return runtime;
|
|
||||||
throw new ChatRuntimeConfigurationError();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
# #1182 — fail closed when a wrong answer is read as no answer
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement FL-07 through FL-10 as one narrow fail-closed change: explicit invalid runtime/storage enum values and launcher failures must never be interpreted as absence or success.
|
|
||||||
|
|
||||||
## Tracking
|
|
||||||
|
|
||||||
- Issue: #1182 (child of #1156; W-F1 prerequisite)
|
|
||||||
- Branch: `fix/1182-fail-closed-launch`
|
|
||||||
- Verified base: `origin/next` at `216cd72226cd9ee17eea461cfe7cd0e010a22f02`
|
|
||||||
|
|
||||||
## Scope and fence
|
|
||||||
|
|
||||||
- Gateway chat runtime enum resolution and focused tests.
|
|
||||||
- Config storage-tier enum resolution and focused tests.
|
|
||||||
- Mosaic launcher spawn/provenance failure handling and focused integration/anti-drift tests.
|
|
||||||
- Narrow operator documentation in `packages/mosaic/README.md` if required by the behavior change.
|
|
||||||
- Preserve the #1109 lease broker without refactor; do not implement W-F1 composition.
|
|
||||||
- Do not touch #1178, #1179, #1072, #1080, #1054, `docs/TASKS.md`, or unrelated source/docs.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. RED: add one independent negative control per FL finding plus exact anti-drift checks.
|
|
||||||
2. Pause and report BASE / BRANCH / FENCE / SPLIT / RED to the coordinator.
|
|
||||||
3. After coordinator confirmation, implement each minimal fail-closed fix and verify each negative control independently.
|
|
||||||
4. Run affected package and repository test/typecheck/lint/build/format gates, focused security review, then commit/push/PR for independent exact-head verification.
|
|
||||||
|
|
||||||
## Split assessment
|
|
||||||
|
|
||||||
One PR remains reviewable: three narrowly bounded decision boundaries, no shared abstraction, no lease-broker refactor, and four independent tests naming the single defect class. Splitting would separate the same fail-closed invariant without reducing implementation coupling. If RED reveals material launcher harness expansion, split before production edits; the Gateway config half is the direct W-F1 selection prerequisite and would gate first.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
No explicit token or monetary cap supplied. Keep scope to the three production files, focused tests, this scratchpad, and one existing README.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Issue #1182 and parent #1156 read directly through Mosaic wrappers.
|
|
||||||
- Base derived from issue dependency plus repository topology: FL-07 exists on `origin/next` (introduced by #1172) and is absent from `origin/main`; branch reset before edits to the exact `origin/next` head above.
|
|
||||||
|
|
||||||
## Verification evidence
|
|
||||||
|
|
||||||
- RED observed independently for FL-07 through FL-10 before implementation.
|
|
||||||
- Focused GREEN: Gateway runtime 4/4, config 5/5, launcher 6/6.
|
|
||||||
- Four final per-finding production reverts discriminated: FL-07 made only FL-07 red; FL-08 made its unit and real Gateway boundary controls red after rebuilding config; FL-09 made only its abnormal-spawn controls red; FL-10 made all three provenance controls (`opencode`, `claudex`, and `yolo claudex`) red while sibling findings stayed green.
|
|
||||||
- Public config export negative control: removing only the `packages/config/src/index.ts` export caused TS2305 and `MosaicConfigEnvironmentError is not a constructor`; restoring it passed 5/5.
|
|
||||||
- Gateway full package: 74 files passed, 7 skipped; 829 tests passed, 17 skipped.
|
|
||||||
- Config full test/typecheck/lint/build: green.
|
|
||||||
- Mosaic typecheck/lint/build: green. Vitest is qualified-red only on the exact three update-notice stderr assertions tracked by #1190 — bare `--source`, bare `--decisions`, and bare `--observations`; 1528 other tests pass.
|
|
||||||
- The separate `test:framework-shell` command is red and is not attributed to #1190: `invariant_r_unittest.py` reports the host Pi runtime changed from measured 0.84.1 to 0.80.7. A clean archive of `origin/next@216cd722` reproduces the same single failure. The base test hardcodes the W-B measurement as `PI_VERSION = "0.84.1"`, then resolves `pi` via `shutil.which` and executes `--version`; on this host that is `/home/hermes/.npm-global/bin/pi`, whose global package reports 0.80.7. Issue #1191 tracks the immediate host drift and hardcoded-version design defect; #1184 tracks pinning/approving native Pi 0.84.1. No related source or test is changed here.
|
|
||||||
- Repository typecheck/lint/format/build: green.
|
|
||||||
- Codex security review: no findings. Initial code-review blocker (claudex provenance bypass) remediated with normal/yolo claudex coverage; subsequent public-export finding remediated.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- Coordinated branch: no merge authority; coordinator routes independent exact-head verification.
|
|
||||||
- #1179 currently owns `apps/gateway/src/__tests__/required-security-wiring.test.ts`; this change does not touch it.
|
|
||||||
- #1190 independently tracks the pre-existing CLI smoke/update-notice stderr collision; no #1190 source or test is included here.
|
|
||||||
@@ -3,7 +3,6 @@ export {
|
|||||||
DEFAULT_LOCAL_CONFIG,
|
DEFAULT_LOCAL_CONFIG,
|
||||||
DEFAULT_STANDALONE_CONFIG,
|
DEFAULT_STANDALONE_CONFIG,
|
||||||
DEFAULT_FEDERATED_CONFIG,
|
DEFAULT_FEDERATED_CONFIG,
|
||||||
MosaicConfigEnvironmentError,
|
|
||||||
loadConfig,
|
loadConfig,
|
||||||
validateConfig,
|
validateConfig,
|
||||||
detectFromEnv,
|
detectFromEnv,
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
import { MosaicConfigEnvironmentError as PublicMosaicConfigEnvironmentError } from '@mosaicstack/config';
|
|
||||||
import { detectFromEnv } from './mosaic-config.js';
|
|
||||||
|
|
||||||
interface TypedSelectionFailure {
|
|
||||||
readonly name: string;
|
|
||||||
readonly code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('#1182 fail closed — a wrong answer must not be read as no answer', () => {
|
|
||||||
const originalEnv = process.env;
|
|
||||||
|
|
||||||
it('exports the typed storage-tier refusal from the public @mosaicstack/config entry point', () => {
|
|
||||||
expect(new PublicMosaicConfigEnvironmentError()).toBeInstanceOf(Error);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
delete process.env['DATABASE_URL'];
|
|
||||||
delete process.env['VALKEY_URL'];
|
|
||||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = originalEnv;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FL-08 rejects an invalid explicit MOSAIC_STORAGE_TIER before local PGlite selection', () => {
|
|
||||||
process.env['MOSAIC_STORAGE_TIER'] = 'federatd';
|
|
||||||
let pgliteSelectionCount = 0;
|
|
||||||
let failure: unknown;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = detectFromEnv();
|
|
||||||
if (config.storage.type === 'pglite') {
|
|
||||||
pgliteSelectionCount += 1;
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
failure = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect
|
|
||||||
.soft(failure, 'invalid explicit storage tier must produce a typed selection failure')
|
|
||||||
.toMatchObject({
|
|
||||||
name: 'MosaicConfigEnvironmentError',
|
|
||||||
code: 'invalid_storage_tier',
|
|
||||||
} satisfies TypedSelectionFailure);
|
|
||||||
expect(
|
|
||||||
pgliteSelectionCount,
|
|
||||||
'invalid explicit storage tier must fail before local PGlite is selected',
|
|
||||||
).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([undefined, ''])('preserves the local default for true absence: %j', (tier) => {
|
|
||||||
if (tier === undefined) delete process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
else process.env['MOSAIC_STORAGE_TIER'] = tier;
|
|
||||||
|
|
||||||
const config = detectFromEnv();
|
|
||||||
expect(config.tier).toBe('local');
|
|
||||||
expect(config.storage.type).toBe('pglite');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('anti-drift: storage selection validates a non-empty tier before the local default', () => {
|
|
||||||
const source = readFileSync(new URL('./mosaic-config.ts', import.meta.url), 'utf8');
|
|
||||||
|
|
||||||
expect(
|
|
||||||
/if \(tier !== undefined && tier !== ''[^]*throw new [A-Za-z]+Error/.test(source),
|
|
||||||
'closed storage enums must reject invalid explicit input before DEFAULT_LOCAL_CONFIG',
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -20,16 +20,6 @@ export interface MosaicConfig {
|
|||||||
memory: MemoryConfigRef;
|
memory: MemoryConfigRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Typed startup refusal for an invalid explicit storage-tier selection. */
|
|
||||||
export class MosaicConfigEnvironmentError extends Error {
|
|
||||||
readonly code = 'invalid_storage_tier' as const;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super('Invalid MOSAIC_STORAGE_TIER; expected "local", "standalone", or "federated".');
|
|
||||||
this.name = 'MosaicConfigEnvironmentError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Defaults */
|
/* Defaults */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -136,10 +126,6 @@ export function validateConfig(raw: unknown): MosaicConfig {
|
|||||||
export function detectFromEnv(): MosaicConfig {
|
export function detectFromEnv(): MosaicConfig {
|
||||||
const tier = process.env['MOSAIC_STORAGE_TIER'];
|
const tier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
|
||||||
if (tier !== undefined && tier !== '' && !VALID_TIERS.has(tier)) {
|
|
||||||
throw new MosaicConfigEnvironmentError();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tier === 'federated') {
|
if (tier === 'federated') {
|
||||||
if (process.env['DATABASE_URL']) {
|
if (process.env['DATABASE_URL']) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -26,15 +26,6 @@ Set `MOSAIC_ASSUME_YES=1` (or ensure stdin is not a TTY) to skip all interactive
|
|||||||
| `MOSAIC_ANTHROPIC_API_KEY` | _(none)_ | No |
|
| `MOSAIC_ANTHROPIC_API_KEY` | _(none)_ | No |
|
||||||
| `MOSAIC_CORS_ORIGIN` | `http://localhost:3000` | No |
|
| `MOSAIC_CORS_ORIGIN` | `http://localhost:3000` | No |
|
||||||
|
|
||||||
`MOSAIC_STORAGE_TIER` is a closed enum: `local`, `standalone`, or `federated`.
|
|
||||||
Unset or empty input selects the documented local default. Any other non-empty
|
|
||||||
value is a typed startup error and is rejected before a storage adapter is
|
|
||||||
selected.
|
|
||||||
|
|
||||||
The Gateway process also accepts `CHAT_HARNESS_RUNTIME=legacy|pi-rpc`. Unset or
|
|
||||||
empty input retains the transitional `legacy` default. Any other non-empty value
|
|
||||||
is a typed startup error and is rejected before either runtime is selected.
|
|
||||||
|
|
||||||
### Admin user bootstrap
|
### Admin user bootstrap
|
||||||
|
|
||||||
| Variable | Default | Required |
|
| Variable | Default | Required |
|
||||||
@@ -64,13 +55,6 @@ mosaic yolo claude # …with --dangerously-skip-permissions
|
|||||||
mosaic codex | opencode | pi
|
mosaic codex | opencode | pi
|
||||||
```
|
```
|
||||||
|
|
||||||
Every runtime launch requires its immutable `session.launch` provenance record
|
|
||||||
to be written before spawn. A provenance-write failure exits nonzero, starts no
|
|
||||||
runtime, and propagates no `MOSAIC_LAUNCH_ID`. Likewise, a spawn error, signal,
|
|
||||||
or missing numeric child status is reported with the fixed
|
|
||||||
`runtime_launch_failed` code and exits nonzero rather than being interpreted as
|
|
||||||
success.
|
|
||||||
|
|
||||||
### `mosaic claudex` (EXPERIMENTAL)
|
### `mosaic claudex` (EXPERIMENTAL)
|
||||||
|
|
||||||
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
|
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ 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"
|
||||||
|
|
||||||
PROBE_TIMEOUT_SECONDS: Final = 2.0
|
# Budget for the out-of-process `mosaic __lease-capability` probe. The CLI
|
||||||
|
# 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
|
||||||
@@ -88,7 +95,13 @@ 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
|
||||||
resolved = shutil.which("mosaic")
|
# Resolve against the PROVIDED environment's PATH, not the ambient
|
||||||
|
# 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]
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
|
|
||||||
import {
|
|
||||||
chmodSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import { delimiter, join } from 'node:path';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const TSX_LOADER_URL = pathToFileURL(require.resolve('tsx')).href;
|
|
||||||
const COMMANDER_MODULE_URL = pathToFileURL(require.resolve('commander')).href;
|
|
||||||
const LAUNCH_MODULE_URL = pathToFileURL(join(import.meta.dirname, 'launch.ts')).href;
|
|
||||||
const DRIVER = `
|
|
||||||
const launch = await import(${JSON.stringify(LAUNCH_MODULE_URL)});
|
|
||||||
if (process.env['TEST_CLAUDEX_PROVENANCE'] === '1') {
|
|
||||||
const execute = launch.execRecordedClaudexRuntime;
|
|
||||||
if (typeof execute !== 'function') {
|
|
||||||
process.stderr.write('[missing_recorded_claudex_runtime]\\n');
|
|
||||||
process.exit(70);
|
|
||||||
}
|
|
||||||
execute([], process.env, process.env['TEST_DANGEROUS'] === '1');
|
|
||||||
} else {
|
|
||||||
const { Command } = await import(${JSON.stringify(COMMANDER_MODULE_URL)});
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
launch.registerLaunchCommands(program);
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'opencode']);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface LaunchFixture {
|
|
||||||
readonly root: string;
|
|
||||||
readonly home: string;
|
|
||||||
readonly bin: string;
|
|
||||||
readonly runtimePath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFixture(): LaunchFixture {
|
|
||||||
const root = mkdtempSync('/var/tmp/mosaic-launch-fail-closed-');
|
|
||||||
const home = join(root, 'mosaic-home');
|
|
||||||
const bin = join(root, 'bin');
|
|
||||||
const runtimePath = join(bin, 'opencode');
|
|
||||||
mkdirSync(join(home, 'runtime', 'opencode'), { recursive: true });
|
|
||||||
mkdirSync(bin, { recursive: true });
|
|
||||||
writeFileSync(join(home, 'AGENTS.md'), '# test agents\n');
|
|
||||||
writeFileSync(join(home, 'SOUL.md'), '# test soul\n');
|
|
||||||
writeFileSync(join(home, 'USER.md'), '# test user\n');
|
|
||||||
writeFileSync(join(home, 'TOOLS.md'), '# test tools\n');
|
|
||||||
writeFileSync(join(home, 'runtime', 'opencode', 'RUNTIME.md'), '# test runtime\n');
|
|
||||||
return { root, home, bin, runtimePath };
|
|
||||||
}
|
|
||||||
|
|
||||||
function installRuntime(fixture: LaunchFixture, source: string): void {
|
|
||||||
writeFileSync(fixture.runtimePath, source);
|
|
||||||
chmodSync(fixture.runtimePath, 0o755);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runLauncher(
|
|
||||||
fixture: LaunchFixture,
|
|
||||||
extraEnv: NodeJS.ProcessEnv = {},
|
|
||||||
): SpawnSyncReturns<string> {
|
|
||||||
const env: NodeJS.ProcessEnv = {
|
|
||||||
...process.env,
|
|
||||||
...extraEnv,
|
|
||||||
MOSAIC_HOME: fixture.home,
|
|
||||||
PATH: `${fixture.bin}${delimiter}${process.env['PATH'] ?? ''}`,
|
|
||||||
};
|
|
||||||
delete env['MOSAIC_AGENT_NAME'];
|
|
||||||
delete env['MOSAIC_AGENT_CLASS'];
|
|
||||||
delete env['MOSAIC_AGENT_TOOL_POLICY'];
|
|
||||||
|
|
||||||
return spawnSync(
|
|
||||||
process.execPath,
|
|
||||||
['--import', TSX_LOADER_URL, '--input-type=module', '--eval', DRIVER],
|
|
||||||
{
|
|
||||||
cwd: fixture.root,
|
|
||||||
encoding: 'utf8',
|
|
||||||
env,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('#1182 fail closed — a wrong answer must not be read as no answer', () => {
|
|
||||||
let fixture: LaunchFixture;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = createFixture();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(fixture.root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
{
|
|
||||||
condition: 'spawn error',
|
|
||||||
runtime: '#!/definitely/missing/interpreter\n',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
condition: 'signal with null status',
|
|
||||||
runtime: '#!/usr/bin/env bash\nkill -TERM $$\n',
|
|
||||||
},
|
|
||||||
])('FL-09 converts $condition into a sanitized nonzero launch failure', ({ runtime }) => {
|
|
||||||
installRuntime(fixture, runtime);
|
|
||||||
|
|
||||||
const result = runLauncher(fixture);
|
|
||||||
|
|
||||||
expect.soft(result.status, 'spawn failure must never be converted into exit 0').not.toBe(0);
|
|
||||||
expect
|
|
||||||
.soft(result.stderr, 'spawn failure must report the fixed typed runtime_launch_failed code')
|
|
||||||
.toContain('[runtime_launch_failed]');
|
|
||||||
expect(
|
|
||||||
result.stderr,
|
|
||||||
'spawn diagnostics must not disclose the runtime fixture path',
|
|
||||||
).not.toContain(fixture.runtimePath);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
{ launcher: 'opencode', extraEnv: {} },
|
|
||||||
{
|
|
||||||
launcher: 'claudex',
|
|
||||||
extraEnv: { TEST_CLAUDEX_PROVENANCE: '1' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
launcher: 'yolo claudex',
|
|
||||||
extraEnv: { TEST_CLAUDEX_PROVENANCE: '1', TEST_DANGEROUS: '1' },
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'FL-10 refuses $launcher spawn when mandatory provenance cannot be recorded and fabricates no launch ID',
|
|
||||||
({ extraEnv }) => {
|
|
||||||
const spawnMarker = join(fixture.root, 'runtime-spawned');
|
|
||||||
const launchIdMarker = join(fixture.root, 'runtime-saw-launch-id');
|
|
||||||
installRuntime(
|
|
||||||
fixture,
|
|
||||||
`#!/usr/bin/env bash\nprintf 'spawned' > "$TEST_SPAWN_MARKER"\nif [[ -n "\${MOSAIC_LAUNCH_ID:-}" ]]; then printf '%s' "$MOSAIC_LAUNCH_ID" > "$TEST_LAUNCH_ID_MARKER"; fi\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const ledgerPath = join(fixture.home, 'fleet', 'run', 'sessions', 'events.ndjson');
|
|
||||||
mkdirSync(ledgerPath, { recursive: true });
|
|
||||||
|
|
||||||
const result = runLauncher(fixture, {
|
|
||||||
...extraEnv,
|
|
||||||
TEST_SPAWN_MARKER: spawnMarker,
|
|
||||||
TEST_LAUNCH_ID_MARKER: launchIdMarker,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect.soft(result.status, 'mandatory provenance failure must exit nonzero').not.toBe(0);
|
|
||||||
expect
|
|
||||||
.soft(existsSync(spawnMarker), 'mandatory provenance failure must prevent spawn')
|
|
||||||
.toBe(false);
|
|
||||||
expect
|
|
||||||
.soft(
|
|
||||||
existsSync(launchIdMarker),
|
|
||||||
'a failed provenance write must not fabricate or propagate a launch ID',
|
|
||||||
)
|
|
||||||
.toBe(false);
|
|
||||||
expect
|
|
||||||
.soft(
|
|
||||||
result.stderr,
|
|
||||||
'provenance refusal must report the fixed typed launch_provenance_failed code',
|
|
||||||
)
|
|
||||||
.toContain('[launch_provenance_failed]');
|
|
||||||
expect(
|
|
||||||
result.stderr,
|
|
||||||
'provenance diagnostics must not disclose filesystem details',
|
|
||||||
).not.toContain(fixture.root);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('anti-drift: launcher contains neither null-status success nor mandatory warn-and-run', () => {
|
|
||||||
const source = readFileSync(new URL('./launch.ts', import.meta.url), 'utf8');
|
|
||||||
|
|
||||||
expect
|
|
||||||
.soft(
|
|
||||||
source.includes('result.status ?? 0'),
|
|
||||||
'spawnSync null status must never default to success',
|
|
||||||
)
|
|
||||||
.toBe(false);
|
|
||||||
expect
|
|
||||||
.soft(
|
|
||||||
source.includes('[mosaic] WARNING: launch record not written:'),
|
|
||||||
'mandatory provenance failures must never warn and continue',
|
|
||||||
)
|
|
||||||
.toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -162,24 +162,13 @@ function redactArgv(argv: string[]): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LaunchRecordSuccess {
|
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
|
||||||
readonly ok: true;
|
|
||||||
readonly launchId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LaunchRecordFailure {
|
|
||||||
readonly ok: false;
|
|
||||||
readonly code: 'launch_provenance_failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
type LaunchRecordResult = LaunchRecordSuccess | LaunchRecordFailure;
|
|
||||||
|
|
||||||
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): LaunchRecordResult {
|
|
||||||
// Never let a stale or caller-supplied correlation id masquerade as this launch.
|
|
||||||
delete process.env['MOSAIC_LAUNCH_ID'];
|
|
||||||
try {
|
try {
|
||||||
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
||||||
|
// Correlation id for the lease.register half. Set into process.env so it
|
||||||
|
// propagates through every `...process.env` / `...baseEnv` spread below.
|
||||||
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
||||||
|
process.env['MOSAIC_LAUNCH_ID'] = launchId;
|
||||||
const record = {
|
const record = {
|
||||||
seq: Date.now(),
|
seq: Date.now(),
|
||||||
kind: 'session.launch',
|
kind: 'session.launch',
|
||||||
@@ -204,24 +193,12 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): L
|
|||||||
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
return { ok: true, launchId };
|
} catch (err) {
|
||||||
} catch {
|
// Never block a launch on bookkeeping — but never fail silently either.
|
||||||
return { ok: false, code: 'launch_provenance_failed' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireLaunchRecord(runtime: RuntimeName, cliArgs: string[], yolo: boolean): string {
|
|
||||||
const result = recordLaunch(runtime, cliArgs, yolo);
|
|
||||||
if (!result.ok) {
|
|
||||||
console.error(
|
console.error(
|
||||||
`[mosaic] ERROR [${result.code}]: mandatory launch provenance could not be recorded; runtime was not started.`,
|
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Propagate correlation authority only after its immutable provenance exists.
|
|
||||||
process.env['MOSAIC_LAUNCH_ID'] = result.launchId;
|
|
||||||
return result.launchId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
||||||
@@ -948,7 +925,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
requireLaunchRecord('claude', cliArgs, yolo);
|
recordLaunch('claude', cliArgs, yolo);
|
||||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -962,7 +939,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
requireLaunchRecord('codex', cliArgs, yolo);
|
recordLaunch('codex', cliArgs, yolo);
|
||||||
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -971,7 +948,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
||||||
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||||
requireLaunchRecord('opencode', args, yolo);
|
recordLaunch('opencode', args, yolo);
|
||||||
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -987,7 +964,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
requireLaunchRecord('pi', cliArgs, yolo);
|
recordLaunch('pi', cliArgs, yolo);
|
||||||
execLeaseGatedRuntime('pi', cliArgs);
|
execLeaseGatedRuntime('pi', cliArgs);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1031,31 +1008,19 @@ function execLeaseGatedRuntime(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeLaunchFailureReason = 'spawn_error' | 'signal' | 'missing_status';
|
/** exec into the runtime, replacing the current process. */
|
||||||
|
|
||||||
interface RuntimeLaunchFailure {
|
|
||||||
readonly code: 'runtime_launch_failed';
|
|
||||||
readonly reason: RuntimeLaunchFailureReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
function refuseRuntimeLaunch(reason: RuntimeLaunchFailureReason): never {
|
|
||||||
const failure: RuntimeLaunchFailure = { code: 'runtime_launch_failed', reason };
|
|
||||||
console.error(
|
|
||||||
`[mosaic] ERROR [${failure.code}]: runtime process did not produce a successful exit result (${failure.reason}).`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Spawn the runtime and preserve only a real numeric exit status as success. */
|
|
||||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||||
|
try {
|
||||||
|
// Use execFileSync with inherited stdio to replace the process
|
||||||
const result = spawnSync(cmd, args, {
|
const result = spawnSync(cmd, args, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
if (result.error !== undefined) refuseRuntimeLaunch('spawn_error');
|
process.exit(result.status ?? 0);
|
||||||
if (result.signal !== null) refuseRuntimeLaunch('signal');
|
} catch (err) {
|
||||||
if (result.status === null) refuseRuntimeLaunch('missing_status');
|
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
|
||||||
process.exit(result.status);
|
process.exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1065,15 +1030,6 @@ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = proce
|
|||||||
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
|
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
|
||||||
* logic lives in the DI module, not here.
|
* logic lives in the DI module, not here.
|
||||||
*/
|
*/
|
||||||
export function execRecordedClaudexRuntime(
|
|
||||||
args: string[],
|
|
||||||
env: NodeJS.ProcessEnv,
|
|
||||||
dangerous: boolean,
|
|
||||||
): void {
|
|
||||||
const launchId = requireLaunchRecord('claude', args, dangerous);
|
|
||||||
execLeaseGatedRuntime('claude', args, { ...env, MOSAIC_LAUNCH_ID: launchId }, dangerous);
|
|
||||||
}
|
|
||||||
|
|
||||||
function launchClaudexProduction(args: string[], yolo: boolean): void {
|
function launchClaudexProduction(args: string[], yolo: boolean): void {
|
||||||
writeSessionLock('claude');
|
writeSessionLock('claude');
|
||||||
const adapter: ClaudexHarnessAdapter = {
|
const adapter: ClaudexHarnessAdapter = {
|
||||||
@@ -1085,7 +1041,8 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
|
|||||||
checkSequentialThinking('claude');
|
checkSequentialThinking('claude');
|
||||||
},
|
},
|
||||||
composePrompt: () => buildRuntimePrompt('claude'),
|
composePrompt: () => buildRuntimePrompt('claude'),
|
||||||
execLeaseGated: execRecordedClaudexRuntime,
|
execLeaseGated: (cmdArgs, env, dangerous) =>
|
||||||
|
execLeaseGatedRuntime('claude', cmdArgs, env, dangerous),
|
||||||
};
|
};
|
||||||
void launchClaudex(args, yolo, adapter);
|
void launchClaudex(args, yolo, adapter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ 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';
|
||||||
@@ -35,6 +37,17 @@ 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({
|
||||||
@@ -100,15 +113,6 @@ 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', () => {
|
||||||
@@ -127,6 +131,61 @@ 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,6 +55,19 @@ 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 &&
|
||||||
@@ -110,12 +123,28 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,9 +168,10 @@ export function defaultCapabilityProbe(
|
|||||||
const cliEntry = resolveCliEntry();
|
const cliEntry = resolveCliEntry();
|
||||||
if (!existsSync(cliEntry)) return null;
|
if (!existsSync(cliEntry)) return null;
|
||||||
|
|
||||||
const output = execFileSync(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
|
const execFile: CapabilityProbeExecFile = deps.execFile ?? execFileSync;
|
||||||
|
const output = execFile(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 2000,
|
timeout: LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,15 @@ 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"
|
||||||
@@ -57,6 +61,20 @@ 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."""
|
||||||
@@ -110,11 +128,102 @@ 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
|
||||||
|
# installation. The dedicated hermeticity tests below provide fake
|
||||||
|
# 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(
|
result = VERSION_GATE.default_probe_activation_capability(
|
||||||
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
|
{"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:
|
||||||
@@ -135,6 +244,29 @@ 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
|
||||||
@@ -174,7 +306,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=2.0)
|
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=10.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")
|
||||||
|
|||||||
Reference in New Issue
Block a user