fix: log a managed policy source that did not load (#1614)

## Summary

Follow-up to #1612. Logs when a machine policy source may be there and
did not load, using `machinePolicy.didNotLoad` (alpha) that
`resolveSettings()` reports since SDK 0.3.323.

## Why

`resolveSettings()` shows a policy source that it could not read (a
macOS MDM profile, the Windows registry policy, the managed-settings
file) as absent. The adapter then applies nothing from it, and nothing
said so:

- at start-up, `applyManagedPolicyEnv` copies no env vars from it into
the process;
- in `SettingsManager`, the session's settings and model list (managed
`deniedModels`, `availableModels`, `modelOverrides`) do not reflect it.

Claude Code reads the policy itself, so its own read is not affected.
The log line only explains why the adapter's side looks as if no policy
were set.

## What changes

- `unloadedPolicySources` in `src/managed-policy.ts` names the sources
for a log line. It is undefined when every source loaded, and for an SDK
that does not report the member.
- `applyManagedPolicyEnv` writes one line to stderr and still applies
the env vars of the sources that did load.
- `SettingsManager` logs once per change, because it resolves the
settings again after every settings file edit.

Not in this PR: reading the policy again later. The env vars are applied
once at start-up, so a source that loads later is picked up for env vars
only by a restart, which the message says.

## Testing

- `npm run build`, `npm run check`, `npm run test:run` (4,126 tests
pass)
- The real SDK reports `machinePolicy: { didNotLoad: false }` on this
machine; the doubles use that shape.
This commit is contained in:
Ben Brandt
2026-11-13 14:16:33 +01:00
committed by GitHub
parent 9619805306
commit ca46b412f5
4 changed files with 107 additions and 1 deletions

View File

@@ -1,5 +1,28 @@
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
/** The machine policy sources that `machinePolicy.didNotLoad` names. */
const POLICY_SOURCES: Record<string, string> = {
profile: "the macOS MDM profile",
registry: "the Windows registry policy",
file: "the managed-settings file",
};
/**
* The machine policy sources that may be there and did not load
* (`machinePolicy.didNotLoad`, SDK 0.3.323), named for a log line: "the macOS
* MDM profile, the managed-settings file". The resolved settings show such a
* source as absent, so the adapter applies nothing from it, and Claude Code
* reads the policy itself. Undefined when every source loaded, and when the
* SDK does not report it.
*/
export function unloadedPolicySources(
resolved: Pick<Awaited<ReturnType<typeof resolveSettings>>, "machinePolicy">,
): string | undefined {
const unloaded = resolved.machinePolicy?.didNotLoad;
if (!unloaded || unloaded.length === 0) return undefined;
return unloaded.map((kind) => POLICY_SOURCES[kind] ?? kind).join(", ");
}
/**
* Applies the managed-policy tier's env vars to `process.env` before any SDK
* call, so the SDK subprocess inherits them. Going through `resolveSettings`
@@ -28,6 +51,13 @@ export async function applyManagedPolicyEnv(
console.error("Failed to resolve managed policy settings:", error);
return;
}
const unloaded = unloadedPolicySources(policy);
if (unloaded) {
console.error(
`Managed settings did not load from ${unloaded}: its env vars are not applied. ` +
"Restart the agent to read it again.",
);
}
for (const [key, value] of Object.entries(policy.effective.env ?? {})) {
env[key] = value;
}

View File

@@ -7,6 +7,7 @@ import {
resolveSettings,
type Settings,
} from "@anthropic-ai/claude-agent-sdk";
import { unloadedPolicySources } from "./managed-policy.js";
import { claudeConfigDir } from "./paths.js";
/**
@@ -62,6 +63,8 @@ export class SettingsManager {
private managedModelSettings: ModelSelectionSettings = {};
private managedApiKeyHelper?: string;
private managedCompactionSettings: CompactionSettings = {};
/** The policy sources last logged as not loaded (see {@link unloadedPolicySources}). */
private unloadedPolicy?: string;
private watchers: fs.FSWatcher[] = [];
private onChange?: () => void;
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
@@ -118,6 +121,17 @@ export class SettingsManager {
private async loadAllSettings(): Promise<void> {
try {
const resolved = await resolveSettings({ cwd: this.cwd });
// Once per change: a reload after each settings file edit would repeat it.
const unloaded = unloadedPolicySources(resolved);
if (unloaded !== this.unloadedPolicy) {
this.unloadedPolicy = unloaded;
if (unloaded) {
this.logger.error(
`Managed settings did not load from ${unloaded}: the session's settings ` +
"and model list do not reflect it. Claude Code reads the policy itself.",
);
}
}
this.effective = filterEscalatingDefaultMode(resolved);
// The CLI reads `deniedModels` from managed settings only.
this.managedDeniedModels = resolved.sources

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
import { applyManagedPolicyEnv } from "../managed-policy.js";
import { applyManagedPolicyEnv, unloadedPolicySources } from "../managed-policy.js";
type ResolveSettings = typeof resolveSettings;
@@ -57,6 +57,25 @@ describe("applyManagedPolicyEnv", () => {
);
});
it("reports a policy source that did not load on stderr, and still applies the rest", async () => {
const env: Record<string, string | undefined> = {};
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
await applyManagedPolicyEnv(
resolverReturning({
effective: { env: { FROM_FILE: "1" } },
machinePolicy: { didNotLoad: ["profile"] },
}),
env,
);
expect(env).toEqual({ FROM_FILE: "1" });
expect(stderr).toHaveBeenCalledWith(
"Managed settings did not load from the macOS MDM profile: its env vars are not applied. " +
"Restart the agent to read it again.",
);
});
it("asks the SDK for the managed tier only", async () => {
const seen: unknown[] = [];
const resolve = (async (options: unknown) => {
@@ -69,3 +88,18 @@ describe("applyManagedPolicyEnv", () => {
expect(seen).toEqual([{ settingSources: [] }]);
});
});
describe("unloadedPolicySources", () => {
it.each([
// What the SDK reports when every source loaded.
[{ machinePolicy: { didNotLoad: false as const } }, undefined],
// An SDK before 0.3.323 leaves the member out.
[{}, undefined],
[
{ machinePolicy: { didNotLoad: ["registry" as const, "file" as const] } },
"the Windows registry policy, the managed-settings file",
],
])("names the sources of %j", (resolved, expected) => {
expect(unloadedPolicySources(resolved)).toBe(expected);
});
});

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import * as os from "node:os";
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
import { SettingsManager } from "../settings.js";
@@ -90,3 +91,30 @@ describe("model selection settings policy", () => {
});
});
});
describe("a managed policy source that did not load", () => {
let manager: SettingsManager;
afterEach(() => manager?.dispose());
it("is logged once while the same source stays unloaded", async () => {
vi.mocked(resolveSettings).mockResolvedValue({
effective: {},
provenance: {},
sources: [],
machinePolicy: { didNotLoad: ["profile"] },
});
const error = vi.fn();
manager = new SettingsManager(process.cwd(), { logger: { log: () => {}, error } });
await manager.initialize();
// Another cwd resolves the settings again.
await manager.setCwd(os.tmpdir());
expect(resolveSettings).toHaveBeenCalledTimes(2);
expect(error.mock.calls).toEqual([
[
"Managed settings did not load from the macOS MDM profile: the session's settings " +
"and model list do not reflect it. Claude Code reads the policy itself.",
],
]);
});
});