diff --git a/src/index.ts b/src/index.ts index bde9b680..530598b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { resolveSettings } from "@anthropic-ai/claude-agent-sdk"; import { claudeCliPath, runAcp } from "./acp-agent.js"; +import { applyManagedPolicyEnv } from "./managed-policy.js"; import packageJson from "../package.json" with { type: "json" }; import { appendFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; @@ -44,13 +44,10 @@ if (process.argv.includes("--cli")) { process.exit(0); } else { // Apply env vars from the managed-policy tier before any SDK call so the - // SDK subprocess inherits them. Going through resolveSettings (vs. a raw - // read of managed-settings.json) also picks up MDM sources on macOS and - // HKLM/HKCU on Windows. - const policy = await resolveSettings({ settingSources: [] }); - for (const [key, value] of Object.entries(policy.effective.env ?? {})) { - process.env[key] = value; - } + // SDK subprocess inherits them. Reading the tier is best-effort: a transient + // failure leaves the agent running with no policy env instead of aborting + // module evaluation here, before any ACP traffic. + await applyManagedPolicyEnv(); // stdout is used to send messages to the client // we redirect everything else to stderr to make sure it doesn't interfere with ACP diff --git a/src/managed-policy.ts b/src/managed-policy.ts new file mode 100644 index 00000000..50ffeca9 --- /dev/null +++ b/src/managed-policy.ts @@ -0,0 +1,34 @@ +import { resolveSettings } from "@anthropic-ai/claude-agent-sdk"; + +/** + * Applies the managed-policy tier's env vars to `process.env` before any SDK + * call, so the SDK subprocess inherits them. Going through `resolveSettings` + * rather than a raw read of `managed-settings.json` also picks up MDM sources on + * macOS and `HKLM`/`HKCU` on Windows. + * + * Reading the tier is best-effort by nature: on a host with no MDM and no + * managed policy the result is empty and the agent behaves identically. So a + * transient failure to read it — `EINTR`, `EMFILE`, `EAGAIN` — must leave the + * agent running with no policy env rather than stop it from starting. + * + * That guard matters because the caller awaits this at module scope in an ESM + * entry point, where a rejection aborts module evaluation and the process exits + * before serving any ACP traffic. `SettingsManager.loadAllSettings` wraps the + * same SDK call for the same reason. + */ +export async function applyManagedPolicyEnv( + resolve: typeof resolveSettings = resolveSettings, + env: Record = process.env, +): Promise { + let policy: Awaited>; + try { + policy = await resolve({ settingSources: [] }); + } catch (error) { + // stderr, so the ACP stream on stdout stays clean. + console.error("Failed to resolve managed policy settings:", error); + return; + } + for (const [key, value] of Object.entries(policy.effective.env ?? {})) { + env[key] = value; + } +} diff --git a/src/tests/managed-policy.test.ts b/src/tests/managed-policy.test.ts new file mode 100644 index 00000000..92979670 --- /dev/null +++ b/src/tests/managed-policy.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { resolveSettings } from "@anthropic-ai/claude-agent-sdk"; +import { applyManagedPolicyEnv } from "../managed-policy.js"; + +type ResolveSettings = typeof resolveSettings; + +/** The SDK's return type is large and @alpha; a test only needs `effective.env`. */ +function resolverReturning(policy: unknown): ResolveSettings { + return (async () => policy) as unknown as ResolveSettings; +} + +function resolverThrowing(error: unknown): ResolveSettings { + return (async () => { + throw error; + }) as unknown as ResolveSettings; +} + +describe("applyManagedPolicyEnv", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("copies the managed tier's env vars into the target env", async () => { + const env: Record = {}; + await applyManagedPolicyEnv( + resolverReturning({ + effective: { env: { ANTHROPIC_BASE_URL: "https://policy.test" } }, + }), + env, + ); + + expect(env).toEqual({ ANTHROPIC_BASE_URL: "https://policy.test" }); + }); + + it("resolves without applying anything when the tier has no env table", async () => { + const env: Record = { KEEP: "1" }; + await applyManagedPolicyEnv(resolverReturning({ effective: {} }), env); + + expect(env).toEqual({ KEEP: "1" }); + }); + + it("does not reject when the tier cannot be read, and reports it on stderr", async () => { + const env: Record = { KEEP: "1" }; + const stderr = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + applyManagedPolicyEnv(resolverThrowing(new Error("EINTR")), env), + ).resolves.toBeUndefined(); + + // The point of the guard: `src/index.ts` awaits this at module scope, so a + // rejection here would abort module evaluation and exit before any ACP + // traffic. Startup must survive a transient read failure. + expect(env).toEqual({ KEEP: "1" }); + expect(stderr).toHaveBeenCalledWith( + "Failed to resolve managed policy settings:", + expect.any(Error), + ); + }); + + it("asks the SDK for the managed tier only", async () => { + const seen: unknown[] = []; + const resolve = (async (options: unknown) => { + seen.push(options); + return { effective: {} }; + }) as unknown as ResolveSettings; + + await applyManagedPolicyEnv(resolve, {}); + + expect(seen).toEqual([{ settingSources: [] }]); + }); +});