Skip to content

Commit 01e49b1

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 19fb891 commit 01e49b1

12 files changed

Lines changed: 519 additions & 13 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; `ManagedPolicy.statements` ranks them 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: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ export const Plugin = define({
2121
Stream.runForEach(() => reload),
2222
Effect.forkScoped({ startImmediately: true }),
2323
)
24-
// Authored documents reverse so user-global policy outranks repository policy; organization statements
25-
// from the connected Console follow every authored one and have the final say.
26-
const policies = () => ManagedPolicy.statements(loaded.entries, managed.current())
24+
const policies = () => ManagedPolicy.statements(loaded.entries, managed.current(), config.managed)
2725
yield* ctx.provider.transform((providers) => {
2826
const current = policies()
2927
for (const record of providers.list()) {

‎packages/core/src/managed-policy.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,27 @@ export const layer = Layer.effect(
4747

4848
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
4949

50-
export function statements(entries: readonly Entry[], organization: State) {
50+
/**
51+
* Orders policy statements so later ones win: authored documents (reversed, so user-global outranks
52+
* repository), then administrator-managed documents, then Console organization statements.
53+
*
54+
* @param entries Loaded config entries.
55+
* @param organization Statements from the connected Console.
56+
* @param managed Paths of administrator-managed documents (`Config.Interface.managed`).
57+
*/
58+
export function statements(entries: readonly Entry[], organization: State, managed: readonly string[] = []) {
59+
const documents = entries.filter((entry): entry is Document => entry.type === "document")
60+
const isManaged = (entry: Document) => entry.path !== undefined && managed.includes(entry.path)
5161
return [
52-
...entries
53-
.filter((entry): entry is Document => entry.type === "document")
62+
...documents
63+
.filter((entry) => !isManaged(entry))
5464
.toReversed()
5565
.flatMap((entry) => entry.info.experimental?.policies ?? [])
5666
.map((policy) => ({ ...policy, message: "Blocked by configuration policy" })),
67+
...documents
68+
.filter(isManaged)
69+
.flatMap((entry) => entry.info.experimental?.policies ?? [])
70+
.map((policy) => ({ ...policy, message: "Blocked by managed configuration policy" })),
5771
...organization.statements.map((policy) => ({
5872
...policy,
5973
message: organization.organization

‎packages/core/src/plugin/module.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export const make = Effect.fn("PluginModule.make")(function* () {
5151
path.isAbsolute(operation.target) || version <= 0 ? operation.target : operation.target.slice(0, version)
5252
if (
5353
ManagedPolicy.decision(
54-
ManagedPolicy.statements(yield* config.entries(), managed.current()),
54+
ManagedPolicy.statements(yield* config.entries(), managed.current(), config.managed),
5555
"integration.use",
5656
`plugin:${target}`,
5757
) === "deny"

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ function testLayer(
6868
),
6969
)
7070
const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
71-
Config.node.replace(Config.configured(options)),
71+
// Managed config is disabled unless a test opts in, so host MDM profiles never leak in.
72+
Config.node.replace(Config.configured({ managed: {}, ...options })),
7273
Location.node.replace(locationLayer),
7374
Global.node.replace(Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })),
7475
Credential.node.replace(credentialNode),

0 commit comments

Comments
 (0)