From f2b01d544e081aaa15ee83ea1e329d89cb64e931 Mon Sep 17 00:00:00 2001 From: Flint <263629284+tps-flint@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:02:44 -0700 Subject: [PATCH] fix(test): deterministic memory-durability Docker test (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tps init and its test (init.test.ts) each independently resolve the home directory via node:os homedir(), which falls back to the OS user database (getpwuid) rather than reading $HOME directly. The Docker test image runs as root with HOME=/home/tps injected only via ENV (no matching /etc/passwd entry for that path), and Bun's os.homedir() has been observed to cache its resolved value independent of later process.env.HOME mutations — mutations that ~40 sibling test files in this same bun test process perform in their own beforeEach/afterEach hooks for test isolation. That combination leaves the test's expected path (parent process) and the actual files written by the spawned CLI subprocess free to disagree, since agentYaml() also called homedir() a second, independent time inside the child. Fix: resolve home once via a single helper that prefers the live process.env.HOME (falling back to homedir() only if unset), thread that single value through runInit() and agentYaml() instead of each computing it separately, and mirror the same resolution in the test so both sides read the identical string. All three spawnSync calls in the test now explicitly pass this resolved HOME to the subprocess env (previously only the first did), removing the asymmetry. Docker isn't available on this host, so the exact container race couldn't be reproduced directly; validated by running the full local bun test suite (packages/cli) 10x for init.test.ts in isolation plus a full-suite run, both before and after — no new failures (same 5 pre-existing, unrelated environment-dependent failures on main). Co-Authored-By: Claude Fable 5 --- packages/cli/src/commands/init.ts | 25 +++++++++++++++++++++---- packages/cli/test/init.test.ts | 20 +++++++++++++++----- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index f7e7250..49b77a8 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -25,6 +25,24 @@ export interface InitArgs { const DEFAULT_MODEL = "anthropic/claude-sonnet-4-6"; const DEFAULT_FLAIR = "http://127.0.0.1:9926"; +/** + * Resolve the effective home directory. + * + * Prefers $HOME (or %USERPROFILE% on Windows) over node:os's homedir(), + * which resolves via the OS user database (getpwuid) as a fallback. + * In a container running as root with no matching /etc/passwd entry for + * an image-injected HOME override (e.g. our Docker test image sets + * HOME=/home/tps without creating a real "tps" user), homedir() and a + * live $HOME override can disagree — and Bun's os.homedir() has been + * observed to cache its result independent of later env mutations, + * so two independent callers (this process and any subprocess it spawns) + * are not guaranteed to agree unless they both read the same live env var. + * Reading process.env.HOME directly sidesteps that class of mismatch. + */ +function resolveHome(): string { + return process.env.HOME || process.env.USERPROFILE || homedir(); +} + function generateEd25519Key(keyPath: string, pubPath: string): void { const { privateKey, publicKey } = generateKeyPairSync("ed25519"); const privDer = privateKey.export({ type: "pkcs8", format: "der" }) as Buffer; @@ -34,8 +52,7 @@ function generateEd25519Key(keyPath: string, pubPath: string): void { writeFileSync(pubPath, pubDer.subarray(12).toString("hex") + "\n", { mode: 0o644 }); } -function agentYaml(agentId: string, model: string, flairUrl: string): string { - const home = homedir(); +function agentYaml(agentId: string, model: string, flairUrl: string, home: string): string { return `# TPS Agent config — generated by tps init agentId: ${agentId} name: ${agentId} @@ -85,7 +102,7 @@ async function registerWithFlair( } export async function runInit(args: InitArgs): Promise { - const home = homedir(); + const home = resolveHome(); const agentId = args.agentId ?? "my-agent"; const name = args.name ?? agentId; const flairUrl = args.flairUrl ?? process.env.FLAIR_URL ?? DEFAULT_FLAIR; @@ -124,7 +141,7 @@ export async function runInit(args: InitArgs): Promise { } // 3. agent.yaml - writeFileSync(configPath, agentYaml(agentId, model, flairUrl), { mode: 0o644 }); + writeFileSync(configPath, agentYaml(agentId, model, flairUrl, home), { mode: 0o644 }); console.log(`✓ Agent config written: ~/.tps/agents/${agentId}/agent.yaml`); // 4. Workspace README diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts index 97ad5e2..de5e70b 100644 --- a/packages/cli/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -5,7 +5,17 @@ import { join, resolve } from "node:path"; import { homedir } from "node:os"; const TPS_BIN = resolve(import.meta.dir, "../bin/tps.ts"); -const HOME = homedir(); +// Prefer the live $HOME over homedir(), which resolves via the OS user +// database as a fallback. In the Docker test image (root, no matching +// /etc/passwd entry for the injected HOME=/home/tps) that fallback can +// disagree with $HOME, and Bun's os.homedir() has been observed to cache +// its result independent of later env mutations performed by sibling test +// files in this same process — so relying on it here (and independently +// again inside the spawned subprocess) risks the test's expected path and +// the subprocess's actual write path silently diverging. Reading +// process.env.HOME directly, and threading that same string through to +// the subprocess's env below, keeps both sides in lockstep. +const HOME = process.env.HOME || homedir(); const TEST_ID = "tps-init-test-agent"; afterEach(() => { @@ -32,15 +42,15 @@ describe("tps init", () => { }); test("errors if agent already exists without --force", () => { - spawnSync("bun", [TPS_BIN, "init", "--id", TEST_ID], { encoding: "utf-8", env: process.env }); - const result = spawnSync("bun", [TPS_BIN, "init", "--id", TEST_ID], { encoding: "utf-8", env: process.env }); + spawnSync("bun", [TPS_BIN, "init", "--id", TEST_ID], { encoding: "utf-8", env: { ...process.env, HOME } }); + const result = spawnSync("bun", [TPS_BIN, "init", "--id", TEST_ID], { encoding: "utf-8", env: { ...process.env, HOME } }); expect(result.status).not.toBe(0); expect((result.stderr ?? "") + (result.stdout ?? "")).toContain("already exists"); }); test("--force overwrites existing", () => { - spawnSync("bun", [TPS_BIN, "init", "--id", TEST_ID], { encoding: "utf-8", env: process.env }); - const result = spawnSync("bun", [TPS_BIN, "init", "--id", TEST_ID, "--force"], { encoding: "utf-8", env: process.env }); + spawnSync("bun", [TPS_BIN, "init", "--id", TEST_ID], { encoding: "utf-8", env: { ...process.env, HOME } }); + const result = spawnSync("bun", [TPS_BIN, "init", "--id", TEST_ID, "--force"], { encoding: "utf-8", env: { ...process.env, HOME } }); expect(result.status).toBe(0); }); });