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); }); });