Skip to content
57 changes: 57 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Log } from "../util/log"
import path from "path"
import { pathToFileURL } from "url"
import os from "os"
import { Process } from "../util/process"
import z from "zod"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda"
Expand Down Expand Up @@ -75,6 +76,59 @@ export namespace Config {

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
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
Expand Down Expand Up @@ -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 ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
Expand Down
81 changes: 81 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
104 changes: 103 additions & 1 deletion packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
5. **`.opencode` directories** - agents, commands, plugins
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
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.
Expand Down Expand Up @@ -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

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