diff --git a/apps/server/src/policy/ManagedPolicy.test.ts b/apps/server/src/policy/ManagedPolicy.test.ts index 26e74f05b0372..d6c7710b57ead 100644 --- a/apps/server/src/policy/ManagedPolicy.test.ts +++ b/apps/server/src/policy/ManagedPolicy.test.ts @@ -142,6 +142,11 @@ it.layer(NodeServices.layer)("ManagedPolicy.load", (it) => { }, }, }), + // Startup reads OTLP endpoints before the policy, and environment variables outrank them. + JSON.stringify({ + ...base, + settings: { "observability.otlpTracesUrl": { value: "", locked: true } }, + }), ], (contents) => loadFrom(contents).pipe( @@ -149,7 +154,7 @@ it.layer(NodeServices.layer)("ManagedPolicy.load", (it) => { Effect.map((error) => error.reason), ), ); - assert.lengthOf(reasons, 13); + assert.lengthOf(reasons, 14); assert.include(reasons[3], "storageCleanup.logsAfterDayz"); assert.include(reasons[6], "adds entries users could not remove"); assert.include(reasons[7], "adds entries users could not remove"); @@ -158,6 +163,7 @@ it.layer(NodeServices.layer)("ManagedPolicy.load", (it) => { assert.include(reasons[10], 'lock "worktreeCleanup" too'); assert.include(reasons[11], 'lock "providerInstances.codex" as a whole'); assert.include(reasons[12], "holds a credential"); + assert.include(reasons[13], "a policy cannot override"); }), ); diff --git a/apps/server/src/policy/managedSettings.ts b/apps/server/src/policy/managedSettings.ts index 7ae0cf6aa2b60..0d41d18ddce26 100644 --- a/apps/server/src/policy/managedSettings.ts +++ b/apps/server/src/policy/managedSettings.ts @@ -28,6 +28,13 @@ const UNMANAGEABLE_KEYS: ReadonlySet = new Set([ "projectScriptOverrides", ]); +/** + * Startup resolves OTLP endpoints from the settings file and desktop bootstrap input before the policy + * loads, and the user's OTLP environment variables outrank them, so a managed value would look + * enforced without being. + */ +const ENVIRONMENT_OVERRIDDEN_KEYS: ReadonlySet = new Set(["observability"]); + /** Legacy settings the server derives from another on every write; manage that one instead. */ const DERIVED_FROM: ReadonlyMap = new Map([ ["compactIdleThreads", "idleCompaction"], @@ -253,6 +260,12 @@ export function makeManagedSettings( if (!(head in ServerSettings.fields) || UNMANAGEABLE_KEYS.has(head)) { return { _tag: "Invalid", reason: `"${path}" is not a setting this version can manage` }; } + if (ENVIRONMENT_OVERRIDDEN_KEYS.has(head)) { + return { + _tag: "Invalid", + reason: `"${path}" is resolved at startup from each user's environment, settings file and desktop bootstrap input, which a policy cannot override`, + }; + } const source = DERIVED_FROM.get(head); if (source !== undefined) { return { diff --git a/docs/user/managed-policy.md b/docs/user/managed-policy.md index 6017444e52a9f..afd6186f5c076 100644 --- a/docs/user/managed-policy.md +++ b/docs/user/managed-policy.md @@ -146,6 +146,8 @@ A few settings need care: - `worktreeCleanup`, which projects can also set, overrides the `worktree` rules in `storageCleanup`. Lock it along with those rules; T3 Code refuses a policy that locks only the rules. +- `observability` cannot be managed. Its OTLP endpoints come from each user's environment + variables, settings file and desktop startup input, which a policy cannot override. - Manage `idleCompaction`, not the older `compactIdleThreads`. A Claude instance's own idle compaction choice still wins over `idleCompaction`. - A provider instance's fields override each other, so lock `providerInstances.` as a whole,