Compare commits

..
Author SHA1 Message Date
fred 982f4fc8f9 test(lease): stop the host's lease identity leaking into spawned hooks
Four cases in mutator-gate.acceptance.spec.ts fail whenever the suite runs
inside a Mosaic-managed agent seat, and pass everywhere else. They are not a
product defect: the gate denies in all four, it just denies for the wrong
reason.

The specs build each spawned hook's environment as `{ ...process.env, <the few
vars the case sets> }`. Inside a managed seat, process.env already carries that
seat's live lease identity, and whatever the spread does not override survives
into the child. MOSAIC_LEASE_GENERATION_FILE is the one that bites:
read_runtime_generation() prefers that file over MOSAIC_RUNTIME_GENERATION, so
a case that carefully sets MOSAIC_RUNTIME_GENERATION=1 is silently overruled by
the host's generation counter -- 388 on the seat this was found on. The revoke
client reads 388 and sends it, the test broker advances the session to that
generation, and every later authorize() in the case sends generation 1, is now
behind, and is denied STALE_GENERATION instead of the asserted
MUTATOR_UNVERIFIED. The fourth failure (runtime gate status 2 rather than 0) is
the same cause.

Fixed once, centrally: a vitest setupFile scrubs the host lease variables from
process.env before any spec in the package runs, so all twelve spread sites are
covered and a thirteenth cannot reintroduce it. Scrubbing is by prefix rather
than by an explicit list, because a lease variable added later leaks by exactly
the same route and a list would have to be remembered.

Measured, not assumed. Red on origin/next: 4 failed / 16 passed. The same spec
re-run with only those five variables stripped and no code change: 20/20. With
this commit: typecheck clean, package build clean, 1537 tests passed across 85
files, 0 failed. Falsified by unregistering the setup file, which turns the
wiring assertion red.

The eslint change is mechanical: the project service needs root-level config
files listed in allowDefaultProject, which already carries the sibling
packages/mosaic/vitest.config.ts.

Worth recording why it lasted: on a clean checkout and in CI these variables are
unset, so the suite is green and the leak is invisible. It only reproduces in
the one environment nobody runs the suite in.
2026-08-15 13:28:39 -05:00
8 changed files with 114 additions and 189 deletions
+1
View File
@@ -33,6 +33,7 @@ export default tseslint.config(
'packages/db/vitest.config.ts', 'packages/db/vitest.config.ts',
'packages/storage/vitest.config.ts', 'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts', 'packages/mosaic/vitest.config.ts',
'packages/mosaic/vitest.setup.ts',
'packages/mosaic/__tests__/*.ts', 'packages/mosaic/__tests__/*.ts',
'tools/federation-harness/*.ts', 'tools/federation-harness/*.ts',
], ],
@@ -128,14 +128,6 @@ EOF
sleep 30 sleep 30
EOF EOF
chmod 700 "$AGENT_BIN/mosaic" chmod 700 "$AGENT_BIN/mosaic"
# The launcher resolves the roster's runtime against PANE_PATH before it
# spawns anything (#1241), so the runtime this projection names has to be
# present here even though the fake `mosaic` above never execs it.
cat > "$AGENT_BIN/pi" <<'EOF'
#!/bin/sh
sleep 30
EOF
chmod 700 "$AGENT_BIN/pi"
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort) server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort) server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \ if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
@@ -286,36 +286,6 @@ _build_runtime_bin_prefix() {
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
# #1241. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared
# environment. A binary missing from *that* path is a pane that dies in under a
# second, inside a session nobody is attached to, with its diagnostic scrolled
# into a pane tmux then destroys. Resolve both here, before any effect, where
# the failure is still attributable to the thing that caused it.
#
# `mosaic yolo <runtime>` runs checkRuntime(runtime) and the binary it looks for
# is named exactly like the runtime, so resolving the runtime name is the same
# question the pane will ask a moment later — asked while an operator can still
# see the answer.
_resolve_in_pane_path() {
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
}
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
# 64 (EX_USAGE) rejections above, which mean the projection itself was bad —
# here the data is fine and the host is not ready. Callers tell the individual
# cases apart by `code=`, the same way fail_env's many codes share exit 64.
fail_launch() {
local code="$1"
shift
echo "ERROR: agent launch aborted: code=${code} agent=${AGENT_NAME} $*" >&2
exit 69
}
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
_resolve_in_pane_path "$required_binary" >/dev/null ||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
done
_ensure_claude_workdir_trusted() { _ensure_claude_workdir_trusted() {
local workdir="$1" local workdir="$1"
local resolved local resolved
@@ -414,19 +384,6 @@ if [ -n "$PANE_PID" ]; then
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2 echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
elif _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
# #1241. Session present, no pane PID after a second of retries. Whatever this
# is, it is not a seat an operator can use, so it is not a success either.
fail_launch pane-pid-unresolved \
"tmux reports the session but no pane PID after 5 attempts"
else else
# #1241. This branch used to print a WARNING about the heartbeat sidecar and echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
# exit 0. It is not a heartbeat problem: tmux destroys a session when its pane
# command exits, so an absent session one second after new-session means the
# runtime died on startup. Reporting it as success is what let `fleet start`
# return 0 over three dead panes — the launcher knew, and said the wrong thing
# at the wrong severity to the wrong layer.
fail_launch pane-did-not-survive \
"the pane exited immediately and tmux destroyed the session;" \
"run 'mosaic yolo ${MOSAIC_AGENT_RUNTIME}' in ${MOSAIC_AGENT_WORKDIR} to see why"
fi fi
@@ -23,26 +23,8 @@ index=0
if [ "${args[0]:-}" = -L ]; then index=2; fi if [ "${args[0]:-}" = -L ]; then index=2; fi
case "${args[$index]:-}" in case "${args[$index]:-}" in
has-session) has-session)
# The holder always answers. MOSAIC_TEST_HELD_SESSIONS lets a case add
# other targets that should answer too — without it there is no way to
# model "tmux still reports the session" for a non-holder agent, and the
# launcher's pane-pid-unresolved branch is unreachable from this harness.
#
# A listed target answers only AFTER new-session, because the launcher asks
# this question twice about the same name: once before launching, where a
# yes means "already running, nothing to do, exit 0", and once after, where
# a yes means "the session survived". A shim that answered yes to both
# would short-circuit at the first and never reach the branch under test —
# it would look like coverage and measure the idempotency path instead.
for argument in "${args[@]}"; do for argument in "${args[@]}"; do
[ "$argument" = '=_holder:0.0' ] && exit 0 [ "$argument" = '=_holder:0.0' ] && exit 0
case " ${MOSAIC_TEST_HELD_SESSIONS:-} " in
*" $argument "*)
if tr '\0' '\n' < "${MOSAIC_TEST_TMUX_CALLS:?}" | grep -qxF new-session; then
exit 0
fi
;;
esac
done done
exit 1 exit 1
;; ;;
@@ -80,30 +62,6 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
SHIM SHIM
chmod +x "$FAKE_BIN/mosaic" chmod +x "$FAKE_BIN/mosaic"
# The runtime the rosters below name. The launcher resolves it against PANE_PATH
# before spawning (#1241), so it has to exist somewhere the pane would find it —
# not merely on the launcher's own PATH.
printf '#!/usr/bin/env bash\nexit 0\n' > "$FAKE_BIN/pi"
chmod +x "$FAKE_BIN/pi"
# PANE_PATH is derived partly from `npm config get prefix`. Left to the real npm
# it would splice whatever the host has installed into the path under test, and
# the missing-binary cases below would pass or fail by accident of the machine.
cat > "$FAKE_BIN/npm" <<'SHIM'
#!/usr/bin/env bash
printf '%s\n' "${MOSAIC_TEST_NPM_PREFIX:-/nonexistent}"
SHIM
chmod +x "$FAKE_BIN/npm"
# PANE_PATH always ends in the system path. A host that installs these there can
# not measure the missing-binary cases at all, and a green run would mean
# nothing — so say so instead of passing.
for host_binary in mosaic pi; do
if PATH=/usr/local/bin:/usr/bin:/bin command -v "$host_binary" >/dev/null 2>&1; then
fail "host provides '$host_binary' in the system path; missing-binary cases are not measurable here"
fi
done
write_generated() { write_generated() {
local home="$1" local home="$1"
local agent="$2" local agent="$2"
@@ -123,19 +81,6 @@ MOSAIC_TMUX_SOCKET=mosaic-test
EOF EOF
chmod 600 "$home/fleet/agents/$agent.env.generated" chmod 600 "$home/fleet/agents/$agent.env.generated"
mkdir -p "$home/work" mkdir -p "$home/work"
install_pane_binaries "$home"
}
# `$PANE_HOME/.npm-global/bin` is one of the prefixes the launcher folds into
# PANE_PATH, so this is the pane's own view of "installed", distinct from the
# launcher's PATH. Tests that need a binary *absent* remove it from here.
install_pane_binaries() {
local pane_home="$1"
mkdir -p "$pane_home/.npm-global/bin"
local binary
for binary in mosaic pi; do
ln -sf "$FAKE_BIN/$binary" "$pane_home/.npm-global/bin/$binary"
done
} }
run_start() { run_start() {
@@ -143,7 +88,6 @@ run_start() {
local agent="$2" local agent="$2"
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \ HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \ MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \
MOSAIC_TEST_HOME="$home" \ MOSAIC_TEST_HOME="$home" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$home" "$START" "$agent" MOSAIC_HOME="$home" "$START" "$agent"
@@ -154,10 +98,7 @@ run_start() {
HOME_VALID="$ROOT/valid" HOME_VALID="$ROOT/valid"
AGENT_VALID="coder0" AGENT_VALID="coder0"
write_generated "$HOME_VALID" "$AGENT_VALID" write_generated "$HOME_VALID" "$AGENT_VALID"
# A live pane PID is part of what "valid launch" means. Until #1241 this case run_start "$HOME_VALID" "$AGENT_VALID"
# ran with none, so the suite's one success path was itself a dead pane the
# launcher reported as fine.
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID"
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS") valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux" echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing" echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
@@ -304,13 +245,6 @@ PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \ printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
# This case does not go through run_start, so its pane binaries come from
# MOSAIC_RUNTIME_BIN=$FAKE_BIN in the env.local written above — not from the
# symlinks install_pane_binaries planted under the generated home, which this
# launcher never consults because HOME here is the trusted parent. That is a
# legitimate resolution path, but it means dropping MOSAIC_RUNTIME_BIN from
# this case on the belief that the symlinks cover it would break the #1241
# binary check rather than exercise it.
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \ LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
BASH_ENV="$PANE_BASH_ENV" \ BASH_ENV="$PANE_BASH_ENV" \
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \ MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
@@ -324,7 +258,6 @@ PATH="$PANE_STALE_PATH" \
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \ "MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_TEST_EXECUTE_PANE=1 \ MOSAIC_TEST_EXECUTE_PANE=1 \
"MOSAIC_TEST_PANE_PID=$$" \
"$START" coder-pane-boundary "$START" coder-pane-boundary
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS") pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
@@ -459,75 +392,6 @@ echo "$interaction_policy_args" | grep -qF 'new-session' && \
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \ echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
fail "interaction pinned-policy check did not follow strict parsing" fail "interaction pinned-policy check did not follow strict parsing"
# #1241. The pane runs `mosaic yolo <runtime>` against PANE_PATH. A binary
# missing from that path is a launch failure, and it has to be named before the
# session is created — after it, the diagnostic dies with the pane.
assert_missing_pane_binary_rejected() {
local binary="$1"
local home="$ROOT/missing-$binary"
local agent="coder-missing-$binary"
write_generated "$home" "$agent"
rm -f "$home/.npm-global/bin/$binary"
: > "$TMUX_CALLS"
local output
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$home" "$agent" 2>&1); then
fail "launch succeeded with '$binary' absent from the pane PATH"
fi
echo "$output" | grep -qF 'code=missing-binary' || fail "missing '$binary' diagnostic missing"
echo "$output" | grep -qF "'$binary'" || fail "missing-binary diagnostic did not name $binary"
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
fail "launcher created a session it knew would die ($binary absent)"
fi
}
assert_missing_pane_binary_rejected mosaic
assert_missing_pane_binary_rejected pi
# #1241. tmux destroys a session when its pane command exits, so no pane PID a
# second after new-session means the runtime died on startup. This used to be a
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
# then reported a fleet that was not running.
: > "$TMUX_CALLS"
HOME_DEAD_PANE="$ROOT/dead-pane"
write_generated "$HOME_DEAD_PANE" "coder-dead-pane"
if output=$(MOSAIC_TEST_PANE_PID='' run_start "$HOME_DEAD_PANE" coder-dead-pane 2>&1); then
fail "launcher reported success over a pane that did not survive"
fi
echo "$output" | grep -qF 'code=pane-did-not-survive' || fail "dead-pane diagnostic missing"
if echo "$output" | grep -qiF 'heartbeat'; then
fail "dead pane is still being reported as a heartbeat-sidecar problem"
fi
tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session || \
fail "dead-pane case did not reach the launch it is measuring"
# #1241, the other way a pane fails. Above, tmux destroyed the session and
# has-session said so. Here the session is still there and no PID comes back
# after the retries — a different fault (the pane is alive but unusable, or
# tmux is answering inconsistently) that an operator has to be told apart from
# a runtime that died on startup.
#
# This case exists because the branch that handles it shipped with nothing able
# to reach it: the shim answered has-session only for the holder, so every
# non-holder agent landed in the session-is-gone branch no matter what. A
# defensive branch nothing exercises is the same shape as the bug this whole
# change is about, one layer down.
: > "$TMUX_CALLS"
HOME_NO_PID="$ROOT/pane-no-pid"
write_generated "$HOME_NO_PID" "coder-no-pid"
if output=$(MOSAIC_TEST_PANE_PID='' MOSAIC_TEST_HELD_SESSIONS='=coder-no-pid:0.0' \
run_start "$HOME_NO_PID" coder-no-pid 2>&1); then
fail "launcher reported success over a session with no resolvable pane PID"
fi
echo "$output" | grep -qF 'code=pane-pid-unresolved' || \
fail "session-present/no-PID was not reported as pane-pid-unresolved: $output"
if echo "$output" | grep -qF 'code=pane-did-not-survive'; then
fail "a session tmux still reports was diagnosed as a destroyed session"
fi
if echo "$output" | grep -qiF 'heartbeat'; then
fail "an unresolvable pane PID is still being reported as a heartbeat-sidecar problem"
fi
# Exact stop derives the socket exclusively from the validated generated # Exact stop derives the socket exclusively from the validated generated
# projection and ignores an ambient socket supplied by the caller. # projection and ignores an ambient socket supplied by the caller.
: > "$TMUX_CALLS" : > "$TMUX_CALLS"
@@ -0,0 +1,64 @@
import { describe, expect, test } from 'vitest';
import { isHostLeaseVariable, scrubHostLeaseEnv } from './host-lease-env.js';
describe('host lease environment scrubbing', () => {
test('removes every lease variable and reports what it removed', () => {
const environment = {
MOSAIC_LEASE_GENERATION_FILE: '/run/user/1001/mosaic-lease/generation-abc.state',
MOSAIC_LEASE_SESSION_ID: 'a'.repeat(64),
MOSAIC_LEASE_BROKER_SOCKET: '/run/user/1001/mosaic-lease/broker.sock',
MOSAIC_LEASE_RUNTIME: 'claude',
MOSAIC_RUNTIME_GENERATION: '388',
PATH: '/usr/bin',
MOSAIC_AGENT_NAME: 'fred',
} as NodeJS.ProcessEnv;
expect(scrubHostLeaseEnv(environment)).toEqual([
'MOSAIC_LEASE_BROKER_SOCKET',
'MOSAIC_LEASE_GENERATION_FILE',
'MOSAIC_LEASE_RUNTIME',
'MOSAIC_LEASE_SESSION_ID',
'MOSAIC_RUNTIME_GENERATION',
]);
expect(environment).toEqual({ PATH: '/usr/bin', MOSAIC_AGENT_NAME: 'fred' });
});
test('a lease variable added later is scrubbed without being listed anywhere', () => {
// The prefix rule is the point: this is the case a hand-maintained list would miss.
const environment = { MOSAIC_LEASE_SOMETHING_NEW: 'x' } as NodeJS.ProcessEnv;
expect(scrubHostLeaseEnv(environment)).toEqual(['MOSAIC_LEASE_SOMETHING_NEW']);
expect(environment).toEqual({});
});
test('leaves unrelated variables alone', () => {
const environment = {
MOSAIC_AGENT_NAME: 'fred',
MOSAIC_HOME: '/home/fred/.mosaic',
HOME: '/home/fred',
} as NodeJS.ProcessEnv;
expect(scrubHostLeaseEnv(environment)).toEqual([]);
expect(environment).toEqual({
MOSAIC_AGENT_NAME: 'fred',
MOSAIC_HOME: '/home/fred/.mosaic',
HOME: '/home/fred',
});
});
test('classifies by prefix, not by an exact list', () => {
expect(isHostLeaseVariable('MOSAIC_LEASE_ANYTHING')).toBe(true);
expect(isHostLeaseVariable('MOSAIC_RUNTIME_GENERATION')).toBe(true);
expect(isHostLeaseVariable('MOSAIC_RUNTIME')).toBe(false);
expect(isHostLeaseVariable('LEASE_MOSAIC_X')).toBe(false);
});
// Wiring check. On a clean checkout or in CI these variables are unset, so this
// passes whether or not vitest.setup.ts is registered -- it is worth little there and
// is not claimed to be. Its value is inside a Mosaic-managed agent seat, where the
// variables ARE set and this is the assertion that catches the setup file being
// dropped from vitest.config.ts. That is the environment the leak was found in.
test('the suite does not run with the host lease identity in scope', () => {
expect(Object.keys(process.env).filter(isHostLeaseVariable)).toEqual([]);
});
});
@@ -0,0 +1,41 @@
/**
* Remove the host's live lease identity from an environment before tests run.
*
* The lease specs start their own broker on a private socket and then spawn the real
* hook scripts against it, building each child's environment as `{ ...process.env, <the
* few vars this case cares about> }`. That spread is the problem: when the suite runs
* inside a Mosaic-managed agent seat, `process.env` already carries that seat's real
* lease identity, and the parts the spread does not override survive into the child.
*
* `MOSAIC_LEASE_GENERATION_FILE` is the one that bites. `read_runtime_generation()`
* prefers that file over `MOSAIC_RUNTIME_GENERATION`, so a case that carefully sets
* `MOSAIC_RUNTIME_GENERATION: '1'` is silently overruled by the host's generation
* counter -- which on a long-lived seat is in the hundreds. The revoke client reads it,
* sends it, and the test broker advances the session to that generation. Every later
* `authorize` in the case sends generation 1, is now behind, and is denied with
* `STALE_GENERATION` instead of the `MUTATOR_UNVERIFIED` the case asserts. The gate
* still denies, so this is not a hole in the product -- but it turns four acceptance
* tests red for a reason that has nothing to do with the code under test.
*
* It only reproduces inside a managed seat. On a clean checkout or in CI these vars are
* unset, the suite is green, and the leak is invisible -- which is why it survived.
*
* Scrubbing by prefix rather than by an explicit list is deliberate: any lease variable
* added later leaks by exactly the same route, and a list would have to be remembered.
*/
const HOST_LEASE_PREFIX = 'MOSAIC_LEASE_';
const HOST_LEASE_EXTRA = ['MOSAIC_RUNTIME_GENERATION'];
export function isHostLeaseVariable(name: string): boolean {
return name.startsWith(HOST_LEASE_PREFIX) || HOST_LEASE_EXTRA.includes(name);
}
/** Deletes the host lease variables from `environment`; returns the names removed. */
export function scrubHostLeaseEnv(environment: NodeJS.ProcessEnv): string[] {
const removed = Object.keys(environment).filter(isHostLeaseVariable);
for (const name of removed) {
delete environment[name];
}
return removed.sort();
}
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
globals: true, globals: true,
environment: 'node', environment: 'node',
testTimeout: 30_000, testTimeout: 30_000,
setupFiles: ['./vitest.setup.ts'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
include: ['src/commands/skill.ts', 'src/lease-broker/broker-test-client.ts'], include: ['src/commands/skill.ts', 'src/lease-broker/broker-test-client.ts'],
+5
View File
@@ -0,0 +1,5 @@
import { scrubHostLeaseEnv } from './src/test-support/host-lease-env.js';
// Runs before every spec file in this package. See src/test-support/host-lease-env.ts
// for why the host's lease identity must not reach a spawned hook process.
scrubHostLeaseEnv(process.env);