diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3cae1af4bdb2..850bcc28bcd9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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" @@ -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 { + 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) @@ -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]: { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index be2a6b11be51..9c631360b620 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -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 + 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") +}) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index a9c39bd59f3f..88aa9d313560 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -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. @@ -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//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 + + + + + PayloadContent + + + PayloadType + ai.opencode.managed + PayloadIdentifier + com.example.opencode.config + PayloadUUID + GENERATE-YOUR-OWN-UUID + PayloadVersion + 1 + share + disabled + server + + hostname + 127.0.0.1 + + permission + + * + ask + bash + + * + ask + rm -rf * + deny + + + + + PayloadType + Configuration + PayloadIdentifier + com.example.opencode + PayloadUUID + GENERATE-YOUR-OWN-UUID + PayloadVersion + 1 + + +``` + +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).