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:
PerryLink
2026-09-23 16:47:45 +08:00
committed by GitHub
parent a2f4239543
commit 58c5db1fdc
3 changed files with 110 additions and 8 deletions

View File

@@ -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
View 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;
}
}

View 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: [] }]);
});
});