Skip to content

Commit 7e32f80

Browse files
lennyvaknine43Lenny Vakninerekram1-node
authored
feat: add macOS managed preferences support for enterprise MDM deployments (#19178)
Co-authored-by: Lenny Vaknine <lvaknine@gitlab.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
1 parent 966d9cf commit 7e32f80

3 files changed

Lines changed: 241 additions & 1 deletion

File tree

packages/opencode/src/config/config.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Log } from "../util/log"
22
import path from "path"
33
import { pathToFileURL } from "url"
44
import os from "os"
5+
import { Process } from "../util/process"
56
import z from "zod"
67
import { ModelsDev } from "../provider/models"
78
import { mergeDeep, pipe, unique } from "remeda"
@@ -75,6 +76,59 @@ export namespace Config {
7576

7677
const managedDir = managedConfigDir()
7778

79+
const MANAGED_PLIST_DOMAIN = "ai.opencode.managed"
80+
81+
// Keys injected by macOS/MDM into the managed plist that are not OpenCode config
82+
const PLIST_META = new Set([
83+
"PayloadDisplayName",
84+
"PayloadIdentifier",
85+
"PayloadType",
86+
"PayloadUUID",
87+
"PayloadVersion",
88+
"_manualProfile",
89+
])
90+
91+
/**
92+
* Parse raw JSON (from plutil conversion of a managed plist) into OpenCode config.
93+
* Strips MDM metadata keys before parsing through the config schema.
94+
* Pure function — no OS interaction, safe to unit test directly.
95+
*/
96+
export function parseManagedPlist(json: string, source: string): Info {
97+
const raw = JSON.parse(json)
98+
for (const key of Object.keys(raw)) {
99+
if (PLIST_META.has(key)) delete raw[key]
100+
}
101+
return parseConfig(JSON.stringify(raw), source)
102+
}
103+
104+
/**
105+
* Read macOS managed preferences deployed via .mobileconfig / MDM (Jamf, Kandji, etc).
106+
* MDM-installed profiles write to /Library/Managed Preferences/ which is only writable by root.
107+
* User-scoped plists are checked first, then machine-scoped.
108+
*/
109+
async function readManagedPreferences(): Promise<Info> {
110+
if (process.platform !== "darwin") return {}
111+
112+
const domain = MANAGED_PLIST_DOMAIN
113+
const user = os.userInfo().username
114+
const paths = [
115+
path.join("/Library/Managed Preferences", user, `${domain}.plist`),
116+
path.join("/Library/Managed Preferences", `${domain}.plist`),
117+
]
118+
119+
for (const plist of paths) {
120+
if (!existsSync(plist)) continue
121+
log.info("reading macOS managed preferences", { path: plist })
122+
const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
123+
if (result.code !== 0) {
124+
log.warn("failed to convert managed preferences plist", { path: plist })
125+
continue
126+
}
127+
return parseManagedPlist(result.stdout.toString(), `mobileconfig:${plist}`)
128+
}
129+
return {}
130+
}
131+
78132
// Custom merge function that concatenates array fields instead of replacing them
79133
function mergeConfigConcatArrays(target: Info, source: Info): Info {
80134
const merged = mergeDeep(target, source)
@@ -1356,6 +1410,9 @@ export namespace Config {
13561410
}
13571411
}
13581412

1413+
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
1414+
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
1415+
13591416
for (const [name, mode] of Object.entries(result.mode ?? {})) {
13601417
result.agent = mergeDeep(result.agent ?? {}, {
13611418
[name]: {

packages/opencode/test/config/config.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2265,3 +2265,84 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
22652265
}
22662266
})
22672267
})
2268+
2269+
// parseManagedPlist unit tests — pure function, no OS interaction
2270+
2271+
test("parseManagedPlist strips MDM metadata keys", async () => {
2272+
const config = await Config.parseManagedPlist(
2273+
JSON.stringify({
2274+
PayloadDisplayName: "OpenCode Managed",
2275+
PayloadIdentifier: "ai.opencode.managed.test",
2276+
PayloadType: "ai.opencode.managed",
2277+
PayloadUUID: "AAAA-BBBB-CCCC",
2278+
PayloadVersion: 1,
2279+
_manualProfile: true,
2280+
share: "disabled",
2281+
model: "mdm/model",
2282+
}),
2283+
"test:mobileconfig",
2284+
)
2285+
expect(config.share).toBe("disabled")
2286+
expect(config.model).toBe("mdm/model")
2287+
// MDM keys must not leak into the parsed config
2288+
expect((config as any).PayloadUUID).toBeUndefined()
2289+
expect((config as any).PayloadType).toBeUndefined()
2290+
expect((config as any)._manualProfile).toBeUndefined()
2291+
})
2292+
2293+
test("parseManagedPlist parses server settings", async () => {
2294+
const config = await Config.parseManagedPlist(
2295+
JSON.stringify({
2296+
$schema: "https://opencode.ai/config.json",
2297+
server: { hostname: "127.0.0.1", mdns: false },
2298+
autoupdate: true,
2299+
}),
2300+
"test:mobileconfig",
2301+
)
2302+
expect(config.server?.hostname).toBe("127.0.0.1")
2303+
expect(config.server?.mdns).toBe(false)
2304+
expect(config.autoupdate).toBe(true)
2305+
})
2306+
2307+
test("parseManagedPlist parses permission rules", async () => {
2308+
const config = await Config.parseManagedPlist(
2309+
JSON.stringify({
2310+
$schema: "https://opencode.ai/config.json",
2311+
permission: {
2312+
"*": "ask",
2313+
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
2314+
grep: "allow",
2315+
glob: "allow",
2316+
webfetch: "ask",
2317+
"~/.ssh/*": "deny",
2318+
},
2319+
}),
2320+
"test:mobileconfig",
2321+
)
2322+
expect(config.permission?.["*"]).toBe("ask")
2323+
expect(config.permission?.grep).toBe("allow")
2324+
expect(config.permission?.webfetch).toBe("ask")
2325+
expect(config.permission?.["~/.ssh/*"]).toBe("deny")
2326+
const bash = config.permission?.bash as Record<string, string>
2327+
expect(bash?.["rm -rf *"]).toBe("deny")
2328+
expect(bash?.["curl *"]).toBe("deny")
2329+
})
2330+
2331+
test("parseManagedPlist parses enabled_providers", async () => {
2332+
const config = await Config.parseManagedPlist(
2333+
JSON.stringify({
2334+
$schema: "https://opencode.ai/config.json",
2335+
enabled_providers: ["anthropic", "google"],
2336+
}),
2337+
"test:mobileconfig",
2338+
)
2339+
expect(config.enabled_providers).toEqual(["anthropic", "google"])
2340+
})
2341+
2342+
test("parseManagedPlist handles empty config", async () => {
2343+
const config = await Config.parseManagedPlist(
2344+
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
2345+
"test:mobileconfig",
2346+
)
2347+
expect(config.$schema).toBe("https://opencode.ai/config.json")
2348+
})

packages/web/src/content/docs/config.mdx

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ Config sources are loaded in this order (later sources override earlier ones):
4949
4. **Project config** (`opencode.json` in project) - project-specific settings
5050
5. **`.opencode` directories** - agents, commands, plugins
5151
6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides
52+
7. **Managed config files** (`/Library/Application Support/opencode/` on macOS) - admin-controlled
53+
8. **macOS managed preferences** (`.mobileconfig` via MDM) - highest priority, not user-overridable
5254

53-
This means project configs can override global defaults, and global configs can override remote organizational defaults.
55+
This means project configs can override global defaults, and global configs can override remote organizational defaults. Managed settings override everything.
5456

5557
:::note
5658
The `.opencode` and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility.
@@ -149,6 +151,106 @@ The custom directory is loaded after the global config and `.opencode` directori
149151

150152
---
151153

154+
### Managed settings
155+
156+
Organizations can enforce configuration that users cannot override. Managed settings are loaded at the highest priority tier.
157+
158+
#### File-based
159+
160+
Drop an `opencode.json` or `opencode.jsonc` file in the system managed config directory:
161+
162+
| Platform | Path |
163+
|----------|------|
164+
| macOS | `/Library/Application Support/opencode/` |
165+
| Linux | `/etc/opencode/` |
166+
| Windows | `%ProgramData%\opencode` |
167+
168+
These directories require admin/root access to write, so users cannot modify them.
169+
170+
#### macOS managed preferences
171+
172+
On macOS, OpenCode reads managed preferences from the `ai.opencode.managed` preference domain. Deploy a `.mobileconfig` via MDM (Jamf, Kandji, FleetDM) and the settings are enforced automatically.
173+
174+
OpenCode checks these paths:
175+
176+
1. `/Library/Managed Preferences/<user>/ai.opencode.managed.plist`
177+
2. `/Library/Managed Preferences/ai.opencode.managed.plist`
178+
179+
The plist keys map directly to `opencode.json` fields. MDM metadata keys (`PayloadUUID`, `PayloadType`, etc.) are stripped automatically.
180+
181+
**Creating a `.mobileconfig`**
182+
183+
Use the `ai.opencode.managed` PayloadType. The OpenCode config keys go directly in the payload dict:
184+
185+
```xml
186+
<?xml version="1.0" encoding="UTF-8"?>
187+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
188+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
189+
<plist version="1.0">
190+
<dict>
191+
<key>PayloadContent</key>
192+
<array>
193+
<dict>
194+
<key>PayloadType</key>
195+
<string>ai.opencode.managed</string>
196+
<key>PayloadIdentifier</key>
197+
<string>com.example.opencode.config</string>
198+
<key>PayloadUUID</key>
199+
<string>GENERATE-YOUR-OWN-UUID</string>
200+
<key>PayloadVersion</key>
201+
<integer>1</integer>
202+
<key>share</key>
203+
<string>disabled</string>
204+
<key>server</key>
205+
<dict>
206+
<key>hostname</key>
207+
<string>127.0.0.1</string>
208+
</dict>
209+
<key>permission</key>
210+
<dict>
211+
<key>*</key>
212+
<string>ask</string>
213+
<key>bash</key>
214+
<dict>
215+
<key>*</key>
216+
<string>ask</string>
217+
<key>rm -rf *</key>
218+
<string>deny</string>
219+
</dict>
220+
</dict>
221+
</dict>
222+
</array>
223+
<key>PayloadType</key>
224+
<string>Configuration</string>
225+
<key>PayloadIdentifier</key>
226+
<string>com.example.opencode</string>
227+
<key>PayloadUUID</key>
228+
<string>GENERATE-YOUR-OWN-UUID</string>
229+
<key>PayloadVersion</key>
230+
<integer>1</integer>
231+
</dict>
232+
</plist>
233+
```
234+
235+
Generate unique UUIDs with `uuidgen`. Customize the settings to match your organization's requirements.
236+
237+
**Deploying via MDM**
238+
239+
- **Jamf Pro:** Computers > Configuration Profiles > Upload > scope to target devices or smart groups
240+
- **FleetDM:** Add the `.mobileconfig` to your gitops repo under `mdm.macos_settings.custom_settings` and run `fleetctl apply`
241+
242+
**Verifying on a device**
243+
244+
Double-click the `.mobileconfig` to install locally for testing (shows in System Settings > Privacy & Security > Profiles), then run:
245+
246+
```bash
247+
opencode debug config
248+
```
249+
250+
All managed preference keys appear in the resolved config and cannot be overridden by user or project configuration.
251+
252+
---
253+
152254
## Schema
153255

154256
The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).

0 commit comments

Comments
 (0)