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>
This commit is contained in:
co-authored by
Lenny Vaknine
Aiden Cline
parent
966d9cfa41
commit
7e32f80d82
@@ -2,6 +2,7 @@ import { Log } from "../util/log"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
|
import { Process } from "../util/process"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ModelsDev } from "../provider/models"
|
import { ModelsDev } from "../provider/models"
|
||||||
import { mergeDeep, pipe, unique } from "remeda"
|
import { mergeDeep, pipe, unique } from "remeda"
|
||||||
@@ -75,6 +76,59 @@ export namespace Config {
|
|||||||
|
|
||||||
const managedDir = managedConfigDir()
|
const managedDir = managedConfigDir()
|
||||||
|
|
||||||
|
const MANAGED_PLIST_DOMAIN = "ai.opencode.managed"
|
||||||
|
|
||||||
|
// Keys injected by macOS/MDM into the managed plist that are not OpenCode config
|
||||||
|
const PLIST_META = new Set([
|
||||||
|
"PayloadDisplayName",
|
||||||
|
"PayloadIdentifier",
|
||||||
|
"PayloadType",
|
||||||
|
"PayloadUUID",
|
||||||
|
"PayloadVersion",
|
||||||
|
"_manualProfile",
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse raw JSON (from plutil conversion of a managed plist) into OpenCode config.
|
||||||
|
* Strips MDM metadata keys before parsing through the config schema.
|
||||||
|
* Pure function — no OS interaction, safe to unit test directly.
|
||||||
|
*/
|
||||||
|
export function parseManagedPlist(json: string, source: string): Info {
|
||||||
|
const raw = JSON.parse(json)
|
||||||
|
for (const key of Object.keys(raw)) {
|
||||||
|
if (PLIST_META.has(key)) delete raw[key]
|
||||||
|
}
|
||||||
|
return parseConfig(JSON.stringify(raw), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read macOS managed preferences deployed via .mobileconfig / MDM (Jamf, Kandji, etc).
|
||||||
|
* MDM-installed profiles write to /Library/Managed Preferences/ which is only writable by root.
|
||||||
|
* User-scoped plists are checked first, then machine-scoped.
|
||||||
|
*/
|
||||||
|
async function readManagedPreferences(): Promise<Info> {
|
||||||
|
if (process.platform !== "darwin") return {}
|
||||||
|
|
||||||
|
const domain = MANAGED_PLIST_DOMAIN
|
||||||
|
const user = os.userInfo().username
|
||||||
|
const paths = [
|
||||||
|
path.join("/Library/Managed Preferences", user, `${domain}.plist`),
|
||||||
|
path.join("/Library/Managed Preferences", `${domain}.plist`),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const plist of paths) {
|
||||||
|
if (!existsSync(plist)) continue
|
||||||
|
log.info("reading macOS managed preferences", { path: plist })
|
||||||
|
const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("failed to convert managed preferences plist", { path: plist })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return parseManagedPlist(result.stdout.toString(), `mobileconfig:${plist}`)
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
// Custom merge function that concatenates array fields instead of replacing them
|
// Custom merge function that concatenates array fields instead of replacing them
|
||||||
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||||
const merged = mergeDeep(target, source)
|
const merged = mergeDeep(target, source)
|
||||||
@@ -1356,6 +1410,9 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
|
||||||
|
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
|
||||||
|
|
||||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||||
result.agent = mergeDeep(result.agent ?? {}, {
|
result.agent = mergeDeep(result.agent ?? {}, {
|
||||||
[name]: {
|
[name]: {
|
||||||
|
|||||||
@@ -2265,3 +2265,84 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// parseManagedPlist unit tests — pure function, no OS interaction
|
||||||
|
|
||||||
|
test("parseManagedPlist strips MDM metadata keys", async () => {
|
||||||
|
const config = await Config.parseManagedPlist(
|
||||||
|
JSON.stringify({
|
||||||
|
PayloadDisplayName: "OpenCode Managed",
|
||||||
|
PayloadIdentifier: "ai.opencode.managed.test",
|
||||||
|
PayloadType: "ai.opencode.managed",
|
||||||
|
PayloadUUID: "AAAA-BBBB-CCCC",
|
||||||
|
PayloadVersion: 1,
|
||||||
|
_manualProfile: true,
|
||||||
|
share: "disabled",
|
||||||
|
model: "mdm/model",
|
||||||
|
}),
|
||||||
|
"test:mobileconfig",
|
||||||
|
)
|
||||||
|
expect(config.share).toBe("disabled")
|
||||||
|
expect(config.model).toBe("mdm/model")
|
||||||
|
// MDM keys must not leak into the parsed config
|
||||||
|
expect((config as any).PayloadUUID).toBeUndefined()
|
||||||
|
expect((config as any).PayloadType).toBeUndefined()
|
||||||
|
expect((config as any)._manualProfile).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseManagedPlist parses server settings", async () => {
|
||||||
|
const config = await Config.parseManagedPlist(
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
server: { hostname: "127.0.0.1", mdns: false },
|
||||||
|
autoupdate: true,
|
||||||
|
}),
|
||||||
|
"test:mobileconfig",
|
||||||
|
)
|
||||||
|
expect(config.server?.hostname).toBe("127.0.0.1")
|
||||||
|
expect(config.server?.mdns).toBe(false)
|
||||||
|
expect(config.autoupdate).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseManagedPlist parses permission rules", async () => {
|
||||||
|
const config = await Config.parseManagedPlist(
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
permission: {
|
||||||
|
"*": "ask",
|
||||||
|
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
|
||||||
|
grep: "allow",
|
||||||
|
glob: "allow",
|
||||||
|
webfetch: "ask",
|
||||||
|
"~/.ssh/*": "deny",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"test:mobileconfig",
|
||||||
|
)
|
||||||
|
expect(config.permission?.["*"]).toBe("ask")
|
||||||
|
expect(config.permission?.grep).toBe("allow")
|
||||||
|
expect(config.permission?.webfetch).toBe("ask")
|
||||||
|
expect(config.permission?.["~/.ssh/*"]).toBe("deny")
|
||||||
|
const bash = config.permission?.bash as Record<string, string>
|
||||||
|
expect(bash?.["rm -rf *"]).toBe("deny")
|
||||||
|
expect(bash?.["curl *"]).toBe("deny")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseManagedPlist parses enabled_providers", async () => {
|
||||||
|
const config = await Config.parseManagedPlist(
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
enabled_providers: ["anthropic", "google"],
|
||||||
|
}),
|
||||||
|
"test:mobileconfig",
|
||||||
|
)
|
||||||
|
expect(config.enabled_providers).toEqual(["anthropic", "google"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseManagedPlist handles empty config", async () => {
|
||||||
|
const config = await Config.parseManagedPlist(
|
||||||
|
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
|
||||||
|
"test:mobileconfig",
|
||||||
|
)
|
||||||
|
expect(config.$schema).toBe("https://opencode.ai/config.json")
|
||||||
|
})
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ Config sources are loaded in this order (later sources override earlier ones):
|
|||||||
4. **Project config** (`opencode.json` in project) - project-specific settings
|
4. **Project config** (`opencode.json` in project) - project-specific settings
|
||||||
5. **`.opencode` directories** - agents, commands, plugins
|
5. **`.opencode` directories** - agents, commands, plugins
|
||||||
6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides
|
6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides
|
||||||
|
7. **Managed config files** (`/Library/Application Support/opencode/` on macOS) - admin-controlled
|
||||||
|
8. **macOS managed preferences** (`.mobileconfig` via MDM) - highest priority, not user-overridable
|
||||||
|
|
||||||
This means project configs can override global defaults, and global configs can override remote organizational defaults.
|
This means project configs can override global defaults, and global configs can override remote organizational defaults. Managed settings override everything.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
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.
|
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
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Managed settings
|
||||||
|
|
||||||
|
Organizations can enforce configuration that users cannot override. Managed settings are loaded at the highest priority tier.
|
||||||
|
|
||||||
|
#### File-based
|
||||||
|
|
||||||
|
Drop an `opencode.json` or `opencode.jsonc` file in the system managed config directory:
|
||||||
|
|
||||||
|
| Platform | Path |
|
||||||
|
|----------|------|
|
||||||
|
| macOS | `/Library/Application Support/opencode/` |
|
||||||
|
| Linux | `/etc/opencode/` |
|
||||||
|
| Windows | `%ProgramData%\opencode` |
|
||||||
|
|
||||||
|
These directories require admin/root access to write, so users cannot modify them.
|
||||||
|
|
||||||
|
#### macOS managed preferences
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
OpenCode checks these paths:
|
||||||
|
|
||||||
|
1. `/Library/Managed Preferences/<user>/ai.opencode.managed.plist`
|
||||||
|
2. `/Library/Managed Preferences/ai.opencode.managed.plist`
|
||||||
|
|
||||||
|
The plist keys map directly to `opencode.json` fields. MDM metadata keys (`PayloadUUID`, `PayloadType`, etc.) are stripped automatically.
|
||||||
|
|
||||||
|
**Creating a `.mobileconfig`**
|
||||||
|
|
||||||
|
Use the `ai.opencode.managed` PayloadType. The OpenCode config keys go directly in the payload dict:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PayloadContent</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>ai.opencode.managed</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>com.example.opencode.config</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>GENERATE-YOUR-OWN-UUID</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>share</key>
|
||||||
|
<string>disabled</string>
|
||||||
|
<key>server</key>
|
||||||
|
<dict>
|
||||||
|
<key>hostname</key>
|
||||||
|
<string>127.0.0.1</string>
|
||||||
|
</dict>
|
||||||
|
<key>permission</key>
|
||||||
|
<dict>
|
||||||
|
<key>*</key>
|
||||||
|
<string>ask</string>
|
||||||
|
<key>bash</key>
|
||||||
|
<dict>
|
||||||
|
<key>*</key>
|
||||||
|
<string>ask</string>
|
||||||
|
<key>rm -rf *</key>
|
||||||
|
<string>deny</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>Configuration</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>com.example.opencode</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>GENERATE-YOUR-OWN-UUID</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate unique UUIDs with `uuidgen`. Customize the settings to match your organization's requirements.
|
||||||
|
|
||||||
|
**Deploying via MDM**
|
||||||
|
|
||||||
|
- **Jamf Pro:** Computers > Configuration Profiles > Upload > scope to target devices or smart groups
|
||||||
|
- **FleetDM:** Add the `.mobileconfig` to your gitops repo under `mdm.macos_settings.custom_settings` and run `fleetctl apply`
|
||||||
|
|
||||||
|
**Verifying on a device**
|
||||||
|
|
||||||
|
Double-click the `.mobileconfig` to install locally for testing (shows in System Settings > Privacy & Security > Profiles), then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode debug config
|
||||||
|
```
|
||||||
|
|
||||||
|
All managed preference keys appear in the resolved config and cannot be overridden by user or project configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
|
The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
|
||||||
|
|||||||
Reference in New Issue
Block a user