mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
fix: keep the agent starting when the managed-policy tier cannot be read (#1146)
Fixes #1124. ## The defect `src/index.ts` awaited `resolveSettings` at module scope with nothing around it: ```ts const policy = await resolveSettings({ settingSources: [] }); for (const [key, value] of Object.entries(policy.effective.env ?? {})) { process.env[key] = value; } ``` That is a top-level `await` in an ESM entry point, so a rejection aborts module evaluation and the process exits before serving any ACP traffic. The client only sees a process that exited 1 with no protocol messages. Two neighbours make this the only call that can do it, and both are still true on \`main\`: - `SettingsManager.loadAllSettings` (`src/settings.ts:105-113`) wraps the same SDK call in `try`/`catch` and falls back to `{}`. - The `process.on("unhandledRejection", ...)` handler is registered at \`src/index.ts:62\`, after this await, so it does not cover it. Reading the managed 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 `EINTR`/`EMFILE`/`EAGAIN` while reading it should leave the agent running with no policy env rather than stop it from starting. ## The change The guard lives in a new `src/managed-policy.ts` rather than inline, for one reason: `src/index.ts` is an ESM entry point with top-level side effects, so nothing in it can be imported by a test. Extracting the tier read makes the failure path testable, which is what the regression test below exercises. `applyManagedPolicyEnv` mirrors `loadAllSettings`: it takes the SDK call through a parameter defaulting to `resolveSettings` and a target env defaulting to `process.env`, reports the failure on stderr, and returns without applying anything. `src/index.ts` becomes: ```ts await applyManagedPolicyEnv(); ``` `console.error` is correct here: this runs before stdout is redirected, so the ACP stream stays clean. ## Verification Four tests in `src/tests/managed-policy.test.ts` cover the applied case, an empty tier, a tier with no `env` table, and the failure path — the last one asserts the promise **resolves** rather than rejects, with the target env untouched, which is the property the entry point depends on. ``` before after tests 17 failed | 1366 passed 17 failed | 1370 passed ``` The 17 failures are pre-existing in this checkout and untouched by this change; the four added tests account for the difference. `eslint src --ext .ts`, `tsc`, and `prettier --check` (with the repository's `.prettierrc.json`) are clean on the three files. ## Note I have not established whether those 17 pre-existing failures are host-specific or real, so I am not claiming anything about them or changing anything for them.
This commit is contained in:
13
src/index.ts
13
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
|
||||
|
||||
34
src/managed-policy.ts
Normal file
34
src/managed-policy.ts
Normal file
@@ -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<string, string | undefined> = process.env,
|
||||
): Promise<void> {
|
||||
let policy: Awaited<ReturnType<typeof resolveSettings>>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
71
src/tests/managed-policy.test.ts
Normal file
71
src/tests/managed-policy.test.ts
Normal file
@@ -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<string, string | undefined> = {};
|
||||
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<string, string | undefined> = { 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<string, string | undefined> = { 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: [] }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user