diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml new file mode 100644 index 000000000..b8ce0879a --- /dev/null +++ b/.github/workflows/workflow-sanity.yml @@ -0,0 +1,37 @@ +name: Workflow Sanity + +on: + pull_request: + push: + +jobs: + no-tabs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fail on tabs in workflow files + run: | + python - <<'PY' + from __future__ import annotations + + import pathlib + import sys + + root = pathlib.Path(".github/workflows") + bad: list[str] = [] + for path in sorted(root.rglob("*.yml")): + if b"\t" in path.read_bytes(): + bad.append(str(path)) + + for path in sorted(root.rglob("*.yaml")): + if b"\t" in path.read_bytes(): + bad.append(str(path)) + + if bad: + print("Tabs found in workflow file(s):") + for path in bad: + print(f"- {path}") + sys.exit(1) + PY diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts index c2ba606a2..004fd5bc7 100644 --- a/src/agents/auth-profiles.test.ts +++ b/src/agents/auth-profiles.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; import { type AuthProfileStore, CLAUDE_CLI_PROFILE_ID, @@ -13,40 +14,6 @@ import { resolveAuthProfileOrder, } from "./auth-profiles.js"; -const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const; -type HomeEnvSnapshot = Record< - (typeof HOME_ENV_KEYS)[number], - string | undefined ->; - -const snapshotHomeEnv = (): HomeEnvSnapshot => ({ - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - HOMEDRIVE: process.env.HOMEDRIVE, - HOMEPATH: process.env.HOMEPATH, -}); - -const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => { - for (const key of HOME_ENV_KEYS) { - const value = snapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -}; - -const setTempHome = (tempHome: string) => { - process.env.HOME = tempHome; - if (process.platform === "win32") { - process.env.USERPROFILE = tempHome; - const root = path.parse(tempHome).root; - process.env.HOMEDRIVE = root.replace(/\\$/, ""); - process.env.HOMEPATH = tempHome.slice(root.length - 1); - } -}; - describe("resolveAuthProfileOrder", () => { const store: AuthProfileStore = { version: 1, @@ -431,259 +398,259 @@ describe("auth profile cooldowns", () => { }); describe("external CLI credential sync", () => { - it("syncs Claude CLI credentials into anthropic:claude-cli", () => { + it("syncs Claude CLI credentials into anthropic:claude-cli", async () => { const agentDir = fs.mkdtempSync( path.join(os.tmpdir(), "clawdbot-cli-sync-"), ); - const originalHome = snapshotHomeEnv(); - try { // Create a temp home with Claude CLI credentials - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-")); - setTempHome(tempHome); - - // Create Claude CLI credentials - const claudeDir = path.join(tempHome, ".claude"); - fs.mkdirSync(claudeDir, { recursive: true }); - const claudeCreds = { - claudeAiOauth: { - accessToken: "fresh-access-token", - refreshToken: "fresh-refresh-token", - expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now - }, - }; - fs.writeFileSync( - path.join(claudeDir, ".credentials.json"), - JSON.stringify(claudeCreds), - ); - - // Create empty auth-profiles.json - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", + await withTempHome( + async (tempHome) => { + // Create Claude CLI credentials + const claudeDir = path.join(tempHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + const claudeCreds = { + claudeAiOauth: { + accessToken: "fresh-access-token", + refreshToken: "fresh-refresh-token", + expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now }, - }, - }), - ); + }; + fs.writeFileSync( + path.join(claudeDir, ".credentials.json"), + JSON.stringify(claudeCreds), + ); - // Load the store - should sync from CLI - const store = ensureAuthProfileStore(agentDir); + // Create empty auth-profiles.json + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-default", + }, + }, + }), + ); - expect(store.profiles["anthropic:default"]).toBeDefined(); - expect((store.profiles["anthropic:default"] as { key: string }).key).toBe( - "sk-default", + // Load the store - should sync from CLI + const store = ensureAuthProfileStore(agentDir); + + expect(store.profiles["anthropic:default"]).toBeDefined(); + expect( + (store.profiles["anthropic:default"] as { key: string }).key, + ).toBe("sk-default"); + expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); + expect( + (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token, + ).toBe("fresh-access-token"); + expect( + (store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }) + .expires, + ).toBeGreaterThan(Date.now()); + }, + { prefix: "clawdbot-home-" }, ); - expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); - expect( - (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token, - ).toBe("fresh-access-token"); - expect( - (store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires, - ).toBeGreaterThan(Date.now()); } finally { - restoreHomeEnv(originalHome); fs.rmSync(agentDir, { recursive: true, force: true }); } }); - it("syncs Codex CLI credentials into openai-codex:codex-cli", () => { + it("syncs Codex CLI credentials into openai-codex:codex-cli", async () => { const agentDir = fs.mkdtempSync( path.join(os.tmpdir(), "clawdbot-codex-sync-"), ); - const originalHome = snapshotHomeEnv(); - try { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-")); - setTempHome(tempHome); + await withTempHome( + async (tempHome) => { + // Create Codex CLI credentials + const codexDir = path.join(tempHome, ".codex"); + fs.mkdirSync(codexDir, { recursive: true }); + const codexCreds = { + tokens: { + access_token: "codex-access-token", + refresh_token: "codex-refresh-token", + }, + }; + const codexAuthPath = path.join(codexDir, "auth.json"); + fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds)); - // Create Codex CLI credentials - const codexDir = path.join(tempHome, ".codex"); - fs.mkdirSync(codexDir, { recursive: true }); - const codexCreds = { - tokens: { - access_token: "codex-access-token", - refresh_token: "codex-refresh-token", + // Create empty auth-profiles.json + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: {}, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + + expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined(); + expect( + (store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access, + ).toBe("codex-access-token"); }, - }; - const codexAuthPath = path.join(codexDir, "auth.json"); - fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds)); - - // Create empty auth-profiles.json - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: {}, - }), + { prefix: "clawdbot-home-" }, ); - - const store = ensureAuthProfileStore(agentDir); - - expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined(); - expect( - (store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access, - ).toBe("codex-access-token"); } finally { - restoreHomeEnv(originalHome); fs.rmSync(agentDir, { recursive: true, force: true }); } }); - it("does not overwrite API keys when syncing external CLI creds", () => { + it("does not overwrite API keys when syncing external CLI creds", async () => { const agentDir = fs.mkdtempSync( path.join(os.tmpdir(), "clawdbot-no-overwrite-"), ); - const originalHome = snapshotHomeEnv(); - try { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-")); - setTempHome(tempHome); - - // Create Claude CLI credentials - const claudeDir = path.join(tempHome, ".claude"); - fs.mkdirSync(claudeDir, { recursive: true }); - const claudeCreds = { - claudeAiOauth: { - accessToken: "cli-access", - refreshToken: "cli-refresh", - expiresAt: Date.now() + 30 * 60 * 1000, - }, - }; - fs.writeFileSync( - path.join(claudeDir, ".credentials.json"), - JSON.stringify(claudeCreds), - ); - - // Create auth-profiles.json with an API key - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-store", + await withTempHome( + async (tempHome) => { + // Create Claude CLI credentials + const claudeDir = path.join(tempHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + const claudeCreds = { + claudeAiOauth: { + accessToken: "cli-access", + refreshToken: "cli-refresh", + expiresAt: Date.now() + 30 * 60 * 1000, }, - }, - }), - ); + }; + fs.writeFileSync( + path.join(claudeDir, ".credentials.json"), + JSON.stringify(claudeCreds), + ); - const store = ensureAuthProfileStore(agentDir); + // Create auth-profiles.json with an API key + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-store", + }, + }, + }), + ); - // Should keep the store's API key and still add the CLI profile. - expect((store.profiles["anthropic:default"] as { key: string }).key).toBe( - "sk-store", + const store = ensureAuthProfileStore(agentDir); + + // Should keep the store's API key and still add the CLI profile. + expect( + (store.profiles["anthropic:default"] as { key: string }).key, + ).toBe("sk-store"); + expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); + }, + { prefix: "clawdbot-home-" }, ); - expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); } finally { - restoreHomeEnv(originalHome); fs.rmSync(agentDir, { recursive: true, force: true }); } }); - it("does not overwrite fresher store token with older Claude CLI credentials", () => { + it("does not overwrite fresher store token with older Claude CLI credentials", async () => { const agentDir = fs.mkdtempSync( path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"), ); - const originalHome = snapshotHomeEnv(); - try { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-")); - setTempHome(tempHome); + await withTempHome( + async (tempHome) => { + const claudeDir = path.join(tempHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync( + path.join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "cli-access", + refreshToken: "cli-refresh", + expiresAt: Date.now() + 30 * 60 * 1000, + }, + }), + ); - const claudeDir = path.join(tempHome, ".claude"); - fs.mkdirSync(claudeDir, { recursive: true }); - fs.writeFileSync( - path.join(claudeDir, ".credentials.json"), - JSON.stringify({ - claudeAiOauth: { - accessToken: "cli-access", - refreshToken: "cli-refresh", - expiresAt: Date.now() + 30 * 60 * 1000, - }, - }), + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + [CLAUDE_CLI_PROFILE_ID]: { + type: "token", + provider: "anthropic", + token: "store-access", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + expect( + (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token, + ).toBe("store-access"); + }, + { prefix: "clawdbot-home-" }, ); - - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - [CLAUDE_CLI_PROFILE_ID]: { - type: "token", - provider: "anthropic", - token: "store-access", - expires: Date.now() + 60 * 60 * 1000, - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - expect( - (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token, - ).toBe("store-access"); } finally { - restoreHomeEnv(originalHome); fs.rmSync(agentDir, { recursive: true, force: true }); } }); - it("updates codex-cli profile when Codex CLI refresh token changes", () => { + it("updates codex-cli profile when Codex CLI refresh token changes", async () => { const agentDir = fs.mkdtempSync( path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"), ); - const originalHome = snapshotHomeEnv(); - try { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-")); - setTempHome(tempHome); + await withTempHome( + async (tempHome) => { + const codexDir = path.join(tempHome, ".codex"); + fs.mkdirSync(codexDir, { recursive: true }); + const codexAuthPath = path.join(codexDir, "auth.json"); + fs.writeFileSync( + codexAuthPath, + JSON.stringify({ + tokens: { + access_token: "same-access", + refresh_token: "new-refresh", + }, + }), + ); + fs.utimesSync(codexAuthPath, new Date(), new Date()); - const codexDir = path.join(tempHome, ".codex"); - fs.mkdirSync(codexDir, { recursive: true }); - const codexAuthPath = path.join(codexDir, "auth.json"); - fs.writeFileSync( - codexAuthPath, - JSON.stringify({ - tokens: { access_token: "same-access", refresh_token: "new-refresh" }, - }), + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + [CODEX_CLI_PROFILE_ID]: { + type: "oauth", + provider: "openai-codex", + access: "same-access", + refresh: "old-refresh", + expires: Date.now() - 1000, + }, + }, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + expect( + (store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }) + .refresh, + ).toBe("new-refresh"); + }, + { prefix: "clawdbot-home-" }, ); - fs.utimesSync(codexAuthPath, new Date(), new Date()); - - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - [CODEX_CLI_PROFILE_ID]: { - type: "oauth", - provider: "openai-codex", - access: "same-access", - refresh: "old-refresh", - expires: Date.now() - 1000, - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - expect( - (store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh, - ).toBe("new-refresh"); } finally { - restoreHomeEnv(originalHome); fs.rmSync(agentDir, { recursive: true, force: true }); } }); diff --git a/src/agents/models-config.test.ts b/src/agents/models-config.test.ts index 979a841be..364d8e066 100644 --- a/src/agents/models-config.test.ts +++ b/src/agents/models-config.test.ts @@ -1,20 +1,12 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import type { ClawdbotConfig } from "../config/config.js"; async function withTempHome(fn: (home: string) => Promise): Promise { - const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-models-")); - const previousHome = process.env.HOME; - process.env.HOME = base; - try { - return await fn(base); - } finally { - process.env.HOME = previousHome; - await fs.rm(base, { recursive: true, force: true }); - } + return withTempHomeBase(fn, { prefix: "clawdbot-models-" }); } const MODELS_CONFIG: ClawdbotConfig = { diff --git a/src/auto-reply/reply.block-streaming.test.ts b/src/auto-reply/reply.block-streaming.test.ts index 010c385d0..fd6aded66 100644 --- a/src/auto-reply/reply.block-streaming.test.ts +++ b/src/auto-reply/reply.block-streaming.test.ts @@ -1,9 +1,8 @@ -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { getReplyFromConfig } from "./reply.js"; @@ -22,15 +21,7 @@ vi.mock("../agents/model-catalog.js", () => ({ })); async function withTempHome(fn: (home: string) => Promise): Promise { - const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-stream-")); - const previousHome = process.env.HOME; - process.env.HOME = base; - try { - return await fn(base); - } finally { - process.env.HOME = previousHome; - await fs.rm(base, { recursive: true, force: true }); - } + return withTempHomeBase(fn, { prefix: "clawdbot-stream-" }); } describe("block streaming", () => { diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 650b2d586..409a7cad6 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { @@ -28,28 +28,30 @@ vi.mock("../agents/model-catalog.js", () => ({ })); async function withTempHome(fn: (home: string) => Promise): Promise { - const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reply-")); - const previousHome = process.env.HOME; - const previousStateDir = process.env.CLAWDBOT_STATE_DIR; - const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - process.env.HOME = base; - process.env.CLAWDBOT_STATE_DIR = path.join(base, ".clawdbot"); - process.env.CLAWDBOT_AGENT_DIR = path.join(base, ".clawdbot", "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; - try { - return await fn(base); - } finally { - process.env.HOME = previousHome; - if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR; - else process.env.CLAWDBOT_STATE_DIR = previousStateDir; - if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR; - else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; - if (previousPiAgentDir === undefined) - delete process.env.PI_CODING_AGENT_DIR; - else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - await fs.rm(base, { recursive: true, force: true }); - } + return withTempHomeBase( + async (home) => { + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot"); + process.env.CLAWDBOT_AGENT_DIR = path.join(home, ".clawdbot", "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + try { + return await fn(home); + } finally { + if (previousStateDir === undefined) + delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = previousStateDir; + if (previousAgentDir === undefined) + delete process.env.CLAWDBOT_AGENT_DIR; + else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; + if (previousPiAgentDir === undefined) + delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; + } + }, + { prefix: "clawdbot-reply-" }, + ); } describe("directive behavior", () => { diff --git a/src/auto-reply/reply.heartbeat-typing.test.ts b/src/auto-reply/reply.heartbeat-typing.test.ts index 57f1fdcdd..73cbe6825 100644 --- a/src/auto-reply/reply.heartbeat-typing.test.ts +++ b/src/auto-reply/reply.heartbeat-typing.test.ts @@ -1,9 +1,9 @@ -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; + const runEmbeddedPiAgentMock = vi.fn(); vi.mock("../agents/model-fallback.js", () => ({ @@ -43,16 +43,13 @@ vi.mock("../web/session.js", () => webMocks); import { getReplyFromConfig } from "./reply.js"; async function withTempHome(fn: (home: string) => Promise): Promise { - const base = await fs.mkdtemp(join(tmpdir(), "clawdbot-typing-")); - const previousHome = process.env.HOME; - process.env.HOME = base; - try { - runEmbeddedPiAgentMock.mockClear(); - return await fn(base); - } finally { - process.env.HOME = previousHome; - await fs.rm(base, { recursive: true, force: true }); - } + return withTempHomeBase( + async (home) => { + runEmbeddedPiAgentMock.mockClear(); + return await fn(home); + }, + { prefix: "clawdbot-typing-" }, + ); } function makeCfg(home: string) { diff --git a/src/auto-reply/reply.media-note.test.ts b/src/auto-reply/reply.media-note.test.ts index 56b6544dd..623e1ea0f 100644 --- a/src/auto-reply/reply.media-note.test.ts +++ b/src/auto-reply/reply.media-note.test.ts @@ -1,9 +1,8 @@ -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { getReplyFromConfig } from "./reply.js"; @@ -28,27 +27,26 @@ function makeResult(text: string) { } async function withTempHome(fn: (home: string) => Promise): Promise { - const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-media-note-")); - const previousHome = process.env.HOME; - const previousBundledSkills = process.env.CLAWDBOT_BUNDLED_SKILLS_DIR; - process.env.HOME = base; - process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = path.join(base, "bundled-skills"); - try { - vi.mocked(runEmbeddedPiAgent).mockReset(); - return await fn(base); - } finally { - process.env.HOME = previousHome; - if (previousBundledSkills === undefined) { - delete process.env.CLAWDBOT_BUNDLED_SKILLS_DIR; - } else { - process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = previousBundledSkills; - } - try { - await fs.rm(base, { recursive: true, force: true }); - } catch { - // ignore cleanup failures in tests - } - } + return withTempHomeBase( + async (home) => { + const previousBundledSkills = process.env.CLAWDBOT_BUNDLED_SKILLS_DIR; + process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = path.join( + home, + "bundled-skills", + ); + try { + vi.mocked(runEmbeddedPiAgent).mockReset(); + return await fn(home); + } finally { + if (previousBundledSkills === undefined) { + delete process.env.CLAWDBOT_BUNDLED_SKILLS_DIR; + } else { + process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = previousBundledSkills; + } + } + }, + { prefix: "clawdbot-media-note-" }, + ); } function makeCfg(home: string) { diff --git a/src/auto-reply/reply.queue.test.ts b/src/auto-reply/reply.queue.test.ts index 7572c6d80..5510b12e1 100644 --- a/src/auto-reply/reply.queue.test.ts +++ b/src/auto-reply/reply.queue.test.ts @@ -1,9 +1,8 @@ -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import { isEmbeddedPiRunActive, isEmbeddedPiRunStreaming, @@ -32,20 +31,13 @@ function makeResult(text: string) { } async function withTempHome(fn: (home: string) => Promise): Promise { - const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-queue-")); - const previousHome = process.env.HOME; - process.env.HOME = base; - try { - vi.mocked(runEmbeddedPiAgent).mockReset(); - return await fn(base); - } finally { - process.env.HOME = previousHome; - try { - await fs.rm(base, { recursive: true, force: true }); - } catch { - // ignore cleanup failures in tests - } - } + return withTempHomeBase( + async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + return await fn(home); + }, + { prefix: "clawdbot-queue-" }, + ); } function makeCfg(home: string, queue?: Record) { diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index c12b1e543..5dabd7ec6 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; + vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), compactEmbeddedPiSession: vi.fn(), @@ -51,37 +53,26 @@ const webMocks = vi.hoisted(() => ({ vi.mock("../web/session.js", () => webMocks); async function withTempHome(fn: (home: string) => Promise): Promise { - const base = await fs.mkdtemp(join(tmpdir(), "clawdbot-triggers-")); - const previousHome = process.env.HOME; - const previousUserProfile = process.env.USERPROFILE; - const previousHomeDrive = process.env.HOMEDRIVE; - const previousHomePath = process.env.HOMEPATH; - const previousStateDir = process.env.CLAWDBOT_STATE_DIR; - const previousClawdisStateDir = process.env.CLAWDIS_STATE_DIR; - process.env.HOME = base; - process.env.CLAWDBOT_STATE_DIR = join(base, ".clawdbot"); - process.env.CLAWDIS_STATE_DIR = join(base, ".clawdbot"); - if (process.platform === "win32") { - process.env.USERPROFILE = base; - const driveMatch = base.match(/^([A-Za-z]:)(.*)$/); - if (driveMatch) { - process.env.HOMEDRIVE = driveMatch[1]; - process.env.HOMEPATH = driveMatch[2] || "\\"; - } - } - try { - vi.mocked(runEmbeddedPiAgent).mockClear(); - vi.mocked(abortEmbeddedPiRun).mockClear(); - return await fn(base); - } finally { - process.env.HOME = previousHome; - process.env.USERPROFILE = previousUserProfile; - process.env.HOMEDRIVE = previousHomeDrive; - process.env.HOMEPATH = previousHomePath; - process.env.CLAWDBOT_STATE_DIR = previousStateDir; - process.env.CLAWDIS_STATE_DIR = previousClawdisStateDir; - await fs.rm(base, { recursive: true, force: true }); - } + return withTempHomeBase( + async (home) => { + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousClawdisStateDir = process.env.CLAWDIS_STATE_DIR; + process.env.CLAWDBOT_STATE_DIR = join(home, ".clawdbot"); + process.env.CLAWDIS_STATE_DIR = join(home, ".clawdbot"); + try { + vi.mocked(runEmbeddedPiAgent).mockClear(); + vi.mocked(abortEmbeddedPiRun).mockClear(); + return await fn(home); + } finally { + if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = previousStateDir; + if (previousClawdisStateDir === undefined) + delete process.env.CLAWDIS_STATE_DIR; + else process.env.CLAWDIS_STATE_DIR = previousClawdisStateDir; + } + }, + { prefix: "clawdbot-triggers-" }, + ); } function makeCfg(home: string) { @@ -320,7 +311,7 @@ describe("trigger handling", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("api-key"); - expect(text).toContain("…"); + expect(text).toMatch(/…|\.{3}/); expect(text).toContain("(anthropic:work)"); expect(text).not.toContain("mixed"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index ef0ac5bc1..06b0c6959 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -1,44 +1,10 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; import type { ClawdbotConfig } from "../config/config.js"; import { buildStatusMessage } from "./status.js"; -const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const; -type HomeEnvSnapshot = Record< - (typeof HOME_ENV_KEYS)[number], - string | undefined ->; - -const snapshotHomeEnv = (): HomeEnvSnapshot => ({ - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - HOMEDRIVE: process.env.HOMEDRIVE, - HOMEPATH: process.env.HOMEPATH, -}); - -const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => { - for (const key of HOME_ENV_KEYS) { - const value = snapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -}; - -const setTempHome = (tempHome: string) => { - process.env.HOME = tempHome; - if (process.platform === "win32") { - process.env.USERPROFILE = tempHome; - const root = path.parse(tempHome).root; - process.env.HOMEDRIVE = root.replace(/\\$/, ""); - process.env.HOMEPATH = tempHome.slice(root.length - 1); - } -}; - afterEach(() => { vi.restoreAllMocks(); }); @@ -260,69 +226,66 @@ describe("buildStatusMessage", () => { }); it("prefers cached prompt tokens from the session log", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-status-")); - const previousHome = snapshotHomeEnv(); - setTempHome(dir); - try { - vi.resetModules(); - const { buildStatusMessage: buildStatusMessageDynamic } = await import( - "./status.js" - ); + await withTempHome( + async (dir) => { + vi.resetModules(); + const { buildStatusMessage: buildStatusMessageDynamic } = await import( + "./status.js" + ); - const sessionId = "sess-1"; - const logPath = path.join( - dir, - ".clawdbot", - "agents", - "main", - "sessions", - `${sessionId}.jsonl`, - ); - fs.mkdirSync(path.dirname(logPath), { recursive: true }); + const sessionId = "sess-1"; + const logPath = path.join( + dir, + ".clawdbot", + "agents", + "main", + "sessions", + `${sessionId}.jsonl`, + ); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.writeFileSync( - logPath, - [ - JSON.stringify({ - type: "message", - message: { - role: "assistant", - model: "claude-opus-4-5", - usage: { - input: 1, - output: 2, - cacheRead: 1000, - cacheWrite: 0, - totalTokens: 1003, + fs.writeFileSync( + logPath, + [ + JSON.stringify({ + type: "message", + message: { + role: "assistant", + model: "claude-opus-4-5", + usage: { + input: 1, + output: 2, + cacheRead: 1000, + cacheWrite: 0, + totalTokens: 1003, + }, }, - }, - }), - ].join("\n"), - "utf-8", - ); + }), + ].join("\n"), + "utf-8", + ); - const text = buildStatusMessageDynamic({ - agent: { - model: "anthropic/claude-opus-4-5", - contextTokens: 32_000, - }, - sessionEntry: { - sessionId, - updatedAt: 0, - totalTokens: 3, // would be wrong if cached prompt tokens exist - contextTokens: 32_000, - }, - sessionKey: "agent:main:main", - sessionScope: "per-sender", - queue: { mode: "collect", depth: 0 }, - includeTranscriptUsage: true, - modelAuth: "api-key", - }); + const text = buildStatusMessageDynamic({ + agent: { + model: "anthropic/claude-opus-4-5", + contextTokens: 32_000, + }, + sessionEntry: { + sessionId, + updatedAt: 0, + totalTokens: 3, // would be wrong if cached prompt tokens exist + contextTokens: 32_000, + }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + includeTranscriptUsage: true, + modelAuth: "api-key", + }); - expect(text).toContain("Context: 1.0k/32k"); - } finally { - restoreHomeEnv(previousHome); - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(text).toContain("Context: 1.0k/32k"); + }, + { prefix: "clawdbot-status-" }, + ); }); }); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 8ff14c8de..3f66c78a7 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { @@ -11,6 +10,8 @@ import { vi, } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; + vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), runEmbeddedPiAgent: vi.fn(), @@ -39,15 +40,7 @@ const runtime: RuntimeEnv = { const configSpy = vi.spyOn(configModule, "loadConfig"); async function withTempHome(fn: (home: string) => Promise): Promise { - const base = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-")); - const previousHome = process.env.HOME; - process.env.HOME = base; - try { - return await fn(base); - } finally { - process.env.HOME = previousHome; - fs.rmSync(base, { recursive: true, force: true }); - } + return withTempHomeBase(fn, { prefix: "clawdbot-agent-" }); } function mockConfig( diff --git a/src/config/config.test.ts b/src/config/config.test.ts index cf0024483..4d9da2597 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1,41 +1,13 @@ import fs from "node:fs/promises"; -import os from "node:os"; +import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; + async function withTempHome(fn: (home: string) => Promise): Promise { - const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-config-")); - const previousHome = process.env.HOME; - const previousUserProfile = process.env.USERPROFILE; - const previousHomeDrive = process.env.HOMEDRIVE; - const previousHomePath = process.env.HOMEPATH; - process.env.HOME = base; - process.env.USERPROFILE = base; - if (process.platform === "win32") { - const parsed = path.parse(base); - process.env.HOMEDRIVE = parsed.root.replace(/\\$/, ""); - process.env.HOMEPATH = base.slice(Math.max(parsed.root.length - 1, 0)); - } - try { - return await fn(base); - } finally { - process.env.HOME = previousHome; - process.env.USERPROFILE = previousUserProfile; - if (process.platform === "win32") { - if (previousHomeDrive === undefined) { - delete process.env.HOMEDRIVE; - } else { - process.env.HOMEDRIVE = previousHomeDrive; - } - if (previousHomePath === undefined) { - delete process.env.HOMEPATH; - } else { - process.env.HOMEPATH = previousHomePath; - } - } - await fs.rm(base, { recursive: true, force: true }); - } + return withTempHomeBase(fn, { prefix: "clawdbot-config-" }); } /** @@ -1277,7 +1249,7 @@ describe("multi-agent agentDir validation", () => { it("rejects shared agents.list agentDir", async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); - const shared = path.join(os.tmpdir(), "clawdbot-shared-agentdir"); + const shared = path.join(tmpdir(), "clawdbot-shared-agentdir"); const res = validateConfigObject({ agents: { list: [ diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 2dc60afd0..300c0eda7 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import type { CliDeps } from "../cli/deps.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { CronJob } from "./types.js"; @@ -23,15 +23,7 @@ import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; async function withTempHome(fn: (home: string) => Promise): Promise { - const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-")); - const previousHome = process.env.HOME; - process.env.HOME = base; - try { - return await fn(base); - } finally { - process.env.HOME = previousHome; - await fs.rm(base, { recursive: true, force: true }); - } + return withTempHomeBase(fn, { prefix: "clawdbot-cron-" }); } async function writeSessionStore(home: string) { diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index a1c01ac88..aa125d6ba 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; import { ensureAuthProfileStore, listProfilesForProvider, @@ -73,45 +73,6 @@ describe("provider usage formatting", () => { }); describe("provider usage loading", () => { - const HOME_ENV_KEYS = [ - "HOME", - "USERPROFILE", - "HOMEDRIVE", - "HOMEPATH", - ] as const; - type HomeEnvSnapshot = Record< - (typeof HOME_ENV_KEYS)[number], - string | undefined - >; - - const snapshotHomeEnv = (): HomeEnvSnapshot => ({ - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - HOMEDRIVE: process.env.HOMEDRIVE, - HOMEPATH: process.env.HOMEPATH, - }); - - const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => { - for (const key of HOME_ENV_KEYS) { - const value = snapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }; - - const setTempHome = (tempHome: string) => { - process.env.HOME = tempHome; - if (process.platform === "win32") { - process.env.USERPROFILE = tempHome; - const root = path.parse(tempHome).root; - process.env.HOMEDRIVE = root.replace(/\\$/, ""); - process.env.HOMEPATH = tempHome.slice(root.length - 1); - } - }; - it("loads usage snapshots with injected auth", async () => { const makeResponse = (status: number, body: unknown): Response => { const payload = typeof body === "string" ? body : JSON.stringify(body); @@ -175,94 +136,98 @@ describe("provider usage loading", () => { }); it("discovers Claude usage from token auth profiles", async () => { - const homeSnapshot = snapshotHomeEnv(); - const stateSnapshot = process.env.CLAWDBOT_STATE_DIR; - const tempHome = fs.mkdtempSync( - path.join(os.tmpdir(), "clawdbot-provider-usage-"), - ); - try { - setTempHome(tempHome); - process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot"); - const agentDir = path.join( - process.env.CLAWDBOT_STATE_DIR, - "agents", - "main", - "agent", - ); - fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync( - path.join(agentDir, "auth-profiles.json"), - `${JSON.stringify( - { - version: 1, - order: { anthropic: ["anthropic:default"] }, - profiles: { - "anthropic:default": { - type: "token", - provider: "anthropic", - token: "token-1", - expires: Date.UTC(2100, 0, 1, 0, 0, 0), + await withTempHome( + async (tempHome) => { + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot"); + const agentDir = path.join( + process.env.CLAWDBOT_STATE_DIR, + "agents", + "main", + "agent", + ); + try { + fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + order: { anthropic: ["anthropic:default"] }, + profiles: { + "anthropic:default": { + type: "token", + provider: "anthropic", + token: "token-1", + expires: Date.UTC(2100, 0, 1, 0, 0, 0), + }, + }, }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, - }); - expect(listProfilesForProvider(store, "anthropic")).toContain( - "anthropic:default", - ); - - const makeResponse = (status: number, body: unknown): Response => { - const payload = typeof body === "string" ? body : JSON.stringify(body); - const headers = - typeof body === "string" - ? undefined - : { "Content-Type": "application/json" }; - return new Response(payload, { status, headers }); - }; - - const mockFetch = vi.fn< - Parameters, - ReturnType - >(async (input, init) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - if (url.includes("api.anthropic.com/api/oauth/usage")) { - const headers = (init?.headers ?? {}) as Record; - expect(headers.Authorization).toBe("Bearer token-1"); - return makeResponse(200, { - five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, + null, + 2, + )}\n`, + "utf8", + ); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, }); + expect(listProfilesForProvider(store, "anthropic")).toContain( + "anthropic:default", + ); + + const makeResponse = (status: number, body: unknown): Response => { + const payload = + typeof body === "string" ? body : JSON.stringify(body); + const headers = + typeof body === "string" + ? undefined + : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); + }; + + const mockFetch = vi.fn< + Parameters, + ReturnType + >(async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url.includes("api.anthropic.com/api/oauth/usage")) { + const headers = (init?.headers ?? {}) as Record; + expect(headers.Authorization).toBe("Bearer token-1"); + return makeResponse(200, { + five_hour: { + utilization: 20, + resets_at: "2026-01-07T01:00:00Z", + }, + }); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + providers: ["anthropic"], + agentDir, + fetch: mockFetch, + }); + + expect(summary.providers).toHaveLength(1); + const claude = summary.providers[0]; + expect(claude?.provider).toBe("anthropic"); + expect(claude?.windows[0]?.label).toBe("5h"); + expect(mockFetch).toHaveBeenCalled(); + } finally { + if (previousStateDir === undefined) + delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = previousStateDir; } - return makeResponse(404, "not found"); - }); - - const summary = await loadProviderUsageSummary({ - now: Date.UTC(2026, 0, 7, 0, 0, 0), - providers: ["anthropic"], - agentDir, - fetch: mockFetch, - }); - - expect(summary.providers).toHaveLength(1); - const claude = summary.providers[0]; - expect(claude?.provider).toBe("anthropic"); - expect(claude?.windows[0]?.label).toBe("5h"); - expect(mockFetch).toHaveBeenCalled(); - } finally { - restoreHomeEnv(homeSnapshot); - if (stateSnapshot === undefined) delete process.env.CLAWDBOT_STATE_DIR; - else process.env.CLAWDBOT_STATE_DIR = stateSnapshot; - } + }, + { prefix: "clawdbot-provider-usage-" }, + ); }); it("falls back to claude.ai web usage when OAuth scope is missing", async () => { diff --git a/test/helpers/temp-home.ts b/test/helpers/temp-home.ts new file mode 100644 index 000000000..2a07512eb --- /dev/null +++ b/test/helpers/temp-home.ts @@ -0,0 +1,68 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +type EnvSnapshot = { + home: string | undefined; + userProfile: string | undefined; + homeDrive: string | undefined; + homePath: string | undefined; +}; + +function snapshotEnv(): EnvSnapshot { + return { + home: process.env.HOME, + userProfile: process.env.USERPROFILE, + homeDrive: process.env.HOMEDRIVE, + homePath: process.env.HOMEPATH, + }; +} + +function restoreEnv(snapshot: EnvSnapshot) { + const restoreKey = (key: string, value: string | undefined) => { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }; + restoreKey("HOME", snapshot.home); + restoreKey("USERPROFILE", snapshot.userProfile); + restoreKey("HOMEDRIVE", snapshot.homeDrive); + restoreKey("HOMEPATH", snapshot.homePath); +} + +function setTempHome(base: string) { + process.env.HOME = base; + process.env.USERPROFILE = base; + + if (process.platform !== "win32") return; + const match = base.match(/^([A-Za-z]:)(.*)$/); + if (!match) return; + process.env.HOMEDRIVE = match[1]; + process.env.HOMEPATH = match[2] || "\\"; +} + +export async function withTempHome( + fn: (home: string) => Promise, + opts: { prefix?: string } = {}, +): Promise { + const base = await fs.mkdtemp( + path.join(os.tmpdir(), opts.prefix ?? "clawdbot-test-home-"), + ); + const snapshot = snapshotEnv(); + setTempHome(base); + + try { + return await fn(base); + } finally { + restoreEnv(snapshot); + try { + await fs.rm(base, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 50, + }); + } catch { + // ignore cleanup failures in tests + } + } +}