mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
Preserve Codex runtime config preferences (#3944)
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
/* eslint-disable max-lines -- Why: these cases exercise one stateful Codex
|
||||
config sync contract across first-run, upgrade, corrupt-state, and trust
|
||||
preservation paths. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as NodeOs from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -41,6 +52,15 @@ function getRuntimeConfigPath(): string {
|
||||
return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml')
|
||||
}
|
||||
|
||||
function getConfigSyncStatePath(): string {
|
||||
return join(userDataDir, 'codex-runtime-home', 'config-sync-state.json')
|
||||
}
|
||||
|
||||
function establishSystemConfigBaseline(config: string): void {
|
||||
writeFileSync(getSystemConfigPath(), config, 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-home-'))
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-user-data-'))
|
||||
@@ -93,6 +113,45 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]')
|
||||
})
|
||||
|
||||
it('treats whitespace-formatted hook trust headers as runtime-owned', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'model = "system-model"',
|
||||
'',
|
||||
'["hooks" . "state" . "system-hooks:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:system"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
'model = "runtime-model"',
|
||||
'',
|
||||
'["hooks" . "state" . "runtime-hooks:stop:0:0"]',
|
||||
'enabled = false',
|
||||
'trusted_hash = "sha256:runtime"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "next-system-model"')
|
||||
expect(runtimeConfig).toContain('["hooks" . "state" . "runtime-hooks:stop:0:0"]')
|
||||
expect(runtimeConfig).toContain('trusted_hash = "sha256:runtime"')
|
||||
expect(runtimeConfig).not.toContain('["hooks" . "state" . "system-hooks:stop:0:0"]')
|
||||
expect(runtimeConfig).not.toContain('trusted_hash = "sha256:system"')
|
||||
})
|
||||
|
||||
it('normalizes deprecated codex_hooks feature flag only in runtime config', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
@@ -123,7 +182,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
})
|
||||
|
||||
it('mirrors system config updates while preserving runtime-owned trust sections', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
establishSystemConfigBaseline('model = "initial-system-model"\n')
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
@@ -175,8 +234,763 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not treat TOML table headers inside multiline strings as sections', () => {
|
||||
it('keeps runtime Codex preference changes when the system config has not changed', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
})
|
||||
|
||||
it('keeps runtime Codex preference changes when the system config is missing', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
})
|
||||
|
||||
it('keeps runtime preferences on first baseline while honoring system project trust', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'model = "system-model"',
|
||||
'',
|
||||
'[projects."/repo"] # explicit revocation',
|
||||
'trust_level = "untrusted"',
|
||||
'',
|
||||
'[projects."/system-only"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
'[hooks.state."system-hooks:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:system"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
'model = "runtime-model"',
|
||||
'',
|
||||
'[projects."/repo"]',
|
||||
'trust_level = "trusted"',
|
||||
'metadata = "runtime-owned"',
|
||||
'',
|
||||
'[projects."/runtime-only"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
'[hooks.state."runtime-hooks:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:runtime"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
utimesSync(
|
||||
getSystemConfigPath(),
|
||||
new Date('2024-01-01T00:00:00Z'),
|
||||
new Date('2024-01-01T00:00:00Z')
|
||||
)
|
||||
utimesSync(
|
||||
getRuntimeConfigPath(),
|
||||
new Date('2024-01-01T00:01:00Z'),
|
||||
new Date('2024-01-01T00:01:00Z')
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "runtime-model"')
|
||||
expect(runtimeConfig).not.toContain('model = "system-model"')
|
||||
expect(runtimeConfig).toContain('[projects."/repo"]')
|
||||
expect(runtimeConfig).toContain('trust_level = "untrusted"')
|
||||
expect(runtimeConfig).toContain('metadata = "runtime-owned"')
|
||||
expect(runtimeConfig).toContain('[projects."/runtime-only"]')
|
||||
expect(runtimeConfig).toContain('[projects."/system-only"]')
|
||||
expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]')
|
||||
expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]')
|
||||
expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1)
|
||||
|
||||
writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const updatedRuntimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(updatedRuntimeConfig).toContain('model = "next-system-model"')
|
||||
expect(updatedRuntimeConfig).not.toContain('model = "runtime-model"')
|
||||
expect(updatedRuntimeConfig).toContain('[projects."/runtime-only"]')
|
||||
expect(updatedRuntimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]')
|
||||
})
|
||||
|
||||
it('baselines skipped no-baseline system settings until their contents change', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8')
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
utimesSync(
|
||||
getSystemConfigPath(),
|
||||
new Date('2024-01-01T00:00:00Z'),
|
||||
new Date('2024-01-01T00:00:00Z')
|
||||
)
|
||||
utimesSync(
|
||||
getRuntimeConfigPath(),
|
||||
new Date('2024-01-01T00:01:00Z'),
|
||||
new Date('2024-01-01T00:01:00Z')
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toMatch(
|
||||
/"lastMirrorableSystemConfigDigest": "sha256:[a-f0-9]{64}"/
|
||||
)
|
||||
|
||||
utimesSync(
|
||||
getSystemConfigPath(),
|
||||
new Date('2024-01-01T00:02:00Z'),
|
||||
new Date('2024-01-01T00:02:00Z')
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
|
||||
writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "next-system-model"\n')
|
||||
})
|
||||
|
||||
it('keeps runtime preferences when only system project trust changes', () => {
|
||||
establishSystemConfigBaseline('model = "system-model"\n')
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'model = "system-model"',
|
||||
'',
|
||||
'[projects."/new-system-project"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
'[hooks.state."system-hooks:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:system"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "runtime-model"')
|
||||
expect(runtimeConfig).toContain('[projects."/new-system-project"]')
|
||||
expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]')
|
||||
})
|
||||
|
||||
it('keeps runtime preferences when an unrelated ordinary system section changes', () => {
|
||||
establishSystemConfigBaseline('model = "system-model"\n')
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'model = "system-model"',
|
||||
'',
|
||||
'[mcp_servers.files]',
|
||||
'command = "node"',
|
||||
'args = ["server.js"]',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "runtime-model"')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.files]')
|
||||
expect(runtimeConfig).toContain('command = "node"')
|
||||
expect(runtimeConfig).not.toContain('model = "system-model"')
|
||||
})
|
||||
|
||||
it('matches equivalent ordinary table headers before merging system sections', () => {
|
||||
establishSystemConfigBaseline(
|
||||
['model = "system-model"', '', '[mcp_servers.files]', 'command = "node"', ''].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
'model = "runtime-model"',
|
||||
'',
|
||||
'[mcp_servers.files]',
|
||||
'command = "node"',
|
||||
'args = ["runtime.js"]',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
['model = "system-model"', '', '[mcp_servers . files]', 'command = "node"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "runtime-model"')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.files]')
|
||||
expect(runtimeConfig).toContain('args = ["runtime.js"]')
|
||||
expect(runtimeConfig).not.toContain('[mcp_servers . files]')
|
||||
expect(runtimeConfig.match(/mcp_servers/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps changed top-level settings before TOML table sections', () => {
|
||||
establishSystemConfigBaseline(
|
||||
['model = "system-model"', '', '[mcp_servers.files]', 'command = "node"', ''].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
'model = "runtime-model"',
|
||||
'',
|
||||
'model_reasoning_effort = "low"',
|
||||
'',
|
||||
'[mcp_servers.files]',
|
||||
'command = "node"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
['model = "next-system-model"', '', '[mcp_servers.files]', 'command = "node"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "next-system-model"')
|
||||
expect(runtimeConfig.indexOf('model = "next-system-model"')).toBeLessThan(
|
||||
runtimeConfig.indexOf('[mcp_servers.files]')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps runtime preferences when the system config is deleted after a baseline', () => {
|
||||
establishSystemConfigBaseline('model = "system-model"\n')
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
rmSync(getSystemConfigPath(), { force: true })
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
})
|
||||
|
||||
it('keeps runtime-edited settings when a deleted system config reappears unchanged', () => {
|
||||
establishSystemConfigBaseline('model = "system-model"\n')
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
rmSync(getSystemConfigPath(), { force: true })
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
})
|
||||
|
||||
it('removes unchanged mirrored settings when the system config is deleted', () => {
|
||||
establishSystemConfigBaseline(
|
||||
['model = "system-model"', 'model_reasoning_effort = "high"', ''].join('\n')
|
||||
)
|
||||
rmSync(getSystemConfigPath(), { force: true })
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('')
|
||||
})
|
||||
|
||||
it('keeps a migrated legacy baseline when system config is temporarily missing', () => {
|
||||
const systemConfig = 'model = "system-model"\n'
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
writeFileSync(
|
||||
getConfigSyncStatePath(),
|
||||
`${JSON.stringify({ lastSystemConfig: systemConfig }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toContain('lastSystemConfigUnitDigests')
|
||||
|
||||
writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
|
||||
writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "next-system-model"\n')
|
||||
})
|
||||
|
||||
it('defers digest-only state migration while system config is temporarily missing', () => {
|
||||
const systemConfig = 'model = "system-model"\n'
|
||||
establishSystemConfigBaseline(systemConfig)
|
||||
const state = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as {
|
||||
lastMirrorableSystemConfigDigest: string
|
||||
}
|
||||
writeFileSync(
|
||||
getConfigSyncStatePath(),
|
||||
`${JSON.stringify(
|
||||
{ lastMirrorableSystemConfigDigest: state.lastMirrorableSystemConfigDigest },
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
rmSync(getSystemConfigPath(), { force: true })
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
|
||||
writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toContain('lastSystemConfigUnitDigests')
|
||||
})
|
||||
|
||||
it('applies system changes when digest-only state proves runtime still matches baseline', () => {
|
||||
const systemConfig = 'model = "system-model"\n'
|
||||
establishSystemConfigBaseline(systemConfig)
|
||||
const state = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as {
|
||||
lastMirrorableSystemConfigDigest: string
|
||||
}
|
||||
writeFileSync(
|
||||
getConfigSyncStatePath(),
|
||||
`${JSON.stringify(
|
||||
{ lastMirrorableSystemConfigDigest: state.lastMirrorableSystemConfigDigest },
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "next-system-model"\n')
|
||||
expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toContain('lastSystemConfigUnitDigests')
|
||||
})
|
||||
|
||||
it('baselines digest-only recovery after preserving ambiguous runtime edits', () => {
|
||||
const systemConfig = 'model = "system-model"\n'
|
||||
establishSystemConfigBaseline(systemConfig)
|
||||
const state = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as {
|
||||
lastMirrorableSystemConfigDigest: string
|
||||
}
|
||||
writeFileSync(
|
||||
getConfigSyncStatePath(),
|
||||
`${JSON.stringify(
|
||||
{ lastMirrorableSystemConfigDigest: state.lastMirrorableSystemConfigDigest },
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
writeFileSync(getSystemConfigPath(), 'model = "current-system-model"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
|
||||
writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "next-system-model"\n')
|
||||
})
|
||||
|
||||
it('rewrites hybrid legacy sync state without keeping sensitive system config contents', () => {
|
||||
const systemConfig = 'model = "system-model"\napi_key = "sk-sensitive-value"\n'
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(getRuntimeConfigPath(), systemConfig, 'utf-8')
|
||||
writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
const stateWithDigest = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as {
|
||||
lastMirrorableSystemConfigDigest: string
|
||||
}
|
||||
writeFileSync(
|
||||
getConfigSyncStatePath(),
|
||||
`${JSON.stringify({ ...stateWithDigest, lastSystemConfig: systemConfig }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const state = readFileSync(getConfigSyncStatePath(), 'utf-8')
|
||||
expect(state).toContain('lastMirrorableSystemConfigDigest')
|
||||
expect(state).toMatch(/sha256:[a-f0-9]{64}/)
|
||||
expect(state).not.toContain('sk-sensitive-value')
|
||||
expect(state).not.toContain('api_key')
|
||||
expect(Object.hasOwn(JSON.parse(state) as Record<string, unknown>, 'lastSystemConfig')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('rewrites hybrid sync state with a non-string legacy key', () => {
|
||||
const systemConfig = 'model = "system-model"\n'
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(getRuntimeConfigPath(), systemConfig, 'utf-8')
|
||||
writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
const stateWithDigest = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as {
|
||||
lastMirrorableSystemConfigDigest: string
|
||||
}
|
||||
writeFileSync(
|
||||
getConfigSyncStatePath(),
|
||||
`${JSON.stringify({ ...stateWithDigest, lastSystemConfig: { token: 'sk-sensitive-value' } }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const parsed = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(parsed.lastMirrorableSystemConfigDigest).toMatch(/^sha256:[a-f0-9]{64}$/)
|
||||
expect(Object.hasOwn(parsed, 'lastSystemConfig')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps runtime preferences when the first sync baseline is missing', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
'model = "stale-runtime-model"',
|
||||
'',
|
||||
'[hooks.state."runtime-hooks:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:runtime"',
|
||||
'',
|
||||
'[projects."/system-project"]',
|
||||
'trust_level = "trusted"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'model = "new-system-model"',
|
||||
'',
|
||||
'[hooks.state."system-hooks:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:system"',
|
||||
'',
|
||||
'[projects."/system-project"]',
|
||||
'trust_level = "untrusted"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
utimesSync(
|
||||
getRuntimeConfigPath(),
|
||||
new Date('2024-01-01T00:00:00Z'),
|
||||
new Date('2024-01-01T00:00:00Z')
|
||||
)
|
||||
utimesSync(
|
||||
getSystemConfigPath(),
|
||||
new Date('2024-01-01T00:01:00Z'),
|
||||
new Date('2024-01-01T00:01:00Z')
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "stale-runtime-model"')
|
||||
expect(runtimeConfig).not.toContain('model = "new-system-model"')
|
||||
expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]')
|
||||
expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]')
|
||||
expect(runtimeConfig).toContain('[projects."/system-project"]')
|
||||
expect(runtimeConfig).toContain('trust_level = "untrusted"')
|
||||
})
|
||||
|
||||
it('updates project trust when the system project trust changes from untrusted to trusted', () => {
|
||||
establishSystemConfigBaseline(
|
||||
['model = "system-model"', '', '[projects."/repo"]', 'trust_level = "untrusted"', ''].join(
|
||||
'\n'
|
||||
)
|
||||
)
|
||||
writeFileSync(getSystemConfigPath(), '[projects."/repo"]\ntrust_level = "trusted"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('[projects."/repo"]')
|
||||
expect(runtimeConfig).toContain('trust_level = "trusted"')
|
||||
expect(runtimeConfig).not.toContain('trust_level = "untrusted"')
|
||||
})
|
||||
|
||||
it('updates project trust without removing runtime-owned project settings', () => {
|
||||
establishSystemConfigBaseline(['[projects."/repo"]', 'trust_level = "trusted"', ''].join('\n'))
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
['[projects."/repo"]', 'trust_level = "trusted"', 'metadata = "runtime-owned"', ''].join(
|
||||
'\n'
|
||||
),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(getSystemConfigPath(), '[projects."/repo"]\ntrust_level = "untrusted"\n')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('[projects."/repo"]')
|
||||
expect(runtimeConfig).toContain('trust_level = "untrusted"')
|
||||
expect(runtimeConfig).toContain('metadata = "runtime-owned"')
|
||||
expect(runtimeConfig).not.toContain('trust_level = "trusted"')
|
||||
})
|
||||
|
||||
it('applies system trusted project state during missing-baseline recovery', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(getRuntimeConfigPath(), '[projects."/repo"]\ntrust_level = "untrusted"\n')
|
||||
writeFileSync(getSystemConfigPath(), '[projects."/repo"]\ntrust_level = "trusted"\n')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('[projects."/repo"]')
|
||||
expect(runtimeConfig).toContain('trust_level = "trusted"')
|
||||
expect(runtimeConfig).not.toContain('trust_level = "untrusted"')
|
||||
})
|
||||
|
||||
it('matches equivalent quoted project headers before applying system untrust', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
["[projects . 'C:\\Repo']", 'trust_level = "trusted"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
['[projects."c:/repo"]', 'trust_level = "untrusted"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain("[projects . 'C:\\Repo']")
|
||||
expect(runtimeConfig).toContain('trust_level = "untrusted"')
|
||||
expect(runtimeConfig).not.toContain('[projects."c:/repo"]')
|
||||
expect(runtimeConfig.match(/trust_level/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('tracks duplicate array-table sections by occurrence', () => {
|
||||
establishSystemConfigBaseline(
|
||||
[
|
||||
'[[hooks.PermissionRequest]]',
|
||||
'command = "first"',
|
||||
'',
|
||||
'[[hooks.PermissionRequest]]',
|
||||
'command = "second"',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'[[hooks.PermissionRequest]]',
|
||||
'command = "first"',
|
||||
'',
|
||||
'[[hooks.PermissionRequest]]',
|
||||
'command = "updated-second"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('command = "first"')
|
||||
expect(runtimeConfig).toContain('command = "updated-second"')
|
||||
expect(runtimeConfig).not.toContain('command = "second"')
|
||||
expect(runtimeConfig.match(/\[\[hooks\.PermissionRequest\]\]/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
it('preserves duplicate array-table order when an earlier occurrence changes', () => {
|
||||
establishSystemConfigBaseline(
|
||||
[
|
||||
'[[hooks.PermissionRequest]]',
|
||||
'command = "first"',
|
||||
'',
|
||||
'[[hooks.PermissionRequest]]',
|
||||
'command = "second"',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'[[hooks.PermissionRequest]]',
|
||||
'command = "updated-first"',
|
||||
'',
|
||||
'[[hooks.PermissionRequest]]',
|
||||
'command = "second"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('command = "updated-first"')
|
||||
expect(runtimeConfig).toContain('command = "second"')
|
||||
expect(runtimeConfig).not.toContain('command = "first"')
|
||||
expect(runtimeConfig.indexOf('command = "updated-first"')).toBeLessThan(
|
||||
runtimeConfig.indexOf('command = "second"')
|
||||
)
|
||||
expect(runtimeConfig.match(/\[\[hooks\.PermissionRequest\]\]/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
it('recovers a corrupt sync state with newer system mtime without clobbering runtime preferences', () => {
|
||||
writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
writeFileSync(getConfigSyncStatePath(), '{not-json', 'utf-8')
|
||||
utimesSync(
|
||||
getRuntimeConfigPath(),
|
||||
new Date('2024-01-01T00:00:00Z'),
|
||||
new Date('2024-01-01T00:00:00Z')
|
||||
)
|
||||
utimesSync(
|
||||
getSystemConfigPath(),
|
||||
new Date('2024-01-01T00:01:00Z'),
|
||||
new Date('2024-01-01T00:01:00Z')
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toMatch(
|
||||
/"lastMirrorableSystemConfigDigest": "sha256:[a-f0-9]{64}"/
|
||||
)
|
||||
|
||||
writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "next-system-model"\n')
|
||||
})
|
||||
|
||||
it('recovers an invalid sync-state digest with newer system mtime without clobbering runtime preferences', () => {
|
||||
writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8')
|
||||
writeFileSync(
|
||||
getConfigSyncStatePath(),
|
||||
`${JSON.stringify({ lastMirrorableSystemConfigDigest: 'not-a-digest' }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
utimesSync(
|
||||
getRuntimeConfigPath(),
|
||||
new Date('2024-01-01T00:00:00Z'),
|
||||
new Date('2024-01-01T00:00:00Z')
|
||||
)
|
||||
utimesSync(
|
||||
getSystemConfigPath(),
|
||||
new Date('2024-01-01T00:01:00Z'),
|
||||
new Date('2024-01-01T00:01:00Z')
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n')
|
||||
expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toMatch(
|
||||
/"lastMirrorableSystemConfigDigest": "sha256:[a-f0-9]{64}"/
|
||||
)
|
||||
})
|
||||
|
||||
it('does not duplicate sensitive system config contents in sync state', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
'model = "system-model"\napi_key = "sk-sensitive-value"\n',
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const state = readFileSync(getConfigSyncStatePath(), 'utf-8')
|
||||
expect(state).toContain('lastMirrorableSystemConfigDigest')
|
||||
expect(state).toMatch(/sha256:[a-f0-9]{64}/)
|
||||
expect(state).not.toContain('sk-sensitive-value')
|
||||
expect(state).not.toContain('api_key')
|
||||
})
|
||||
|
||||
it('migrates legacy sync state without keeping sensitive system config contents', () => {
|
||||
const systemConfig = 'model = "system-model"\napi_key = "sk-sensitive-value"\n'
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(getRuntimeConfigPath(), systemConfig, 'utf-8')
|
||||
writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8')
|
||||
writeFileSync(
|
||||
getConfigSyncStatePath(),
|
||||
`${JSON.stringify({ lastSystemConfig: systemConfig }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const state = readFileSync(getConfigSyncStatePath(), 'utf-8')
|
||||
expect(state).toContain('lastMirrorableSystemConfigDigest')
|
||||
expect(state).toMatch(/sha256:[a-f0-9]{64}/)
|
||||
expect(state).not.toContain('sk-sensitive-value')
|
||||
expect(state).not.toContain('api_key')
|
||||
})
|
||||
|
||||
it('normalizes legacy codex_hooks sync state before comparing digests', () => {
|
||||
const systemConfig = [
|
||||
'model = "system-model"',
|
||||
'',
|
||||
'[features]',
|
||||
'codex_hooks = true',
|
||||
''
|
||||
].join('\n')
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
['model = "runtime-model"', '', '[features]', 'hooks = true', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8')
|
||||
writeFileSync(
|
||||
getConfigSyncStatePath(),
|
||||
`${JSON.stringify({ lastSystemConfig: systemConfig }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "runtime-model"')
|
||||
expect(runtimeConfig).not.toContain('model = "system-model"')
|
||||
expect(runtimeConfig).toContain('[features]\nhooks = true')
|
||||
})
|
||||
|
||||
it('does not treat TOML table headers inside multiline strings as sections', () => {
|
||||
establishSystemConfigBaseline('model = "initial-system-model"\n')
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
@@ -216,7 +1030,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
})
|
||||
|
||||
it('does not let triple quotes in comments affect runtime-owned section mirroring', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
establishSystemConfigBaseline('model = "initial-system-model"\n')
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
/* eslint-disable max-lines -- Why: keeping Codex config merge policy beside
|
||||
the TOML section scanner makes precedence between system config, runtime
|
||||
preferences, and trust state auditable in one place. */
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
|
||||
import {
|
||||
getSystemCodexConfigDigest,
|
||||
readLastSyncedSystemCodexConfigState,
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigest,
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigestOnly,
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigestValue
|
||||
} from './codex-config-sync-state'
|
||||
|
||||
function getRuntimeCodexConfigTomlPath(): string {
|
||||
return join(getOrcaManagedCodexHomePath(), 'config.toml')
|
||||
@@ -31,18 +42,163 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
|
||||
const systemConfig = normalizeDeprecatedCodexHookFeatureFlag(
|
||||
systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : ''
|
||||
)
|
||||
const systemConfigUnits = getSystemConfigUnits(systemConfig)
|
||||
const systemConfigUnitDigests = getSystemConfigUnitDigestRecord(systemConfigUnits)
|
||||
const mirrorableSystemConfig = getMirrorableSystemCodexConfig(systemConfig)
|
||||
const lastSyncedSystemConfig = readLastSyncedSystemCodexConfigState()
|
||||
const lastSyncedMirrorableSystemConfig =
|
||||
lastSyncedSystemConfig.status === 'legacy'
|
||||
? {
|
||||
status: 'valid' as const,
|
||||
digest: getSystemCodexConfigDigest(
|
||||
getMirrorableSystemCodexConfig(
|
||||
normalizeDeprecatedCodexHookFeatureFlag(lastSyncedSystemConfig.systemConfig)
|
||||
)
|
||||
),
|
||||
unitDigests: getSystemConfigUnitDigestRecord(
|
||||
getSystemConfigUnits(
|
||||
normalizeDeprecatedCodexHookFeatureFlag(lastSyncedSystemConfig.systemConfig)
|
||||
)
|
||||
),
|
||||
needsRewrite: true
|
||||
}
|
||||
: lastSyncedSystemConfig
|
||||
if (!runtimeConfigExists) {
|
||||
// Why: trust blocks reference a hooks.json path, so system-home hook trust
|
||||
// entries are not valid in Orca's runtime CODEX_HOME until install remaps them.
|
||||
writeFileAtomically(runtimeConfigPath, stripRuntimeOwnedTomlSections(systemConfig))
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigest(
|
||||
mirrorableSystemConfig,
|
||||
systemConfigUnitDigests
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!systemConfigExists) {
|
||||
if (lastSyncedMirrorableSystemConfig.status !== 'valid') {
|
||||
return
|
||||
}
|
||||
if (lastSyncedMirrorableSystemConfig.unitDigests === null) {
|
||||
if (lastSyncedMirrorableSystemConfig.needsRewrite) {
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigestOnly(
|
||||
lastSyncedMirrorableSystemConfig.digest
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (lastSyncedMirrorableSystemConfig.needsRewrite) {
|
||||
// Why: a migrated legacy state can still prove the last system content;
|
||||
// scrub legacy raw config without treating temporary absence as deletion.
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigestValue(
|
||||
lastSyncedMirrorableSystemConfig.digest,
|
||||
lastSyncedMirrorableSystemConfig.unitDigests
|
||||
)
|
||||
return
|
||||
}
|
||||
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
|
||||
const nextUnitDigests = getUnitDigestsAfterSystemConfigDeletion(
|
||||
runtimeConfig,
|
||||
lastSyncedMirrorableSystemConfig.unitDigests
|
||||
)
|
||||
const mergedConfig = mergeChangedSystemConfigUnitsIntoRuntime(
|
||||
runtimeConfig,
|
||||
systemConfigUnits,
|
||||
lastSyncedMirrorableSystemConfig.unitDigests
|
||||
)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, mergedConfig)
|
||||
}
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigestValue(
|
||||
getSystemCodexConfigDigest(mirrorableSystemConfig),
|
||||
nextUnitDigests
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (lastSyncedMirrorableSystemConfig.status === 'missing') {
|
||||
// Why: pre-state runtime configs may already contain Codex TUI preference
|
||||
// changes written inside Orca's managed CODEX_HOME. Without a content
|
||||
// baseline, preserve those ordinary prefs and only sync trust state.
|
||||
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
|
||||
const mergedConfig = mergeSystemProjectTrustIntoRuntimeBaseline(runtimeConfig, systemConfig)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, mergedConfig)
|
||||
}
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigest(
|
||||
mirrorableSystemConfig,
|
||||
systemConfigUnitDigests
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (lastSyncedMirrorableSystemConfig.status === 'invalid') {
|
||||
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
|
||||
const mergedConfig = mergeSystemProjectTrustIntoRuntimeBaseline(runtimeConfig, systemConfig)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, mergedConfig)
|
||||
}
|
||||
// Why: corrupt sync state cannot prove the ordinary system settings were
|
||||
// previously mirrored, so recover the baseline without overwriting TUI prefs.
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigest(
|
||||
mirrorableSystemConfig,
|
||||
systemConfigUnitDigests
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (lastSyncedMirrorableSystemConfig.unitDigests === null) {
|
||||
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
|
||||
const currentMirrorableSystemConfigDigest = getSystemCodexConfigDigest(mirrorableSystemConfig)
|
||||
const runtimeMirrorableConfigDigest = getSystemCodexConfigDigest(
|
||||
getMirrorableSystemCodexConfig(runtimeConfig)
|
||||
)
|
||||
if (lastSyncedMirrorableSystemConfig.digest === currentMirrorableSystemConfigDigest) {
|
||||
const mergedConfig = mergeSystemProjectTrustIntoRuntimeBaseline(runtimeConfig, systemConfig)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, mergedConfig)
|
||||
}
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigest(
|
||||
mirrorableSystemConfig,
|
||||
systemConfigUnitDigests
|
||||
)
|
||||
return
|
||||
}
|
||||
if (lastSyncedMirrorableSystemConfig.digest === runtimeMirrorableConfigDigest) {
|
||||
const mergedConfig = mergeChangedSystemConfigUnitsIntoRuntime(
|
||||
runtimeConfig,
|
||||
systemConfigUnits,
|
||||
{}
|
||||
)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, mergedConfig)
|
||||
}
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigest(
|
||||
mirrorableSystemConfig,
|
||||
systemConfigUnitDigests
|
||||
)
|
||||
return
|
||||
}
|
||||
const mergedConfig = mergeSystemProjectTrustIntoRuntimeBaseline(runtimeConfig, systemConfig)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, mergedConfig)
|
||||
}
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigest(
|
||||
mirrorableSystemConfig,
|
||||
systemConfigUnitDigests
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
|
||||
const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig)
|
||||
const mergedConfig = mergeChangedSystemConfigUnitsIntoRuntime(
|
||||
runtimeConfig,
|
||||
systemConfigUnits,
|
||||
lastSyncedMirrorableSystemConfig.unitDigests
|
||||
)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, mergedConfig)
|
||||
}
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigest(mirrorableSystemConfig, systemConfigUnitDigests)
|
||||
}
|
||||
|
||||
function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
|
||||
@@ -109,33 +265,390 @@ function normalizeFeatureSectionLines(lines: string[], start: number, end: numbe
|
||||
}
|
||||
}
|
||||
|
||||
function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string {
|
||||
type SystemConfigUnit = {
|
||||
key: string
|
||||
stateKey: string
|
||||
digest: string
|
||||
block: string
|
||||
kind: 'ordinary' | 'project'
|
||||
placement: 'top-level' | 'section'
|
||||
}
|
||||
|
||||
function getSystemConfigUnits(config: string): SystemConfigUnit[] {
|
||||
const ordinaryTopLevelUnits = getTopLevelTomlUnits(config).map((unit) =>
|
||||
createSystemConfigUnit(`top:${unit.key}`, unit.block, 'ordinary', 'top-level')
|
||||
)
|
||||
const sectionIdentityCounts = new Map<string, number>()
|
||||
const sectionUnits: SystemConfigUnit[] = []
|
||||
for (const section of getTomlSections(config)) {
|
||||
if (isRuntimeHookTrustTomlSection(section.header)) {
|
||||
continue
|
||||
}
|
||||
const identityKey = getTomlSectionIdentityKey(section.header)
|
||||
const occurrence = sectionIdentityCounts.get(identityKey) ?? 0
|
||||
sectionIdentityCounts.set(identityKey, occurrence + 1)
|
||||
sectionUnits.push(
|
||||
createSystemConfigUnit(
|
||||
`section:${identityKey}:${occurrence}`,
|
||||
section.block,
|
||||
isRuntimeProjectTomlSection(section.header) ? 'project' : 'ordinary',
|
||||
'section'
|
||||
)
|
||||
)
|
||||
}
|
||||
return [...ordinaryTopLevelUnits, ...sectionUnits]
|
||||
}
|
||||
|
||||
function createSystemConfigUnit(
|
||||
key: string,
|
||||
block: string,
|
||||
kind: SystemConfigUnit['kind'],
|
||||
placement: SystemConfigUnit['placement']
|
||||
): SystemConfigUnit {
|
||||
return {
|
||||
key,
|
||||
stateKey: getSystemCodexConfigDigest(key),
|
||||
digest: getSystemCodexConfigDigest(normalizeTomlUnitForDigest(block)),
|
||||
block,
|
||||
kind,
|
||||
placement
|
||||
}
|
||||
}
|
||||
|
||||
function getSystemConfigUnitDigestRecord(units: SystemConfigUnit[]): Record<string, string> {
|
||||
return Object.fromEntries(units.map((unit) => [unit.stateKey, unit.digest]))
|
||||
}
|
||||
|
||||
type TomlTopLevelUnit = {
|
||||
key: string
|
||||
block: string
|
||||
}
|
||||
|
||||
function getTopLevelTomlUnits(config: string): TomlTopLevelUnit[] {
|
||||
const lines = config.split('\n')
|
||||
const firstSectionIndex = getTomlSections(config)[0]?.start ?? -1
|
||||
const topLevelLines = firstSectionIndex === -1 ? lines : lines.slice(0, firstSectionIndex)
|
||||
const units: TomlTopLevelUnit[] = []
|
||||
let unitStart = -1
|
||||
let unitKey: string | null = null
|
||||
let multilineState: TomlMultilineState = { basic: false, literal: false }
|
||||
|
||||
for (let index = 0; index < topLevelLines.length; index += 1) {
|
||||
const line = topLevelLines[index] ?? ''
|
||||
const assignmentKey = isInsideTomlMultilineString(multilineState)
|
||||
? null
|
||||
: getTomlAssignmentKey(line)
|
||||
if (assignmentKey !== null) {
|
||||
if (unitStart !== -1 && unitKey !== null) {
|
||||
units.push({
|
||||
key: unitKey,
|
||||
block: topLevelLines.slice(unitStart, index).join('\n')
|
||||
})
|
||||
}
|
||||
unitStart = index
|
||||
unitKey = assignmentKey
|
||||
}
|
||||
multilineState = updateTomlMultilineState(multilineState, line)
|
||||
}
|
||||
|
||||
if (unitStart !== -1 && unitKey !== null) {
|
||||
units.push({
|
||||
key: unitKey,
|
||||
block: topLevelLines.slice(unitStart).join('\n')
|
||||
})
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
function getTomlAssignmentKey(line: string): string | null {
|
||||
let mode: TomlMultilineMode = null
|
||||
let index = 0
|
||||
while (index < line.length) {
|
||||
if (mode === 'basic') {
|
||||
if (line[index] === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (line[index] === '"') {
|
||||
mode = null
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (mode === 'literal') {
|
||||
if (line[index] === "'") {
|
||||
mode = null
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
const char = line[index]
|
||||
if (char === '#') {
|
||||
return null
|
||||
}
|
||||
if (char === '=') {
|
||||
const key = line.slice(0, index).trim()
|
||||
return key.length > 0 ? key : null
|
||||
}
|
||||
if (char === '"') {
|
||||
mode = 'basic'
|
||||
} else if (char === "'") {
|
||||
mode = 'literal'
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeTomlUnitForDigest(block: string): string {
|
||||
let multilineState: TomlMultilineState = { basic: false, literal: false }
|
||||
const lines: string[] = []
|
||||
for (const line of block.split('\n')) {
|
||||
const normalizedLine = isInsideTomlMultilineString(multilineState)
|
||||
? line.trim()
|
||||
: normalizeTomlStructuralLineForDigest(line)
|
||||
if (normalizedLine.length > 0) {
|
||||
lines.push(normalizedLine)
|
||||
}
|
||||
multilineState = updateTomlMultilineState(multilineState, line)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function normalizeTomlStructuralLineForDigest(line: string): string {
|
||||
const header = getTomlTableHeader(line)
|
||||
const table = header ? parseTomlTableHeaderPath(header) : null
|
||||
if (table) {
|
||||
return getCanonicalTomlTableIdentity(table)
|
||||
}
|
||||
return stripTomlLineComment(line)
|
||||
.trim()
|
||||
.replace(/[ \t]*=[ \t]*/, ' = ')
|
||||
}
|
||||
|
||||
function stripTomlLineComment(line: string): string {
|
||||
let mode: TomlMultilineMode = null
|
||||
let index = 0
|
||||
while (index < line.length) {
|
||||
if (mode === 'basic') {
|
||||
if (line[index] === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (line[index] === '"') {
|
||||
mode = null
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (mode === 'literal') {
|
||||
if (line[index] === "'") {
|
||||
mode = null
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
const char = line[index]
|
||||
if (char === '#') {
|
||||
return line.slice(0, index)
|
||||
}
|
||||
if (char === '"') {
|
||||
mode = 'basic'
|
||||
} else if (char === "'") {
|
||||
mode = 'literal'
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
function mergeChangedSystemConfigUnitsIntoRuntime(
|
||||
runtimeConfig: string,
|
||||
systemUnits: SystemConfigUnit[],
|
||||
previousUnitDigests: Record<string, string>
|
||||
): string {
|
||||
const runtimeUnits = getSystemConfigUnits(runtimeConfig)
|
||||
const runtimeStateKeys = new Set(runtimeUnits.map((unit) => unit.stateKey))
|
||||
const systemStateKeys = new Set(systemUnits.map((unit) => unit.stateKey))
|
||||
const changedSystemStateKeys = new Set(
|
||||
systemUnits
|
||||
.filter(
|
||||
(unit) =>
|
||||
previousUnitDigests[unit.stateKey] !== unit.digest ||
|
||||
(unit.kind === 'project' && !runtimeStateKeys.has(unit.stateKey))
|
||||
)
|
||||
.map((unit) => unit.stateKey)
|
||||
)
|
||||
const removedSystemStateKeys = new Set(
|
||||
Object.keys(previousUnitDigests).filter((stateKey) => !systemStateKeys.has(stateKey))
|
||||
)
|
||||
const runtimeHookSections = getTomlSections(runtimeConfig)
|
||||
.filter((section) => isRuntimeHookTrustTomlSection(section.header))
|
||||
.map((section) => section.block)
|
||||
const changedSystemUnitByStateKey = new Map(
|
||||
systemUnits
|
||||
.filter((unit) => changedSystemStateKeys.has(unit.stateKey))
|
||||
.map((unit) => [
|
||||
unit.stateKey,
|
||||
unit.kind === 'project' ? getProjectUnitWithRuntimeOwnedFields(unit, runtimeUnits) : unit
|
||||
])
|
||||
)
|
||||
const consumedChangedSystemStateKeys = new Set<string>()
|
||||
const outputUnits: SystemConfigUnit[] = []
|
||||
for (const runtimeUnit of runtimeUnits) {
|
||||
const changedSystemUnit = changedSystemUnitByStateKey.get(runtimeUnit.stateKey)
|
||||
if (changedSystemUnit) {
|
||||
outputUnits.push(changedSystemUnit)
|
||||
consumedChangedSystemStateKeys.add(runtimeUnit.stateKey)
|
||||
continue
|
||||
}
|
||||
const previousDigest = previousUnitDigests[runtimeUnit.stateKey]
|
||||
const shouldRemoveUnchangedSystemUnit =
|
||||
previousDigest !== undefined &&
|
||||
removedSystemStateKeys.has(runtimeUnit.stateKey) &&
|
||||
previousDigest === runtimeUnit.digest
|
||||
if (!shouldRemoveUnchangedSystemUnit) {
|
||||
outputUnits.push(runtimeUnit)
|
||||
}
|
||||
}
|
||||
outputUnits.push(
|
||||
...[...changedSystemUnitByStateKey]
|
||||
.filter(([stateKey]) => !consumedChangedSystemStateKeys.has(stateKey))
|
||||
.map(([, unit]) => unit)
|
||||
)
|
||||
return joinTomlBlocks([
|
||||
...outputUnits.filter((unit) => unit.placement === 'top-level').map((unit) => unit.block),
|
||||
...outputUnits.filter((unit) => unit.placement === 'section').map((unit) => unit.block),
|
||||
...runtimeHookSections
|
||||
])
|
||||
}
|
||||
|
||||
function getProjectUnitWithRuntimeOwnedFields(
|
||||
systemUnit: SystemConfigUnit,
|
||||
runtimeUnits: SystemConfigUnit[]
|
||||
): SystemConfigUnit {
|
||||
const runtimeUnit = runtimeUnits.find((unit) => unit.stateKey === systemUnit.stateKey)
|
||||
if (!runtimeUnit) {
|
||||
return systemUnit
|
||||
}
|
||||
return {
|
||||
...systemUnit,
|
||||
block: mergeProjectTrustAssignmentIntoRuntimeBlock(runtimeUnit.block, systemUnit.block)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProjectTrustAssignmentIntoRuntimeBlock(
|
||||
runtimeBlock: string,
|
||||
systemBlock: string
|
||||
): string {
|
||||
const systemTrustLine = getProjectTrustLine(systemBlock)
|
||||
if (!systemTrustLine) {
|
||||
return runtimeBlock
|
||||
}
|
||||
|
||||
const lines = runtimeBlock.split('\n')
|
||||
const trustLineIndexes = lines
|
||||
.map((line, index) => (isProjectTrustAssignmentLine(line) ? index : -1))
|
||||
.filter((index) => index !== -1)
|
||||
if (trustLineIndexes.length === 0) {
|
||||
lines.splice(1, 0, systemTrustLine)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
lines[trustLineIndexes[0]!] = systemTrustLine
|
||||
for (const index of trustLineIndexes.slice(1).reverse()) {
|
||||
lines.splice(index, 1)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function getProjectTrustLine(block: string): string | null {
|
||||
return block.split('\n').find((line) => isProjectTrustAssignmentLine(line)) ?? null
|
||||
}
|
||||
|
||||
function isProjectTrustAssignmentLine(line: string): boolean {
|
||||
return /^[ \t]*trust_level[ \t]*=/.test(line) && getProjectTrustLevel(`x = 1\n${line}\n`) !== null
|
||||
}
|
||||
|
||||
function getUnitDigestsAfterSystemConfigDeletion(
|
||||
runtimeConfig: string,
|
||||
previousUnitDigests: Record<string, string>
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
getSystemConfigUnits(runtimeConfig)
|
||||
.map((unit) => [unit.stateKey, previousUnitDigests[unit.stateKey], unit.digest] as const)
|
||||
.filter(
|
||||
([, previousDigest, runtimeDigest]) =>
|
||||
previousDigest !== undefined && previousDigest !== runtimeDigest
|
||||
)
|
||||
.map(([stateKey, previousDigest]) => [stateKey, previousDigest])
|
||||
)
|
||||
}
|
||||
|
||||
function getMirrorableSystemCodexConfig(systemConfig: string): string {
|
||||
return joinTomlBlocks(
|
||||
getSystemConfigUnits(systemConfig)
|
||||
.filter((unit) => unit.kind === 'ordinary')
|
||||
.map((unit) => unit.block)
|
||||
)
|
||||
}
|
||||
|
||||
function mergeSystemProjectTrustIntoRuntimeBaseline(
|
||||
runtimeConfig: string,
|
||||
systemConfig: string
|
||||
): string {
|
||||
const runtimeSections = getTomlSections(runtimeConfig)
|
||||
const runtimeProjectHeaders = new Set(
|
||||
runtimeSections
|
||||
.filter((section) => isRuntimeProjectTomlSection(section.header))
|
||||
.map((section) => getTomlSectionHeaderKey(section.header))
|
||||
.map((section) => getTomlSectionIdentityKey(section.header))
|
||||
)
|
||||
const systemUntrustedProjectHeaders = new Set(
|
||||
getTomlSections(systemConfig)
|
||||
.filter((section) => isRuntimeProjectTomlSection(section.header))
|
||||
.filter((section) => getProjectTrustLevel(section.block) === 'untrusted')
|
||||
.map((section) => getTomlSectionHeaderKey(section.header))
|
||||
const systemProjectSections = getTomlSections(systemConfig).filter((section) =>
|
||||
isRuntimeProjectTomlSection(section.header)
|
||||
)
|
||||
// Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook
|
||||
// trust and project trust are written under Orca's managed CODEX_HOME and
|
||||
// must survive the copy unless the user explicitly revoked project trust in
|
||||
// the system config.
|
||||
const systemProjectSectionsByHeader = new Map(
|
||||
systemProjectSections.map((section) => [getTomlSectionIdentityKey(section.header), section])
|
||||
)
|
||||
const systemProjectSectionsToAppend = systemProjectSections.filter(
|
||||
(section) => !runtimeProjectHeaders.has(getTomlSectionIdentityKey(section.header))
|
||||
)
|
||||
const hasExplicitTrustToMerge = systemProjectSections.some(
|
||||
(section) =>
|
||||
runtimeProjectHeaders.has(getTomlSectionIdentityKey(section.header)) &&
|
||||
getProjectTrustLevel(section.block) !== null
|
||||
)
|
||||
if (systemProjectSectionsToAppend.length === 0 && !hasExplicitTrustToMerge) {
|
||||
return runtimeConfig
|
||||
}
|
||||
|
||||
const systemExplicitTrustProjectHeaders = new Set(
|
||||
systemProjectSections
|
||||
.filter((section) => getProjectTrustLevel(section.block) !== null)
|
||||
.map((section) => getTomlSectionIdentityKey(section.header))
|
||||
)
|
||||
const lines = runtimeConfig.split('\n')
|
||||
const firstSectionIndex = runtimeSections[0]?.start ?? -1
|
||||
const preamble =
|
||||
firstSectionIndex === -1 ? runtimeConfig : lines.slice(0, firstSectionIndex).join('\n')
|
||||
// Why: when the baseline is missing, Orca cannot safely decide whether
|
||||
// ordinary settings changed in system or runtime config. Project trust is
|
||||
// safety-sensitive, so still honor explicit system revocations.
|
||||
return joinTomlBlocks([
|
||||
stripRuntimeOwnedTomlSections(systemConfig, runtimeProjectHeaders),
|
||||
...runtimeSections
|
||||
.filter((section) => isRuntimePreservedTomlSection(section.header))
|
||||
.filter(
|
||||
(section) =>
|
||||
!isRuntimeProjectTomlSection(section.header) ||
|
||||
!systemUntrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header))
|
||||
)
|
||||
.map((section) => section.block)
|
||||
preamble,
|
||||
...runtimeSections.map((section) => {
|
||||
const identityKey = getTomlSectionIdentityKey(section.header)
|
||||
if (
|
||||
!isRuntimeProjectTomlSection(section.header) ||
|
||||
!systemExplicitTrustProjectHeaders.has(identityKey)
|
||||
) {
|
||||
return section.block
|
||||
}
|
||||
const systemSection = systemProjectSectionsByHeader.get(identityKey)
|
||||
return systemSection
|
||||
? mergeProjectTrustAssignmentIntoRuntimeBlock(section.block, systemSection.block)
|
||||
: section.block
|
||||
}),
|
||||
...systemProjectSectionsToAppend.map((section) => section.block)
|
||||
])
|
||||
}
|
||||
|
||||
@@ -167,7 +680,7 @@ function stripRuntimeOwnedTomlSections(
|
||||
.filter(
|
||||
(section) =>
|
||||
!isRuntimeProjectTomlSection(section.header) ||
|
||||
!runtimeProjectHeaders.has(getTomlSectionHeaderKey(section.header)) ||
|
||||
!runtimeProjectHeaders.has(getTomlSectionIdentityKey(section.header)) ||
|
||||
getProjectTrustLevel(section.block) === 'untrusted'
|
||||
)
|
||||
.map((section) => section.block)
|
||||
@@ -212,20 +725,99 @@ function getTomlSections(config: string): TomlSection[] {
|
||||
return sections
|
||||
}
|
||||
|
||||
function isRuntimePreservedTomlSection(header: string): boolean {
|
||||
return isRuntimeHookTrustTomlSection(header) || isRuntimeProjectTomlSection(header)
|
||||
}
|
||||
|
||||
function isRuntimeHookTrustTomlSection(header: string): boolean {
|
||||
return header.trimStart().startsWith('[hooks.state.')
|
||||
const table = parseTomlTableHeaderPath(header)
|
||||
return table?.parts[0] === 'hooks' && table.parts[1] === 'state' && table.parts.length > 2
|
||||
}
|
||||
|
||||
function isRuntimeProjectTomlSection(header: string): boolean {
|
||||
return header.trimStart().startsWith('[projects.')
|
||||
const table = parseTomlTableHeaderPath(header)
|
||||
return table?.parts[0] === 'projects' && table.parts.length > 1
|
||||
}
|
||||
|
||||
function getTomlSectionHeaderKey(header: string): string {
|
||||
return header.trim()
|
||||
function getTomlSectionIdentityKey(header: string): string {
|
||||
const table = parseTomlTableHeaderPath(header)
|
||||
if (!table) {
|
||||
return header.trim()
|
||||
}
|
||||
return getCanonicalTomlTableIdentity(table)
|
||||
}
|
||||
|
||||
function getCanonicalTomlTableIdentity(table: { array: boolean; parts: string[] }): string {
|
||||
if (table.parts[0] === 'projects' && table.parts.length > 1) {
|
||||
return `project:${normalizeRuntimePathForComparison(table.parts.slice(1).join('.'))}`
|
||||
}
|
||||
return JSON.stringify({ array: table.array, parts: table.parts })
|
||||
}
|
||||
|
||||
function parseTomlTableHeaderPath(header: string): { array: boolean; parts: string[] } | null {
|
||||
const trimmed = header.trim()
|
||||
const arrayMatch = /^\[\[\s*(.*?)\s*\]\]$/.exec(trimmed)
|
||||
const tableMatch = /^\[\s*(.*?)\s*\]$/.exec(trimmed)
|
||||
const keyPath = arrayMatch?.[1] ?? tableMatch?.[1]
|
||||
if (keyPath === undefined) {
|
||||
return null
|
||||
}
|
||||
const parts = splitTomlDottedKeyPath(keyPath)
|
||||
.map((part) => parseTomlHeaderKeyPart(part.trim()))
|
||||
.filter((part): part is string => part !== null)
|
||||
return parts.length > 0 ? { array: arrayMatch !== null, parts } : null
|
||||
}
|
||||
|
||||
function splitTomlDottedKeyPath(keyPath: string): string[] {
|
||||
const parts: string[] = []
|
||||
let mode: TomlMultilineMode = null
|
||||
let partStart = 0
|
||||
let index = 0
|
||||
while (index < keyPath.length) {
|
||||
if (mode === 'basic') {
|
||||
if (keyPath[index] === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (keyPath[index] === '"') {
|
||||
mode = null
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (mode === 'literal') {
|
||||
if (keyPath[index] === "'") {
|
||||
mode = null
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (keyPath[index] === '"') {
|
||||
mode = 'basic'
|
||||
} else if (keyPath[index] === "'") {
|
||||
mode = 'literal'
|
||||
} else if (keyPath[index] === '.') {
|
||||
parts.push(keyPath.slice(partStart, index))
|
||||
partStart = index + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
parts.push(keyPath.slice(partStart))
|
||||
return parts
|
||||
}
|
||||
|
||||
function parseTomlHeaderKeyPart(keyPart: string): string | null {
|
||||
if (keyPart.startsWith('"') && keyPart.endsWith('"')) {
|
||||
return parseTomlBasicStringValue(keyPart)
|
||||
}
|
||||
if (keyPart.startsWith("'") && keyPart.endsWith("'")) {
|
||||
return keyPart.slice(1, -1)
|
||||
}
|
||||
return keyPart.length > 0 ? keyPart : null
|
||||
}
|
||||
|
||||
function parseTomlBasicStringValue(value: string): string | null {
|
||||
try {
|
||||
return JSON.parse(value) as string
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectTrustLevel(block: string): 'trusted' | 'untrusted' | null {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import { getOrcaManagedCodexHomePath } from './codex-home-paths'
|
||||
|
||||
type CodexConfigSyncState = {
|
||||
lastMirrorableSystemConfigDigest: string
|
||||
lastSystemConfigUnitDigests: Record<string, string>
|
||||
}
|
||||
|
||||
type CodexConfigSyncStateRead =
|
||||
| {
|
||||
status: 'valid'
|
||||
digest: string
|
||||
unitDigests: Record<string, string> | null
|
||||
needsRewrite: boolean
|
||||
}
|
||||
| {
|
||||
status: 'legacy'
|
||||
systemConfig: string
|
||||
}
|
||||
| {
|
||||
status: 'missing'
|
||||
}
|
||||
| {
|
||||
status: 'invalid'
|
||||
}
|
||||
|
||||
const SYSTEM_CONFIG_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/
|
||||
|
||||
function getCodexConfigSyncStatePath(): string {
|
||||
return join(dirname(getOrcaManagedCodexHomePath()), 'config-sync-state.json')
|
||||
}
|
||||
|
||||
export function getSystemCodexConfigDigest(systemConfig: string): string {
|
||||
return `sha256:${createHash('sha256').update(systemConfig).digest('hex')}`
|
||||
}
|
||||
|
||||
export function readLastSyncedSystemCodexConfigState(): CodexConfigSyncStateRead {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(getCodexConfigSyncStatePath(), 'utf-8')) as unknown
|
||||
const isStateObject = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
const hasLegacySystemConfig = isStateObject && Object.hasOwn(parsed, 'lastSystemConfig')
|
||||
const lastSystemConfig =
|
||||
isStateObject &&
|
||||
typeof (parsed as { lastSystemConfig?: unknown }).lastSystemConfig === 'string'
|
||||
? (parsed as { lastSystemConfig: string }).lastSystemConfig
|
||||
: null
|
||||
const lastMirrorableSystemConfigDigest =
|
||||
isStateObject &&
|
||||
typeof (parsed as { lastMirrorableSystemConfigDigest?: unknown })
|
||||
.lastMirrorableSystemConfigDigest === 'string'
|
||||
? (parsed as CodexConfigSyncState).lastMirrorableSystemConfigDigest
|
||||
: null
|
||||
const legacySystemConfigDigest =
|
||||
isStateObject &&
|
||||
typeof (parsed as { lastSystemConfigDigest?: unknown }).lastSystemConfigDigest === 'string'
|
||||
? (parsed as { lastSystemConfigDigest: string }).lastSystemConfigDigest
|
||||
: null
|
||||
const effectiveSystemConfigDigest = lastMirrorableSystemConfigDigest ?? legacySystemConfigDigest
|
||||
const lastSystemConfigUnitDigests =
|
||||
isStateObject &&
|
||||
isValidDigestRecord(
|
||||
(parsed as { lastSystemConfigUnitDigests?: unknown }).lastSystemConfigUnitDigests
|
||||
)
|
||||
? ((parsed as CodexConfigSyncState).lastSystemConfigUnitDigests ?? null)
|
||||
: null
|
||||
if (
|
||||
effectiveSystemConfigDigest !== null &&
|
||||
SYSTEM_CONFIG_DIGEST_PATTERN.test(effectiveSystemConfigDigest)
|
||||
) {
|
||||
return {
|
||||
status: 'valid',
|
||||
digest: effectiveSystemConfigDigest,
|
||||
unitDigests: lastSystemConfigUnitDigests,
|
||||
needsRewrite:
|
||||
hasLegacySystemConfig ||
|
||||
lastSystemConfigUnitDigests === null ||
|
||||
legacySystemConfigDigest !== null
|
||||
}
|
||||
}
|
||||
if (lastSystemConfig !== null) {
|
||||
return {
|
||||
status: 'legacy',
|
||||
systemConfig: lastSystemConfig
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
? { status: 'missing' }
|
||||
: { status: 'invalid' }
|
||||
}
|
||||
return { status: 'invalid' }
|
||||
}
|
||||
|
||||
function isValidDigestRecord(value: unknown): value is Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
return Object.entries(value).every(
|
||||
([key, digest]) =>
|
||||
SYSTEM_CONFIG_DIGEST_PATTERN.test(key) &&
|
||||
typeof digest === 'string' &&
|
||||
SYSTEM_CONFIG_DIGEST_PATTERN.test(digest)
|
||||
)
|
||||
}
|
||||
|
||||
export function writeLastSyncedMirrorableSystemCodexConfigDigest(
|
||||
mirrorableSystemConfig: string,
|
||||
unitDigests: Record<string, string>
|
||||
): void {
|
||||
writeLastSyncedMirrorableSystemCodexConfigDigestValue(
|
||||
getSystemCodexConfigDigest(mirrorableSystemConfig),
|
||||
unitDigests
|
||||
)
|
||||
}
|
||||
|
||||
export function writeLastSyncedMirrorableSystemCodexConfigDigestValue(
|
||||
digest: string,
|
||||
unitDigests: Record<string, string>
|
||||
): void {
|
||||
if (!SYSTEM_CONFIG_DIGEST_PATTERN.test(digest)) {
|
||||
throw new Error('Invalid Codex config digest')
|
||||
}
|
||||
if (!isValidDigestRecord(unitDigests)) {
|
||||
throw new Error('Invalid Codex config unit digests')
|
||||
}
|
||||
writeConfigSyncState({
|
||||
lastMirrorableSystemConfigDigest: digest,
|
||||
lastSystemConfigUnitDigests: unitDigests
|
||||
})
|
||||
}
|
||||
|
||||
export function writeLastSyncedMirrorableSystemCodexConfigDigestOnly(digest: string): void {
|
||||
if (!SYSTEM_CONFIG_DIGEST_PATTERN.test(digest)) {
|
||||
throw new Error('Invalid Codex config digest')
|
||||
}
|
||||
writeConfigSyncState({ lastMirrorableSystemConfigDigest: digest })
|
||||
}
|
||||
|
||||
function writeConfigSyncState(state: {
|
||||
lastMirrorableSystemConfigDigest: string
|
||||
lastSystemConfigUnitDigests?: Record<string, string>
|
||||
}): void {
|
||||
// Why: config.toml can contain provider credentials; a digest is enough to
|
||||
// detect user edits without persisting a second copy of the config.
|
||||
writeFileAtomically(getCodexConfigSyncStatePath(), `${JSON.stringify(state, null, 2)}\n`, {
|
||||
mode: 0o600
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/* eslint-disable max-lines -- Why: launch-home materialization needs path
|
||||
safety, link/copy fallback, reconciliation, and cleanup in one place so
|
||||
auth-only account isolation cannot drift across platforms. */
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
readdirSync,
|
||||
rmdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { getOrcaManagedCodexHomePath } from './codex-home-paths'
|
||||
|
||||
const LAUNCH_HOME_MARKER = '.orca-managed-launch-home'
|
||||
const LAUNCH_HOME_LINK_MARKERS_DIR = '.orca-launch-home-links'
|
||||
const LAUNCH_HOME_MARKER_VERSION = 1
|
||||
const SHARED_LAUNCH_ENTRY_NAMES = new Set([
|
||||
'config.toml',
|
||||
'hooks.json',
|
||||
'history.jsonl',
|
||||
'sessions',
|
||||
'skills',
|
||||
'plugins',
|
||||
'plugin-state',
|
||||
'profile-v2',
|
||||
'themes',
|
||||
'prompts'
|
||||
])
|
||||
const MUTABLE_SHARED_FILE_ENTRIES = new Set([
|
||||
'config.toml',
|
||||
'hooks.json',
|
||||
'history.jsonl',
|
||||
'profile-v2'
|
||||
])
|
||||
const MUTABLE_SHARED_DIRECTORY_ENTRIES = new Set(['sessions', 'plugin-state', 'profile-v2'])
|
||||
|
||||
type LaunchEntryMarker = {
|
||||
version: number
|
||||
sourcePath: string
|
||||
mode: 'link' | 'copy'
|
||||
targetDigest: string | null
|
||||
sourceDigest: string | null
|
||||
}
|
||||
|
||||
export function getOrcaCodexLaunchHomePath(accountId: string | null): string {
|
||||
const launchHomePath = resolveOrcaCodexLaunchHomePath(accountId, { create: true })
|
||||
mkdirSync(launchHomePath, { recursive: true })
|
||||
return launchHomePath
|
||||
}
|
||||
|
||||
export function ensureOrcaCodexLaunchHome(accountId: string | null): string {
|
||||
const launchHomePath = getOrcaCodexLaunchHomePath(accountId)
|
||||
writeLaunchHomeMarker(launchHomePath, accountId)
|
||||
return launchHomePath
|
||||
}
|
||||
|
||||
export function materializeOrcaCodexLaunchHome(accountId: string | null): string {
|
||||
reconcileMutableLaunchHomeFilesIntoSharedHome()
|
||||
const sharedHomePath = getOrcaManagedCodexHomePath()
|
||||
const launchHomePath = getOrcaCodexLaunchHomePath(accountId)
|
||||
writeLaunchHomeMarker(launchHomePath, accountId)
|
||||
|
||||
const sharedEntries = new Set<string>()
|
||||
for (const entryName of listSharedLaunchEntryNames(sharedHomePath)) {
|
||||
sharedEntries.add(entryName)
|
||||
linkSharedEntryIntoLaunchHome(sharedHomePath, launchHomePath, entryName)
|
||||
}
|
||||
removeStaleLaunchHomeEntries(launchHomePath, sharedHomePath, sharedEntries)
|
||||
return launchHomePath
|
||||
}
|
||||
|
||||
export function removeOrcaCodexLaunchHome(accountId: string): void {
|
||||
const launchHomePath = resolveOrcaCodexLaunchHomePath(accountId, { create: false })
|
||||
if (!existsSync(launchHomePath)) {
|
||||
return
|
||||
}
|
||||
const launchHomeStat = lstatSync(launchHomePath)
|
||||
if (!launchHomeStat.isDirectory() || launchHomeStat.isSymbolicLink()) {
|
||||
console.warn('[codex-home] Refusing to remove unexpected launch-home root:', launchHomePath)
|
||||
return
|
||||
}
|
||||
if (!isMarkedLaunchHomeForAccount(launchHomePath, accountId)) {
|
||||
// Why: older builds could write auth before the launch-home marker existed.
|
||||
// Remove only the deterministic credential file, not an unmarked directory.
|
||||
rmSync(join(launchHomePath, 'auth.json'), { force: true })
|
||||
return
|
||||
}
|
||||
if (!isContainedPath(getOrcaCodexLaunchHostRootPath(), launchHomePath)) {
|
||||
console.warn('[codex-home] Refusing to remove launch home outside host root:', launchHomePath)
|
||||
return
|
||||
}
|
||||
rmSync(launchHomePath, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function getOrcaCodexLaunchHostRootPath(): string {
|
||||
return getOrcaCodexLaunchHostRootPathWithOptions({ create: true })
|
||||
}
|
||||
|
||||
function getOrcaCodexLaunchHostRootPathWithOptions(options: { create: boolean }): string {
|
||||
const rootPath = join(dirname(getOrcaManagedCodexHomePath()), 'launch', 'host')
|
||||
if (options.create) {
|
||||
mkdirSync(rootPath, { recursive: true })
|
||||
}
|
||||
return rootPath
|
||||
}
|
||||
|
||||
function resolveOrcaCodexLaunchHomePath(
|
||||
accountId: string | null,
|
||||
options: { create: boolean }
|
||||
): string {
|
||||
return join(
|
||||
getOrcaCodexLaunchHostRootPathWithOptions(options),
|
||||
getLaunchSelectionSegment(accountId),
|
||||
'home'
|
||||
)
|
||||
}
|
||||
|
||||
function getLaunchSelectionSegment(accountId: string | null): string {
|
||||
if (accountId === null) {
|
||||
return 'system'
|
||||
}
|
||||
return `account-${createHash('sha256').update(accountId).digest('hex').slice(0, 32)}`
|
||||
}
|
||||
|
||||
function listSharedLaunchEntryNames(sharedHomePath: string): string[] {
|
||||
try {
|
||||
return readdirSync(sharedHomePath)
|
||||
.filter((entryName) => SHARED_LAUNCH_ENTRY_NAMES.has(entryName))
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function linkSharedEntryIntoLaunchHome(
|
||||
sharedHomePath: string,
|
||||
launchHomePath: string,
|
||||
entryName: string
|
||||
): void {
|
||||
const sourcePath = join(sharedHomePath, entryName)
|
||||
const targetPath = join(launchHomePath, entryName)
|
||||
const existingMarker = readLaunchEntryMarker(launchHomePath, entryName)
|
||||
reconcileMutableLaunchEntryIfNeeded(sourcePath, targetPath, existingMarker)
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
removeLaunchEntryIfOwned(targetPath, launchHomePath, entryName, sourcePath)
|
||||
return
|
||||
}
|
||||
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
|
||||
markLaunchEntry(launchHomePath, entryName, sourcePath, 'link')
|
||||
return
|
||||
}
|
||||
|
||||
const ownedTarget =
|
||||
existingMarker?.sourcePath === sourcePath && targetExistsForLaunchRemoval(targetPath)
|
||||
if (targetExistsForLaunchRemoval(targetPath) && !ownedTarget) {
|
||||
return
|
||||
}
|
||||
if (ownedTarget) {
|
||||
removeLaunchEntry(targetPath)
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceStat = lstatSync(sourcePath)
|
||||
symlinkSync(
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sourceStat.isDirectory() && process.platform === 'win32' ? 'junction' : undefined
|
||||
)
|
||||
markLaunchEntry(launchHomePath, entryName, sourcePath, 'link')
|
||||
} catch (error) {
|
||||
if (!copyFallbackAllowed(sourcePath, entryName)) {
|
||||
console.warn('[codex-home] Failed to link shared Codex launch entry:', entryName, error)
|
||||
return
|
||||
}
|
||||
try {
|
||||
removeLaunchEntry(targetPath)
|
||||
cpSync(sourcePath, targetPath, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
dereference: true
|
||||
})
|
||||
markLaunchEntry(launchHomePath, entryName, sourcePath, 'copy')
|
||||
} catch {
|
||||
console.warn('[codex-home] Failed to copy shared Codex launch entry:', entryName, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyFallbackAllowed(sourcePath: string, entryName: string): boolean {
|
||||
if (entryName === 'hooks.json') {
|
||||
return false
|
||||
}
|
||||
const sourceStat = lstatSync(sourcePath)
|
||||
return !sourceStat.isDirectory() || !MUTABLE_SHARED_DIRECTORY_ENTRIES.has(entryName)
|
||||
}
|
||||
|
||||
function isContainedPath(rootPath: string, candidatePath: string): boolean {
|
||||
const relativePath = relative(resolve(rootPath), resolve(candidatePath))
|
||||
return (
|
||||
Boolean(relativePath) &&
|
||||
relativePath !== '..' &&
|
||||
!isAbsolute(relativePath) &&
|
||||
!relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
|
||||
)
|
||||
}
|
||||
|
||||
function reconcileMutableLaunchHomeFilesIntoSharedHome(): void {
|
||||
const hostRootPath = getOrcaCodexLaunchHostRootPath()
|
||||
let selectionEntries: string[]
|
||||
try {
|
||||
selectionEntries = readdirSync(hostRootPath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const selectionEntry of selectionEntries.sort()) {
|
||||
const launchHomePath = join(hostRootPath, selectionEntry, 'home')
|
||||
if (!existsSync(join(launchHomePath, LAUNCH_HOME_MARKER))) {
|
||||
continue
|
||||
}
|
||||
reconcileMarkedMutableFiles(launchHomePath)
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileMarkedMutableFiles(launchHomePath: string): void {
|
||||
const markerDir = join(launchHomePath, LAUNCH_HOME_LINK_MARKERS_DIR)
|
||||
let markerFiles: string[]
|
||||
try {
|
||||
markerFiles = readdirSync(markerDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const markerFile of markerFiles.sort()) {
|
||||
const entryName = markerFile.replace(/\.json$/, '')
|
||||
const marker = readLaunchEntryMarker(launchHomePath, entryName)
|
||||
if (!marker || !MUTABLE_SHARED_FILE_ENTRIES.has(entryName)) {
|
||||
continue
|
||||
}
|
||||
reconcileMutableLaunchEntryIfNeeded(marker.sourcePath, join(launchHomePath, entryName), marker)
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileMutableLaunchEntryIfNeeded(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
marker: LaunchEntryMarker | null
|
||||
): void {
|
||||
if (!marker || !MUTABLE_SHARED_FILE_ENTRIES.has(targetPath.split(/[\\/]/).at(-1) ?? '')) {
|
||||
return
|
||||
}
|
||||
if (!targetExistsForLaunchRemoval(targetPath)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (lstatSync(targetPath).isSymbolicLink() || !statSync(targetPath).isFile()) {
|
||||
return
|
||||
}
|
||||
const targetDigest = digestFile(targetPath)
|
||||
if (targetDigest === marker.targetDigest) {
|
||||
return
|
||||
}
|
||||
const sourceDigest = existsSync(sourcePath) ? digestFile(sourcePath) : null
|
||||
if (
|
||||
sourceDigest !== null &&
|
||||
marker.sourceDigest !== null &&
|
||||
sourceDigest !== marker.sourceDigest &&
|
||||
statSync(sourcePath).mtimeMs > statSync(targetPath).mtimeMs
|
||||
) {
|
||||
return
|
||||
}
|
||||
mkdirSync(dirname(sourcePath), { recursive: true })
|
||||
cpSync(targetPath, sourcePath, { force: true })
|
||||
} catch (error) {
|
||||
console.warn('[codex-home] Failed to reconcile launch-home Codex entry:', targetPath, error)
|
||||
}
|
||||
}
|
||||
|
||||
function removeStaleLaunchHomeEntries(
|
||||
launchHomePath: string,
|
||||
sharedHomePath: string,
|
||||
sharedEntries: Set<string>
|
||||
): void {
|
||||
const markerDir = join(launchHomePath, LAUNCH_HOME_LINK_MARKERS_DIR)
|
||||
let markerFiles: string[]
|
||||
try {
|
||||
markerFiles = readdirSync(markerDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const markerFile of markerFiles) {
|
||||
const entryName = markerFile.replace(/\.json$/, '')
|
||||
if (!sharedEntries.has(entryName)) {
|
||||
removeLaunchEntryIfOwned(
|
||||
join(launchHomePath, entryName),
|
||||
launchHomePath,
|
||||
entryName,
|
||||
join(sharedHomePath, entryName)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeLaunchEntryIfOwned(
|
||||
targetPath: string,
|
||||
launchHomePath: string,
|
||||
entryName: string,
|
||||
sourcePath: string
|
||||
): void {
|
||||
const marker = readLaunchEntryMarker(launchHomePath, entryName)
|
||||
if (marker?.sourcePath !== sourcePath) {
|
||||
return
|
||||
}
|
||||
removeLaunchEntry(targetPath)
|
||||
rmSync(getLaunchEntryMarkerPath(launchHomePath, entryName), { force: true })
|
||||
}
|
||||
|
||||
function removeLaunchEntry(targetPath: string): void {
|
||||
if (!targetExistsForLaunchRemoval(targetPath)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const stat = lstatSync(targetPath)
|
||||
if (stat.isSymbolicLink()) {
|
||||
try {
|
||||
unlinkSync(targetPath)
|
||||
} catch (error) {
|
||||
if (process.platform !== 'win32') {
|
||||
throw error
|
||||
}
|
||||
rmdirSync(targetPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
rmSync(targetPath, { recursive: stat.isDirectory(), force: true })
|
||||
} catch (error) {
|
||||
console.warn('[codex-home] Failed to remove owned launch-home entry:', targetPath, error)
|
||||
}
|
||||
}
|
||||
|
||||
function targetExistsForLaunchRemoval(targetPath: string): boolean {
|
||||
try {
|
||||
lstatSync(targetPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function writeLaunchHomeMarker(launchHomePath: string, accountId: string | null): void {
|
||||
writeFileSync(
|
||||
join(launchHomePath, LAUNCH_HOME_MARKER),
|
||||
`${JSON.stringify({ version: LAUNCH_HOME_MARKER_VERSION, accountId }, null, 2)}\n`,
|
||||
{ encoding: 'utf-8', mode: 0o600 }
|
||||
)
|
||||
}
|
||||
|
||||
function isMarkedLaunchHomeForAccount(launchHomePath: string, accountId: string): boolean {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
readFileSync(join(launchHomePath, LAUNCH_HOME_MARKER), 'utf-8')
|
||||
)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return false
|
||||
}
|
||||
const marker = parsed as { version?: unknown; accountId?: unknown }
|
||||
return marker.version === LAUNCH_HOME_MARKER_VERSION && marker.accountId === accountId
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markLaunchEntry(
|
||||
launchHomePath: string,
|
||||
entryName: string,
|
||||
sourcePath: string,
|
||||
mode: 'link' | 'copy'
|
||||
): void {
|
||||
const markerPath = getLaunchEntryMarkerPath(launchHomePath, entryName)
|
||||
mkdirSync(dirname(markerPath), { recursive: true })
|
||||
writeFileSync(
|
||||
markerPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: LAUNCH_HOME_MARKER_VERSION,
|
||||
sourcePath,
|
||||
mode,
|
||||
sourceDigest: digestPathIfFile(sourcePath),
|
||||
targetDigest: digestPathIfFile(join(launchHomePath, entryName))
|
||||
} satisfies LaunchEntryMarker,
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
{ encoding: 'utf-8', mode: 0o600 }
|
||||
)
|
||||
}
|
||||
|
||||
function readLaunchEntryMarker(
|
||||
launchHomePath: string,
|
||||
entryName: string
|
||||
): LaunchEntryMarker | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
readFileSync(getLaunchEntryMarkerPath(launchHomePath, entryName), 'utf-8')
|
||||
)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
const marker = parsed as Partial<LaunchEntryMarker>
|
||||
if (
|
||||
marker.version !== LAUNCH_HOME_MARKER_VERSION ||
|
||||
typeof marker.sourcePath !== 'string' ||
|
||||
(marker.mode !== 'link' && marker.mode !== 'copy')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
version: marker.version,
|
||||
sourcePath: marker.sourcePath,
|
||||
mode: marker.mode,
|
||||
sourceDigest: typeof marker.sourceDigest === 'string' ? marker.sourceDigest : null,
|
||||
targetDigest: typeof marker.targetDigest === 'string' ? marker.targetDigest : null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function targetAlreadyPointsToSource(targetPath: string, sourcePath: string): boolean {
|
||||
try {
|
||||
return (
|
||||
lstatSync(targetPath).isSymbolicLink() &&
|
||||
linkTargetsMatch(readlinkSync(targetPath), sourcePath)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function linkTargetsMatch(actualTarget: string, expectedTarget: string): boolean {
|
||||
if (process.platform !== 'win32') {
|
||||
return actualTarget === expectedTarget
|
||||
}
|
||||
return normalizeWindowsLinkTarget(actualTarget) === normalizeWindowsLinkTarget(expectedTarget)
|
||||
}
|
||||
|
||||
function normalizeWindowsLinkTarget(linkTarget: string): string {
|
||||
return linkTarget.replace(/^\\\\\?\\/, '').toLowerCase()
|
||||
}
|
||||
|
||||
function getLaunchEntryMarkerPath(launchHomePath: string, entryName: string): string {
|
||||
return join(launchHomePath, LAUNCH_HOME_LINK_MARKERS_DIR, `${entryName}.json`)
|
||||
}
|
||||
|
||||
function digestPathIfFile(targetPath: string): string | null {
|
||||
try {
|
||||
if (!statSync(targetPath).isFile()) {
|
||||
return null
|
||||
}
|
||||
return digestFile(targetPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function digestFile(targetPath: string): string {
|
||||
return createHash('sha256').update(readFileSync(targetPath)).digest('hex')
|
||||
}
|
||||
@@ -995,7 +995,7 @@ describe('CodexHookService', () => {
|
||||
expect(runtimeToml).not.toContain(':stop:0:0')
|
||||
})
|
||||
|
||||
it('mirrors system Codex config while preserving runtime hook trust on hook install', () => {
|
||||
it('preserves runtime Codex prefs and hook trust on hook install without a sync baseline', () => {
|
||||
const systemCodexHome = join(tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8')
|
||||
@@ -1019,12 +1019,12 @@ describe('CodexHookService', () => {
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(trustConfig).toContain('model = "system-model"')
|
||||
expect(trustConfig).toContain('model = "runtime-model"')
|
||||
expect(trustConfig).toContain('[hooks.state."runtime-hook"]')
|
||||
expect(trustConfig).toContain('enabled = false')
|
||||
expect(trustConfig).toContain('trusted_hash = "sha256:runtime"')
|
||||
expect(trustConfig).toContain(':permission_request:0:0')
|
||||
expect(trustConfig).not.toContain('model = "runtime-model"')
|
||||
expect(trustConfig).not.toContain('model = "system-model"')
|
||||
})
|
||||
|
||||
it('repairs duplicate managed SessionStart trust tables on restart install', () => {
|
||||
@@ -1112,9 +1112,9 @@ describe('CodexHookService', () => {
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(trustConfig).toContain('model = "system-model"')
|
||||
expect(trustConfig).toContain('model = "runtime-model"')
|
||||
expect(trustConfig).toContain('[projects."/repo"]\ntrust_level = "untrusted"')
|
||||
expect(trustConfig).toContain('[projects."/runtime-only"]\ntrust_level = "trusted"')
|
||||
expect(trustConfig).not.toContain('model = "runtime-model"')
|
||||
expect(trustConfig).not.toContain('model = "system-model"')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user