Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2db9a3f73 |
@@ -451,24 +451,15 @@ describe('AppModule federation gating', (): void => {
|
||||
);
|
||||
|
||||
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> => {
|
||||
const failure = await loadModuleGraphFromDotenv({
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
* absence (unset or empty) retains the documented transitional legacy default;
|
||||
* any other explicit value must be a member of the closed runtime enum.
|
||||
* Resolves the process-wide chat runtime mode from the environment. Anything other
|
||||
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
|
||||
*/
|
||||
export function resolveChatRuntimeMode(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): ChatRuntimeMode {
|
||||
const runtime = env['CHAT_HARNESS_RUNTIME'];
|
||||
if (runtime === undefined || runtime === '') return 'legacy';
|
||||
if (runtime === 'legacy' || runtime === 'pi-rpc') return runtime;
|
||||
throw new ChatRuntimeConfigurationError();
|
||||
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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_STANDALONE_CONFIG,
|
||||
DEFAULT_FEDERATED_CONFIG,
|
||||
MosaicConfigEnvironmentError,
|
||||
loadConfig,
|
||||
validateConfig,
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -136,10 +126,6 @@ export function validateConfig(raw: unknown): MosaicConfig {
|
||||
export function detectFromEnv(): MosaicConfig {
|
||||
const tier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
|
||||
if (tier !== undefined && tier !== '' && !VALID_TIERS.has(tier)) {
|
||||
throw new MosaicConfigEnvironmentError();
|
||||
}
|
||||
|
||||
if (tier === 'federated') {
|
||||
if (process.env['DATABASE_URL']) {
|
||||
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_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
|
||||
|
||||
| Variable | Default | Required |
|
||||
@@ -64,13 +55,6 @@ mosaic yolo claude # …with --dangerously-skip-permissions
|
||||
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)
|
||||
|
||||
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
|
||||
|
||||
@@ -57,12 +57,18 @@ done
|
||||
PKG_JSON="$ROOT/packages/mosaic/package.json"
|
||||
CI_YML="$ROOT/.woodpecker/ci.yml"
|
||||
TOOLS_DIR="$ROOT/packages/mosaic/framework/tools"
|
||||
# The population is "basename matches *test*.sh", which is not a tools/ property.
|
||||
# Direction A used to scan TOOLS_DIR, so a suite in a SIBLING of tools/ was invisible
|
||||
# to the guard whose whole purpose is making that impossible — measured on origin/next
|
||||
# as systemd/user/test-fleet-units.sh, on no CI surface and in no exclusion (@scooby).
|
||||
# Scan the framework, so the scanned surface matches the claimed property.
|
||||
FRAMEWORK_DIR="$ROOT/packages/mosaic/framework"
|
||||
EXCLUSIONS="$TOOLS_DIR/quality/test-enumeration-exclusions.txt"
|
||||
|
||||
for f in "$PKG_JSON" "$CI_YML"; do
|
||||
[[ -f "$f" ]] || { echo "FAIL: required surface file missing: $f" >&2; exit 2; }
|
||||
done
|
||||
[[ -d "$TOOLS_DIR" ]] || { echo "FAIL: tools dir missing: $TOOLS_DIR" >&2; exit 2; }
|
||||
[[ -d "$FRAMEWORK_DIR" ]] || { echo "FAIL: framework dir missing: $FRAMEWORK_DIR" >&2; exit 2; }
|
||||
|
||||
fail_count=0
|
||||
fail() { printf 'FAIL %s\n' "$1"; fail_count=$(( fail_count + 1 )); }
|
||||
@@ -90,7 +96,7 @@ print("\n".join(seen))
|
||||
PY
|
||||
)
|
||||
|
||||
# --- Surface 2: ci.yml, every framework/tools token wherever it appears ------
|
||||
# --- Surface 2: ci.yml, every framework token wherever it appears ------------
|
||||
# Comment lines (first non-whitespace char is #) are skipped BEFORE matching:
|
||||
# commenting an invocation out is the most common way a suite actually gets
|
||||
# disabled, and a raw-text regex would keep calling it enumerated (F1, 20155 on
|
||||
@@ -98,7 +104,7 @@ PY
|
||||
# in a TRAILING comment on a live line still matches; no such line exists today
|
||||
# and full fidelity would need a YAML parser the CI image does not ship.
|
||||
mapfile -t S2 < <(grep -vE '^[[:space:]]*#' "$CI_YML" \
|
||||
| grep -oE 'packages/mosaic/framework/tools/[A-Za-z0-9_./-]+\.(sh|py)' | sort -u)
|
||||
| grep -oE 'packages/mosaic/framework/[A-Za-z0-9_./-]+\.(sh|py)' | sort -u)
|
||||
|
||||
# --- Union, and its population-restricted view -------------------------------
|
||||
declare -A ENUM=() ENUM_POP=()
|
||||
@@ -154,7 +160,7 @@ while IFS= read -r f; do
|
||||
fail "UNENUMERATED: '$rel' exists on disk but is neither enumerated on any CI surface nor signed in the exclusions file"
|
||||
unlisted=$(( unlisted + 1 ))
|
||||
fi
|
||||
done < <(find "$TOOLS_DIR" -type f -name '*.sh' | sort)
|
||||
done < <(find "$FRAMEWORK_DIR" -type f -name '*.sh' | sort)
|
||||
|
||||
if (( fail_count > 0 )); then
|
||||
printf 'enumeration guard: %d failure(s) — population %d, enumerated (in-population) %d, excluded %d\n' \
|
||||
|
||||
@@ -161,6 +161,34 @@ R="$(fixture n7)"
|
||||
excl "$R" "packages/mosaic/framework/tools/quality/scripts/verify-thing.sh | not a suite but signing it anyway"
|
||||
expect NEEDLE 1 "out-of-population exclusion rejected" --out "EXCLUSION OUTSIDE POPULATION" -- "$R"
|
||||
|
||||
echo "=== n9/c5: a suite in a SIBLING of tools/ is in the population (@scooby, 2026-08-16) ==="
|
||||
# Every other fixture here lives under framework/tools/, which is how the guard came to
|
||||
# scan TOOLS_DIR while claiming a population defined by basename alone. The real specimen
|
||||
# was framework/systemd/user/test-fleet-units.sh: a member by the guard's own definition,
|
||||
# on no CI surface, in no exclusion, and structurally unreachable by the scan. n9 is that
|
||||
# blind spot; without it a future narrowing back to TOOLS_DIR passes all fourteen needles.
|
||||
R="$(fixture n9)"
|
||||
mkdir -p "$R/packages/mosaic/framework/systemd/user"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/systemd/user/test-sibling.sh"
|
||||
expect NEEDLE 1 "suite outside tools/ but inside framework/ is enumerable, not invisible" \
|
||||
--out "UNENUMERATED: 'packages/mosaic/framework/systemd/user/test-sibling.sh'" -- "$R"
|
||||
# c5 is why the S2 regex had to widen WITH the scan: detecting the file is useless if the
|
||||
# fix for it cannot be recognised. Enumerating a sibling-directory suite on ci.yml must
|
||||
# clear the finding — under a tools/-scoped S2 it stays UNENUMERATED forever and the only
|
||||
# reachable disposition is an exclusion.
|
||||
#
|
||||
# Measured scope of what c5 catches, because it is narrower than it looks: against the
|
||||
# ORIGINAL guard (both hunks absent) c5 passes vacuously — the scan never sees the file
|
||||
# and S2 never matches it, so nothing is asserted. It discriminates against the HALF-patch
|
||||
# — scan widened, S2 narrowed back — which is the realistic future regression, and it was
|
||||
# confirmed red in exactly that state. n9 is the one that fails on the original.
|
||||
R="$(fixture c5)"
|
||||
mkdir -p "$R/packages/mosaic/framework/systemd/user"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/systemd/user/test-sibling.sh"
|
||||
printf ' - bash packages/mosaic/framework/systemd/user/test-sibling.sh\n' >> "$R/.woodpecker/ci.yml"
|
||||
expect CONTROL 0 "enumerating a sibling-directory suite on ci.yml actually clears it" \
|
||||
--out "enumeration guard: OK" -- "$R"
|
||||
|
||||
echo
|
||||
printf 'enumeration-guard needles: %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
(( FAIL == 0 ))
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/systemd/user/test-fleet-units.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -200,24 +200,13 @@ function redactArgv(argv: string[]): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
interface LaunchRecordSuccess {
|
||||
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'];
|
||||
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
|
||||
try {
|
||||
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')}`;
|
||||
process.env['MOSAIC_LAUNCH_ID'] = launchId;
|
||||
const record = {
|
||||
seq: Date.now(),
|
||||
kind: 'session.launch',
|
||||
@@ -244,24 +233,12 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): L
|
||||
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
return { ok: true, launchId };
|
||||
} catch {
|
||||
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) {
|
||||
} catch (err) {
|
||||
// Never block a launch on bookkeeping — but never fail silently either.
|
||||
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 ──────────────────────────────────────────────────────
|
||||
@@ -999,7 +976,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
requireLaunchRecord('claude', cliArgs, yolo);
|
||||
recordLaunch('claude', cliArgs, yolo);
|
||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||
break;
|
||||
}
|
||||
@@ -1013,7 +990,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
requireLaunchRecord('codex', cliArgs, yolo);
|
||||
recordLaunch('codex', cliArgs, yolo);
|
||||
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
||||
break;
|
||||
}
|
||||
@@ -1022,7 +999,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
||||
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||
requireLaunchRecord('opencode', args, yolo);
|
||||
recordLaunch('opencode', args, yolo);
|
||||
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
||||
break;
|
||||
}
|
||||
@@ -1038,7 +1015,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
requireLaunchRecord('pi', cliArgs, yolo);
|
||||
recordLaunch('pi', cliArgs, yolo);
|
||||
execLeaseGatedRuntime('pi', cliArgs);
|
||||
break;
|
||||
}
|
||||
@@ -1082,31 +1059,19 @@ function execLeaseGatedRuntime(
|
||||
);
|
||||
}
|
||||
|
||||
type RuntimeLaunchFailureReason = 'spawn_error' | 'signal' | 'missing_status';
|
||||
|
||||
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. */
|
||||
/** exec into the runtime, replacing the current process. */
|
||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||
const result = spawnSync(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
if (result.error !== undefined) refuseRuntimeLaunch('spawn_error');
|
||||
if (result.signal !== null) refuseRuntimeLaunch('signal');
|
||||
if (result.status === null) refuseRuntimeLaunch('missing_status');
|
||||
process.exit(result.status);
|
||||
try {
|
||||
// Use execFileSync with inherited stdio to replace the process
|
||||
const result = spawnSync(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
process.exit(result.status ?? 0);
|
||||
} catch (err) {
|
||||
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1116,15 +1081,6 @@ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = proce
|
||||
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
|
||||
* 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 {
|
||||
writeSessionLock('claude');
|
||||
const adapter: ClaudexHarnessAdapter = {
|
||||
@@ -1136,7 +1092,8 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
|
||||
checkSequentialThinking('claude');
|
||||
},
|
||||
composePrompt: () => buildRuntimePrompt('claude'),
|
||||
execLeaseGated: execRecordedClaudexRuntime,
|
||||
execLeaseGated: (cmdArgs, env, dangerous) =>
|
||||
execLeaseGatedRuntime('claude', cmdArgs, env, dangerous),
|
||||
};
|
||||
void launchClaudex(args, yolo, adapter);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user