diff --git a/src/managed-policy.ts b/src/managed-policy.ts index 50ffeca9..f981d58e 100644 --- a/src/managed-policy.ts +++ b/src/managed-policy.ts @@ -1,5 +1,28 @@ import { resolveSettings } from "@anthropic-ai/claude-agent-sdk"; +/** The machine policy sources that `machinePolicy.didNotLoad` names. */ +const POLICY_SOURCES: Record = { + 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>, "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; } diff --git a/src/settings.ts b/src/settings.ts index e3f47a4f..6283c66e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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 { 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 diff --git a/src/tests/managed-policy.test.ts b/src/tests/managed-policy.test.ts index 92979670..1750154a 100644 --- a/src/tests/managed-policy.test.ts +++ b/src/tests/managed-policy.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/src/tests/model-selection-settings.test.ts b/src/tests/model-selection-settings.test.ts index 04fb61b0..65e4f4af 100644 --- a/src/tests/model-selection-settings.test.ts +++ b/src/tests/model-selection-settings.test.ts @@ -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.", + ], + ]); + }); +});