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:
@@ -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<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)
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user