Skip to content

Commit 4a89c60

Browse files
fix(core): load managed config directory and macOS managed preferences
V2 dropped the admin-managed configuration channel V1 supported, so MDM-deployed profiles and system-managed opencode.json files stopped being enforced. Load opencode.json(c) from the system-managed directory and, on macOS, the ai.opencode.managed plist (MDM metadata keys stripped). Managed documents rank above every other source, and their policy statements follow authored ones and precede Console organization statements. Managed paths come from Config.Options rather than environment variables, so users cannot redirect them; tests pass explicit paths and the core test preload keeps host profiles out of other suites. Fixes anomalyco#51107 Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
1 parent ffa4c4c commit 4a89c60

9 files changed

Lines changed: 489 additions & 10 deletions

File tree

‎packages/core/src/config.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { AbsolutePath } from "./schema.js"
1616
import { ConfigVariable } from "./config/variable.js"
1717
import { ConfigNormalize } from "./config/normalize.js"
1818
import { ConfigDiscovery } from "./config/discovery.js"
19+
import { ConfigManaged } from "./config/managed.js"
1920
import { ConfigWatch } from "./config/watch.js"
2021
import { WellKnown } from "./wellknown.js"
2122

@@ -40,6 +41,8 @@ export interface Interface {
4041
readonly changes: () => Stream.Stream<Watcher.Update>
4142
/** Updates supported global config fields while preserving unrelated JSONC content. */
4243
readonly update?: (patch: Patch) => Effect.Effect<void, FSUtil.Error>
44+
/** Paths admin-managed documents load from; the policy plugin ranks their statements above authored ones. */
45+
readonly managed?: readonly AbsolutePath[]
4346
}
4447

4548
export const Options = Schema.Struct({
@@ -49,6 +52,14 @@ export const Options = Schema.Struct({
4952
global: Schema.optional(Schema.Boolean),
5053
file: Schema.optional(Schema.String),
5154
content: Schema.optional(Schema.String),
55+
// Defaults to the platform's system-managed locations. Hosts and tests set
56+
// this explicitly; `{}` disables managed configuration.
57+
managed: Schema.optional(
58+
Schema.Struct({
59+
directory: Schema.optional(Schema.String),
60+
preferences: Schema.optional(Schema.Array(Schema.String)),
61+
}),
62+
),
5263
})
5364
export type Options = typeof Options.Type
5465

@@ -70,6 +81,7 @@ export const testLayer = (
7081
claude: [],
7182
agents: [],
7283
},
84+
managed: readonly AbsolutePath[] = [],
7385
) =>
7486
Layer.effectContext(
7587
Effect.gen(function* () {
@@ -79,6 +91,7 @@ export const testLayer = (
7991
entries: () => Ref.get(entries),
8092
compatibility: () => Effect.succeed(compatibility),
8193
changes: () => Stream.fromPubSub(updates),
94+
managed,
8295
setEntries: (next) => Ref.set(entries, next),
8396
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
8497
})
@@ -191,6 +204,21 @@ export const layer = (options?: Options) =>
191204
]
192205
})
193206

207+
const managedPaths = options?.managed ?? ConfigManaged.systemPaths()
208+
// Managed documents rank above every other source so users and
209+
// repositories cannot override administrator-enforced settings.
210+
const loadManaged = Effect.fn("Config.loadManaged")(function* () {
211+
const managed = yield* ConfigManaged.sources(managedPaths)
212+
return yield* Effect.forEach(managed, (source) =>
213+
ConfigVariable.substitute({ type: "path", path: source.path, text: source.text }).pipe(
214+
Effect.flatMap((text) => parseInfo(text, source.path)),
215+
Effect.map((info) =>
216+
info ? new Document({ type: "document", path: AbsolutePath.make(source.path), info }) : undefined,
217+
),
218+
),
219+
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
220+
})
221+
194222
const load = Effect.fn("Config.load")(function* (sources: ConfigDiscovery.Sources) {
195223
const direct = yield* Effect.forEach(sources.direct, (filepath) => loadFile(filepath)).pipe(
196224
Effect.orDie,
@@ -234,6 +262,7 @@ export const layer = (options?: Options) =>
234262
...direct,
235263
...projectSupplementary,
236264
...content,
265+
...(yield* loadManaged().pipe(Effect.orDie)),
237266
]
238267
})
239268

@@ -359,6 +388,7 @@ export const layer = (options?: Options) =>
359388
}),
360389
changes: () => Stream.fromPubSub(updates),
361390
update,
391+
managed: ConfigManaged.candidates(managedPaths).map((file) => AbsolutePath.make(file)),
362392
})
363393
}),
364394
)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
export * as ConfigManaged from "./managed.js"
2+
3+
import os from "os"
4+
import path from "path"
5+
import { Effect } from "effect"
6+
import { FSUtil } from "@opencode/util/fs-util"
7+
import { names } from "./discovery.js"
8+
9+
const DOMAIN = "ai.opencode.managed"
10+
11+
// Keys macOS/MDM injects into the managed payload that are not OpenCode config.
12+
const PLIST_META = new Set([
13+
"PayloadDisplayName",
14+
"PayloadIdentifier",
15+
"PayloadType",
16+
"PayloadUUID",
17+
"PayloadVersion",
18+
"_manualProfile",
19+
])
20+
21+
export interface Paths {
22+
/** Admin-owned directory holding `opencode.json(c)`; omitted disables it. */
23+
readonly directory?: string
24+
/** macOS managed-preference plists in lookup order; the first readable one wins. */
25+
readonly preferences?: readonly string[]
26+
}
27+
28+
export interface Source {
29+
readonly path: string
30+
readonly text: string
31+
}
32+
33+
/** Where administrators deploy managed configuration on the current platform. */
34+
export function systemPaths(platform: NodeJS.Platform = process.platform, username = currentUser()): Paths {
35+
if (platform === "darwin")
36+
return {
37+
directory: "/Library/Application Support/opencode",
38+
preferences: [
39+
path.join("/Library/Managed Preferences", username, `${DOMAIN}.plist`),
40+
path.join("/Library/Managed Preferences", `${DOMAIN}.plist`),
41+
],
42+
}
43+
if (platform === "win32") return { directory: path.join(process.env.ProgramData || "C:\\ProgramData", "opencode") }
44+
return { directory: "/etc/opencode" }
45+
}
46+
47+
/** Every path a managed source can be read from. */
48+
export function candidates(paths: Paths): string[] {
49+
return [...directoryFiles(paths), ...(paths.preferences ?? [])]
50+
}
51+
52+
function directoryFiles(paths: Paths) {
53+
const directory = paths.directory
54+
return directory ? names.map((name) => path.join(directory, name)) : []
55+
}
56+
57+
/** Converts `plutil -convert json` output into config JSON by dropping MDM metadata keys. */
58+
export function parsePlist(json: string): Record<string, unknown> {
59+
const raw: unknown = JSON.parse(json)
60+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
61+
throw new Error("managed plist root is not a dictionary")
62+
return Object.fromEntries(Object.entries(raw).filter(([key]) => !PLIST_META.has(key)))
63+
}
64+
65+
/** Reads managed sources from lowest to highest priority: directory files, then the first readable plist. */
66+
export const sources = Effect.fn("ConfigManaged.sources")(function* (paths: Paths) {
67+
const fs = yield* FSUtil.Service
68+
const result: Source[] = []
69+
for (const file of directoryFiles(paths)) {
70+
const text = yield* fs
71+
.readFileStringSafe(file)
72+
.pipe(
73+
Effect.catch((error) =>
74+
Effect.logWarning("failed to read managed config", { source: file, error }).pipe(Effect.as(undefined)),
75+
),
76+
)
77+
if (text !== undefined) result.push({ path: file, text })
78+
}
79+
for (const plist of paths.preferences ?? []) {
80+
if (!(yield* fs.isFile(plist))) continue
81+
const text = yield* Effect.tryPromise(() => convertPlist(plist)).pipe(
82+
Effect.catch((error) =>
83+
Effect.logWarning("failed to read managed preferences", { source: plist, error }).pipe(Effect.as(undefined)),
84+
),
85+
)
86+
if (text === undefined) continue
87+
result.push({ path: plist, text })
88+
break
89+
}
90+
return result
91+
})
92+
93+
async function convertPlist(plist: string) {
94+
// Imported lazily: only macOS hosts with a deployed profile ever spawn plutil.
95+
const { execFile } = await import("node:child_process")
96+
const { promisify } = await import("node:util")
97+
const { stdout } = await promisify(execFile)("plutil", ["-convert", "json", "-o", "-", plist])
98+
return JSON.stringify(parsePlist(stdout))
99+
}
100+
101+
function currentUser() {
102+
try {
103+
return os.userInfo().username || "user"
104+
} catch {
105+
return "user"
106+
}
107+
}

‎packages/core/src/config/plugin/policy.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,23 @@ export const Plugin = define({
1414
const config = yield* Config.Service
1515
const managed = yield* ManagedPolicy.Service
1616
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.provider.reload())
17-
// Authored documents reverse so user-global policy outranks repository policy; organization statements
18-
// from the connected Console follow every authored one and have the final say.
17+
const managedPaths = new Set<string>(config.managed ?? [])
18+
const isManaged = (entry: Document) => entry.path !== undefined && managedPaths.has(entry.path)
19+
// Authored documents reverse so user-global policy outranks repository policy; administrator-managed
20+
// documents follow them, and organization statements from the connected Console have the final say.
1921
const policies = () => {
2022
const organization = managed.current()
23+
const documents = loaded.entries.filter((entry): entry is Document => entry.type === "document")
2124
return [
22-
...loaded.entries
23-
.filter((entry): entry is Document => entry.type === "document")
25+
...documents
26+
.filter((entry) => !isManaged(entry))
2427
.toReversed()
2528
.flatMap((entry) => entry.info.experimental?.policies ?? [])
2629
.map((policy) => ({ ...policy, message: "Blocked by configuration policy" })),
30+
...documents
31+
.filter(isManaged)
32+
.flatMap((entry) => entry.info.experimental?.policies ?? [])
33+
.map((policy) => ({ ...policy, message: "Blocked by managed configuration policy" })),
2734
...organization.statements.map((policy) => ({
2835
...policy,
2936
message: organization.organization

‎packages/core/test/config/config.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ function testLayer(
5656
),
5757
)
5858
const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
59-
Config.node.replace(Config.configured(options)),
59+
// Managed config is disabled unless a test opts in, so host MDM profiles never leak in.
60+
Config.node.replace(Config.configured({ managed: {}, ...options })),
6061
Location.node.replace(locationLayer),
6162
Global.node.replace(Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })),
6263
Credential.node.replace(credentialNode),

0 commit comments

Comments
 (0)