Files
open-design/apps/daemon/tests/app-config.test.ts
LukasandGitHub c190f264e0 fix(daemon): keep pi's config-directory override across a config save (#8119)
`agentCliEnv` is validated against a per-agent allowlist on every save, and
pi exposed only `PI_BIN`. `PI_CODING_AGENT_DIR` — pi's own override for its
config directory, default `~/.pi/agent` — was therefore dropped on every save
cycle, leaving no way to isolate a pi config but a wrapper script that sets
the variable and is pointed at by `PI_BIN`.

This extends pi's contract by the one key rather than turning the allowlist
into a pass-through: `PI_CODING_AGENT_DIR` is the same kind of entry
`CLAUDE_CONFIG_DIR` and `CODEX_HOME` already are for their CLIs, and nothing
else about pi's environment exposure changes.

Persistence is half the fix, so the tests follow the value the rest of the
way: one cell writes the config, reads it back and hands
`agentCliEnvForAgent`'s projection to `spawnEnvForAgent`, asserting the
variable arrives in the env pi is launched with; two more pin the launch env
directly, including `~` expansion. A neighbouring key that is NOT part of the
contract (`PI_CODING_AGENT_SESSION_DIR`) is asserted to still be dropped.
2026-10-08 04:01:33 +00:00

1424 lines
51 KiB
TypeScript

import http from 'node:http';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import path from 'node:path';
import express from 'express';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} from 'vitest';
import { agentCliEnvForAgent, readAppConfig, writeAppConfig } from '../src/app-config.js';
import { spawnEnvForAgent } from '../src/runtimes/env.js';
import { readOdNextRolloutPolicy } from '../src/strategies/od-next/rollout.js';
import { isLocalSameOrigin } from '../src/origin-validation.js';
// Default telemetry preference applied when an existing config has no
// telemetry block (fresh install, pre-disclosure). See
// `app-config.ts#applyTelemetryDefaults` and `state/config.ts#DEFAULT_CONFIG`
// for the matching client default. Tests that previously expected an
// empty `{}` are now updated to expect this default; tests confirming
// "user opted out → stays opted out" assert on `metrics: false`.
const DEFAULT_TELEMETRY = {
metrics: true,
content: true,
} as const;
describe('app-config', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-appconfig-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
describe('readAppConfig', () => {
it('returns default telemetry when config file does not exist', async () => {
expect(await readAppConfig(dataDir)).toEqual({
telemetry: DEFAULT_TELEMETRY,
});
});
it('returns parsed config from existing file (with default telemetry)', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ onboardingCompleted: true }),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.onboardingCompleted).toBe(true);
expect(cfg.telemetry).toEqual(DEFAULT_TELEMETRY);
});
it('returns default telemetry for corrupted JSON without crashing', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), '{not valid');
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({ telemetry: DEFAULT_TELEMETRY });
});
it('returns default telemetry when file contains a JSON array', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), '[1,2,3]');
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({ telemetry: DEFAULT_TELEMETRY });
});
it('returns default telemetry when file contains a JSON primitive', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), '"hello"');
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({ telemetry: DEFAULT_TELEMETRY });
});
// A file that cannot be parsed at all resets every preference, including
// this one, and that stays true. Singling out `odNextStrategyMode` to
// survive a broken file would opt installations out of a rollout they never
// declined — a broken file is evidence of a broken file, not of an opt-out.
// What does survive is a mode we can see and cannot read; see below.
describe('OD Next opt-out when the mode itself cannot be read', () => {
const cases: Array<[string, unknown]> = [
['a mode this build does not recognise', 'Off'],
['a mode with a typo', 'acive'],
['a non-string mode', 1],
['an object where a mode belongs', { mode: 'off' }],
];
for (const [label, value] of cases) {
it(`reads off, not the default, for ${label}`, async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ odNextStrategyMode: value }),
);
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBe('off');
});
}
it('still reads as unconfigured when there is genuinely no config', async () => {
// The negative control that matters most for this rollout. Failing
// closed is only correct for a value we can see and cannot read; a
// fresh install has made no choice, and turning that into an opt-out
// would cancel the rollout instead of protecting it.
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBeUndefined();
});
it('still reads as unconfigured when the whole file is unparseable', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), '{not valid');
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBeUndefined();
});
it('leaves an explicit null as the deliberate way back to the default', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ odNextStrategyMode: null }),
);
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBeUndefined();
});
it('keeps every readable mode exactly as saved', async () => {
for (const mode of ['off', 'observe', 'active'] as const) {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ odNextStrategyMode: mode }),
);
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBe(mode);
}
});
});
it('filters out unknown keys from stored file', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ agentId: 'claude', rogue: 'value', __proto: 'x' }),
);
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({ agentId: 'claude', telemetry: DEFAULT_TELEMETRY });
expect(cfg).not.toHaveProperty('rogue');
expect(cfg).not.toHaveProperty('__proto');
});
it('filters out invalid scalar values from stored file', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
onboardingCompleted: 'yes',
agentId: 123,
skillId: { id: 'bad' },
designSystemId: ['bad'],
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({ telemetry: DEFAULT_TELEMETRY });
});
it('preserves an explicit telemetry opt-out across reads', async () => {
// Regression guard: the `applyTelemetryDefaults` helper must only
// fill in defaults when the saved config has NO telemetry field.
// A user who explicitly opted out (toggled metrics off in
// Settings → Privacy) keeps `metrics: false`; we never silently
// re-enable it on read.
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toEqual({
metrics: false,
content: false,
artifactManifest: false,
});
});
it('preserves and validates the silent update preference', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ allowSilentUpdates: true }),
);
expect((await readAppConfig(dataDir)).allowSilentUpdates).toBe(true);
expect((await writeAppConfig(dataDir, { allowSilentUpdates: false })).allowSilentUpdates).toBe(false);
expect((await writeAppConfig(dataDir, { allowSilentUpdates: 'yes' })).allowSilentUpdates).toBeUndefined();
});
it('preserves a partial explicit telemetry (metrics on, content off)', async () => {
// The user picked a non-default combo (e.g. metrics on for funnel,
// content off for privacy). We hand back exactly what they saved
// — defaults never overwrite explicit per-field choices.
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ telemetry: { metrics: true, content: false } }),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toEqual({ metrics: true, content: false });
});
it('preserves omitted orbit.templateSkillId from legacy stored config', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
orbit: {
enabled: true,
time: '09:30',
},
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.orbit).toEqual({
enabled: true,
time: '09:30',
});
expect(cfg.orbit).not.toHaveProperty('templateSkillId');
});
it('preserves only the minimal persisted Orbit Workspace identity', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
orbit: {
enabled: true,
time: '09:30',
workspaceScope: {
workspaceId: ' workspace-a ',
workspaceMemberId: ' member-a ',
role: 'owner',
},
},
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.orbit?.workspaceScope).toEqual({
workspaceId: 'workspace-a',
workspaceMemberId: 'member-a',
});
});
it('keeps scoped Orbit identity when an older client updates Orbit without that field', async () => {
await writeAppConfig(dataDir, {
orbit: {
enabled: true,
time: '09:30',
workspaceScope: {
workspaceId: 'workspace-a',
workspaceMemberId: 'member-a',
},
},
});
await writeAppConfig(dataDir, {
orbit: {
enabled: false,
time: '10:15',
},
});
await expect(readAppConfig(dataDir)).resolves.toMatchObject({
orbit: {
enabled: false,
time: '10:15',
workspaceScope: {
workspaceId: 'workspace-a',
workspaceMemberId: 'member-a',
},
},
});
});
it('allows an explicit null to clear a persisted Orbit Workspace identity', async () => {
await writeAppConfig(dataDir, {
orbit: {
enabled: true,
time: '09:30',
workspaceScope: {
workspaceId: 'workspace-a',
workspaceMemberId: 'member-a',
},
},
});
await writeAppConfig(dataDir, {
orbit: {
enabled: false,
time: '10:15',
workspaceScope: null,
},
});
await expect(readAppConfig(dataDir)).resolves.toMatchObject({
orbit: {
enabled: false,
time: '10:15',
workspaceScope: null,
},
});
});
it('falls back to default orbit time for out-of-range stored values', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
orbit: {
enabled: true,
time: '99:99',
},
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.orbit).toEqual({
enabled: true,
time: '08:00',
});
});
it('preserves explicit orbit.templateSkillId null and trimmed string', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
orbit: {
enabled: false,
time: '08:00',
templateSkillId: null,
},
}),
);
let cfg = await readAppConfig(dataDir);
expect(cfg.orbit).toEqual({
enabled: false,
time: '08:00',
templateSkillId: null,
});
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
orbit: {
enabled: true,
time: '10:15',
templateSkillId: ' orbit-general ',
},
}),
);
cfg = await readAppConfig(dataDir);
expect(cfg.orbit).toEqual({
enabled: true,
time: '10:15',
templateSkillId: 'orbit-general',
});
});
});
describe('writeAppConfig', () => {
it('creates data directory if missing', async () => {
const nested = path.join(dataDir, 'sub', 'dir');
await writeAppConfig(nested, { onboardingCompleted: true });
const cfg = await readAppConfig(nested);
expect(cfg.onboardingCompleted).toBe(true);
});
it('only persists ALLOWED_KEYS, filtering unknown keys', async () => {
await writeAppConfig(dataDir, {
onboardingCompleted: true,
unknownKey: 'should be dropped',
agentId: 'claude',
});
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({
onboardingCompleted: true,
agentId: 'claude',
telemetry: DEFAULT_TELEMETRY,
});
expect(cfg).not.toHaveProperty('unknownKey');
});
it('does not persist invalid scalar values', async () => {
await writeAppConfig(dataDir, {
onboardingCompleted: 'yes',
agentId: 123,
skillId: false,
designSystemId: { id: 'bad' },
});
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({ telemetry: DEFAULT_TELEMETRY });
});
it('merges with existing config', async () => {
await writeAppConfig(dataDir, { agentId: 'claude' });
await writeAppConfig(dataDir, { skillId: 'coder' });
const cfg = await readAppConfig(dataDir);
expect(cfg.agentId).toBe('claude');
expect(cfg.skillId).toBe('coder');
});
it('clears a key when null is sent', async () => {
await writeAppConfig(dataDir, { agentId: 'claude', skillId: 'coder' });
await writeAppConfig(dataDir, { agentId: null });
const cfg = await readAppConfig(dataDir);
expect(cfg.agentId).toBeNull();
expect(cfg.skillId).toBe('coder');
});
it('clears agentModels when null is sent', async () => {
await writeAppConfig(dataDir, {
agentModels: { a: { model: 'gpt-4' } },
onboardingCompleted: true,
});
expect((await readAppConfig(dataDir)).agentModels).toBeDefined();
await writeAppConfig(dataDir, { agentModels: null });
const cfg = await readAppConfig(dataDir);
expect(cfg.agentModels).toBeUndefined();
expect(cfg.onboardingCompleted).toBe(true);
});
it('clears agentModels when empty object is sent', async () => {
await writeAppConfig(dataDir, {
agentModels: { a: { model: 'gpt-4' } },
});
await writeAppConfig(dataDir, { agentModels: {} });
const cfg = await readAppConfig(dataDir);
expect(cfg.agentModels).toBeUndefined();
});
it('validates agentModels entries, dropping invalid shapes', async () => {
await writeAppConfig(dataDir, {
agentModels: {
validAgent: { model: 'gpt-4', reasoning: 'fast', serviceTier: 'priority' },
invalidAgent: 'not-an-object',
arrayAgent: [1, 2, 3],
badKeys: { model: 'ok', extra: 42 },
},
});
const cfg = await readAppConfig(dataDir);
expect(cfg.agentModels).toEqual({
validAgent: { model: 'gpt-4', reasoning: 'fast', serviceTier: 'priority' },
});
});
it('drops agentModels entirely when no entries are valid', async () => {
await writeAppConfig(dataDir, {
onboardingCompleted: true,
agentModels: { bad: 'string-value' },
});
const cfg = await readAppConfig(dataDir);
expect(cfg.onboardingCompleted).toBe(true);
expect(cfg.agentModels).toBeUndefined();
});
it('clears retired Gemini agent preferences from stored config', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), JSON.stringify({
agentId: 'gemini',
agentModels: {
gemini: { model: 'gemini-2.5-pro' },
codex: { model: 'gpt-5-codex' },
},
agentCliEnv: {
gemini: { GEMINI_BIN: '~/bin/gemini' },
},
}));
const cfg = await readAppConfig(dataDir);
expect(cfg.agentId).toBeUndefined();
expect(cfg.agentModels).toEqual({
codex: { model: 'gpt-5-codex' },
});
expect(cfg.agentCliEnv).toBeUndefined();
});
it('persists supported per-agent CLI env keys and drops everything else', async () => {
await writeAppConfig(dataDir, {
agentCliEnv: {
claude: {
CLAUDE_CONFIG_DIR: ' ~/.claude-2 ',
ANTHROPIC_BASE_URL: ' https://proxy.example/anthropic ',
ANTHROPIC_API_KEY: ' sk-proxy-anthropic ',
ANTHROPIC_AUTH_TOKEN: ' sk-proxy-token ',
MMD_MODEL_ROUTES_FILE: ' ~/.config/mms/model-routes.json ',
},
codex: {
CODEX_HOME: '~/.codex-alt',
CODEX_BIN: '~/bin/codex-next',
OPENAI_BASE_URL: ' https://proxy.example/openai ',
OPENAI_API_KEY: ' sk-proxy-openai ',
},
amr: {
VELA_BIN: '~/bin/vela',
VELA_API_URL: ' https://custom-amr.example ',
OPEN_DESIGN_AMR_PROFILE: ' local ',
OPENCODE_TEST_HOME: ' ~/.open-design-amr-opencode ',
HOME: 'should-not-persist',
},
opencode: {
OPENCODE_BIN: ' ~/bin/opencode ',
},
'byok-opencode': {
OPENCODE_BIN: ' ~/bin/byok-opencode ',
},
'trae-cli': {
TRAE_CLI_BIN: ' ~/bin/traecli-public ',
},
pi: {
PI_BIN: ' ~/bin/pi ',
PI_CODING_AGENT_DIR: ' ~/.pi-isolated/agent ',
PI_CODING_AGENT_SESSION_DIR: 'not part of the contract yet',
},
__proto__: {
CLAUDE_CONFIG_DIR: 'bad',
},
},
});
const cfg = await readAppConfig(dataDir);
expect(cfg.agentCliEnv).toEqual({
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2', ANTHROPIC_BASE_URL: 'https://proxy.example/anthropic', ANTHROPIC_API_KEY: 'sk-proxy-anthropic', ANTHROPIC_AUTH_TOKEN: 'sk-proxy-token', MMD_MODEL_ROUTES_FILE: '~/.config/mms/model-routes.json' },
codex: { CODEX_HOME: '~/.codex-alt', CODEX_BIN: '~/bin/codex-next', OPENAI_BASE_URL: 'https://proxy.example/openai', OPENAI_API_KEY: 'sk-proxy-openai' },
amr: {
VELA_BIN: '~/bin/vela',
VELA_API_URL: 'https://custom-amr.example',
OPEN_DESIGN_AMR_PROFILE: 'local',
OPENCODE_TEST_HOME: '~/.open-design-amr-opencode',
},
opencode: { OPENCODE_BIN: '~/bin/opencode' },
'trae-cli': { TRAE_CLI_BIN: '~/bin/traecli-public' },
// pi's config-directory override survives the save (#8093); the
// neighbouring session-dir key is not in the contract and still does not.
pi: { PI_BIN: '~/bin/pi', PI_CODING_AGENT_DIR: '~/.pi-isolated/agent' },
});
expect(agentCliEnvForAgent(cfg.agentCliEnv, 'byok-opencode')).toEqual({
OPENCODE_BIN: '~/bin/opencode',
});
});
it('drops legacy standalone Claude and Codex auth keys without base URLs or CLI intent', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), JSON.stringify({
agentCliEnv: {
claude: {
CLAUDE_CONFIG_DIR: '~/.claude-2',
ANTHROPIC_API_KEY: 'sk-legacy-anthropic',
ANTHROPIC_AUTH_TOKEN: 'sk-legacy-token',
},
codex: {
CODEX_HOME: '~/.codex-alt',
CODEX_API_KEY: 'sk-legacy-codex',
OPENAI_API_KEY: 'sk-legacy-openai',
},
},
}));
const cfg = await readAppConfig(dataDir);
expect(cfg.agentCliEnv).toEqual({
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
codex: { CODEX_HOME: '~/.codex-alt' },
});
expect(cfg.agentCliEnvIntent).toBeUndefined();
});
it('keeps explicit CLI API key overrides without requiring base URLs', async () => {
await writeAppConfig(dataDir, {
agentCliEnv: {
claude: { ANTHROPIC_API_KEY: 'sk-anthropic' },
codex: { CODEX_API_KEY: 'sk-codex', OPENAI_API_KEY: 'sk-openai' },
},
agentCliEnvIntent: {
claude: { apiKeyOverride: true },
codex: { apiKeyOverride: true },
},
});
const cfg = await readAppConfig(dataDir);
expect(cfg.agentCliEnv).toEqual({
claude: { ANTHROPIC_API_KEY: 'sk-anthropic' },
codex: { CODEX_API_KEY: 'sk-codex', OPENAI_API_KEY: 'sk-openai' },
});
expect(cfg.agentCliEnvIntent).toEqual({
claude: { apiKeyOverride: true },
codex: { apiKeyOverride: true },
});
});
it('infers CLI API key override intent for explicit agentCliEnv writes', async () => {
await writeAppConfig(dataDir, {
agentCliEnv: {
claude: { ANTHROPIC_AUTH_TOKEN: 'sk-anthropic-token' },
codex: { CODEX_API_KEY: 'sk-codex' },
},
});
const cfg = await readAppConfig(dataDir);
expect(cfg.agentCliEnv).toEqual({
claude: { ANTHROPIC_AUTH_TOKEN: 'sk-anthropic-token' },
codex: { CODEX_API_KEY: 'sk-codex' },
});
expect(cfg.agentCliEnvIntent).toEqual({
claude: { apiKeyOverride: true },
codex: { apiKeyOverride: true },
});
});
it('does not infer CLI API key override intent when reading legacy disk config', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), JSON.stringify({
agentCliEnv: {
codex: { CODEX_API_KEY: 'sk-legacy-codex' },
},
}));
const cfg = await readAppConfig(dataDir);
expect(cfg.agentCliEnv).toBeUndefined();
expect(cfg.agentCliEnvIntent).toBeUndefined();
});
it('carries a saved PI_CODING_AGENT_DIR through a save cycle and into the pi launch env', async () => {
// #8093 end to end. Persistence alone is not the fix: the whole point of
// the key is that pi reads its config from the isolated directory, so
// the assertion follows the value from app-config.json to the env
// spawnEnvForAgent hands the process.
await writeAppConfig(dataDir, {
agentId: 'pi',
agentCliEnv: {
pi: {
PI_BIN: '/Users/test/bin/pi',
PI_CODING_AGENT_DIR: '/Users/test/.pi-isolated/agent',
},
},
});
const cfg = await readAppConfig(dataDir);
const configured = agentCliEnvForAgent(cfg.agentCliEnv, 'pi');
expect(configured.PI_CODING_AGENT_DIR).toBe('/Users/test/.pi-isolated/agent');
const env = spawnEnvForAgent('pi', { PATH: '/usr/bin' }, configured);
expect(env.PI_CODING_AGENT_DIR).toBe('/Users/test/.pi-isolated/agent');
expect(env.PI_BIN).toBe('/Users/test/bin/pi');
});
it('drops orphan CLI env intent entries when the agent env is empty', async () => {
await writeAppConfig(dataDir, {
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
},
agentCliEnvIntent: {
codex: { apiKeyOverride: true },
},
});
const cfg = await readAppConfig(dataDir);
expect(cfg.agentCliEnv).toEqual({
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
});
expect(cfg.agentCliEnvIntent).toBeUndefined();
});
it('drops agentCliEnv entries that collide with Object.prototype keys', async () => {
await writeAppConfig(dataDir, {
agentCliEnv: {
toString: {
CODEX_HOME: '~/.codex-prototype',
},
hasOwnProperty: {
CLAUDE_CONFIG_DIR: '~/.claude-prototype',
},
claude: {
CLAUDE_CONFIG_DIR: '~/.claude-2',
},
},
});
const cfg = await readAppConfig(dataDir);
expect(cfg.agentCliEnv).toEqual({
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
});
});
it('clears agentCliEnv when null or an empty object is sent', async () => {
await writeAppConfig(dataDir, {
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
},
onboardingCompleted: true,
});
expect((await readAppConfig(dataDir)).agentCliEnv).toBeDefined();
await writeAppConfig(dataDir, { agentCliEnv: null });
let cfg = await readAppConfig(dataDir);
expect(cfg.agentCliEnv).toBeUndefined();
expect(cfg.onboardingCompleted).toBe(true);
await writeAppConfig(dataDir, {
agentCliEnv: {
codex: { CODEX_HOME: '~/.codex-alt' },
},
});
await writeAppConfig(dataDir, { agentCliEnv: {} });
cfg = await readAppConfig(dataDir);
expect(cfg.agentCliEnv).toBeUndefined();
});
it('handles corrupted existing file gracefully on write', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), 'CORRUPT');
await writeAppConfig(dataDir, { agentId: 'test' });
const cfg = await readAppConfig(dataDir);
expect(cfg.agentId).toBe('test');
});
});
});
// ---------------------------------------------------------------------------
// HTTP-layer origin guard
// ---------------------------------------------------------------------------
function httpRequest(
url: string,
opts: { method?: string; headers?: Record<string, string>; body?: string },
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const req = http.request(
{
hostname: parsed.hostname,
port: Number(parsed.port),
path: parsed.pathname,
method: opts.method ?? 'GET',
headers: opts.headers ?? {},
},
(res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => resolve({ status: res.statusCode!, body: data }));
},
);
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
describe('app-config disabled lists', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-disabled-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('persists disabledSkills as string array', async () => {
await writeAppConfig(dataDir, { disabledSkills: ['skill-a', 'skill-b'] });
const cfg = await readAppConfig(dataDir);
expect(cfg.disabledSkills).toEqual(['skill-a', 'skill-b']);
});
it('persists disabledDesignSystems as string array', async () => {
await writeAppConfig(dataDir, { disabledDesignSystems: ['ds-x'] });
const cfg = await readAppConfig(dataDir);
expect(cfg.disabledDesignSystems).toEqual(['ds-x']);
});
it('drops disabledSkills when not a string array', async () => {
await writeAppConfig(dataDir, { disabledSkills: 'not-array' } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.disabledSkills).toBeUndefined();
});
it('drops disabledSkills with non-string elements', async () => {
await writeAppConfig(dataDir, { disabledSkills: [1, 2, 3] } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.disabledSkills).toBeUndefined();
});
it('clears disabledSkills when empty array is sent', async () => {
await writeAppConfig(dataDir, { disabledSkills: ['a'] });
await writeAppConfig(dataDir, { disabledSkills: [] });
const cfg = await readAppConfig(dataDir);
expect(cfg.disabledSkills).toEqual([]);
});
});
describe('app-config telemetry prefs', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-telemetry-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('persists installationId as string', async () => {
await writeAppConfig(dataDir, {
installationId: '11111111-2222-3333-4444-555555555555',
});
const cfg = await readAppConfig(dataDir);
expect(cfg.installationId).toBe('11111111-2222-3333-4444-555555555555');
});
it('clears installationId when null is sent', async () => {
await writeAppConfig(dataDir, { installationId: 'abc' });
await writeAppConfig(dataDir, { installationId: null });
const cfg = await readAppConfig(dataDir);
expect(cfg.installationId).toBeNull();
});
it('drops installationId of wrong type', async () => {
await writeAppConfig(dataDir, { installationId: 12345 } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.installationId).toBeUndefined();
});
it('persists privacyDecisionAt as a timestamp', async () => {
await writeAppConfig(dataDir, { privacyDecisionAt: 1778244000000 });
const cfg = await readAppConfig(dataDir);
expect(cfg.privacyDecisionAt).toBe(1778244000000);
});
it('clears privacyDecisionAt when null is sent', async () => {
await writeAppConfig(dataDir, { privacyDecisionAt: 1778244000000 });
await writeAppConfig(dataDir, { privacyDecisionAt: null });
const cfg = await readAppConfig(dataDir);
expect(cfg.privacyDecisionAt).toBeNull();
});
it('drops privacyDecisionAt of wrong type', async () => {
await writeAppConfig(dataDir, { privacyDecisionAt: 'yesterday' } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.privacyDecisionAt).toBeUndefined();
});
it('persists full telemetry prefs', async () => {
await writeAppConfig(dataDir, {
telemetry: { metrics: true, content: true, artifactManifest: false },
});
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toEqual({
metrics: true,
content: true,
artifactManifest: false,
});
});
it('persists partial telemetry prefs and omits absent keys', async () => {
await writeAppConfig(dataDir, { telemetry: { metrics: true } });
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toEqual({ metrics: true });
});
it('drops telemetry inner values that are not booleans', async () => {
await writeAppConfig(dataDir, {
telemetry: {
metrics: 'yes' as any,
content: 1 as any,
artifactManifest: true,
},
} as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toEqual({ artifactManifest: true });
});
it('drops invalid telemetry entirely from the on-disk file (read backfills the default)', async () => {
// Pre-default era: a bad-shaped `telemetry` write got stripped and
// `readAppConfig` returned `cfg.telemetry === undefined`. After the
// 2026-05-22 default-on switch, the same read backfills the
// default — telemetry is never undefined for callers, but the
// user's invalid value still didn't make it to disk. The
// assertion now tracks "what the gate sees" (the default), since
// that's the actually observable behavior; the write-validation
// invariant the test was guarding is still in force (nothing of
// the bad input survives).
await writeAppConfig(dataDir, {
onboardingCompleted: true,
telemetry: { metrics: 'yes' } as any,
} as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.onboardingCompleted).toBe(true);
expect(cfg.telemetry).toEqual(DEFAULT_TELEMETRY);
});
it('drops unknown keys nested inside telemetry', async () => {
await writeAppConfig(dataDir, {
telemetry: { metrics: true, rogue: true } as any,
} as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toEqual({ metrics: true });
expect(cfg.telemetry).not.toHaveProperty('rogue');
});
it('drops telemetry when value is not a plain object (read backfills default)', async () => {
await writeAppConfig(dataDir, { telemetry: [true] } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toEqual(DEFAULT_TELEMETRY);
});
it('clearing telemetry by sending null resets to default (read backfills)', async () => {
// Sending `null` for telemetry erases the on-disk field. Read
// path then backfills the default because the absence of a value
// is treated the same as a fresh install. If the user really
// wants to opt out, the PrivacySection writes
// `{ metrics: false, content: false, ... }` explicitly — that
// shape persists and is preserved across reads (see "preserves
// an explicit telemetry opt-out across reads" above).
await writeAppConfig(dataDir, {
telemetry: { metrics: true, content: true },
});
await writeAppConfig(dataDir, { telemetry: null } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toEqual(DEFAULT_TELEMETRY);
});
it('merges telemetry without disturbing other keys', async () => {
await writeAppConfig(dataDir, {
installationId: 'install-1',
telemetry: { metrics: true },
agentId: 'claude',
});
await writeAppConfig(dataDir, { telemetry: { content: true } });
const cfg = await readAppConfig(dataDir);
expect(cfg.installationId).toBe('install-1');
expect(cfg.agentId).toBe('claude');
// telemetry is replaced (not deep-merged) — matches the agentModels semantics.
expect(cfg.telemetry).toEqual({ content: true });
});
});
describe('app-config projectLocations', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-projectLocations-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('persists valid projectLocations and reads them back', async () => {
const locs = [
{ id: 'ext-one', name: 'One', path: '/tmp/od-loc-one' },
{ id: 'ext-two', name: 'Two', path: '/tmp/od-loc-two' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toEqual(locs);
});
it('normalizes ~/ paths via expandHomePrefix', async () => {
const home = homedir();
const locs = [{ id: 'home-loc', name: 'Home', path: '~/od-projects' }];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.path).toBe(path.join(home, 'od-projects'));
expect(path.isAbsolute(first.path)).toBe(true);
});
it('drops relative paths that cannot be resolved to absolute', async () => {
const locs = [
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
{ id: 'bad-relative', name: 'Bad Rel', path: './relative/path' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.id).toBe('good');
});
it('drops entries without a string path', async () => {
const locs = [
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
{ id: 'no-path', name: 'No Path' },
];
await writeAppConfig(dataDir, { projectLocations: locs as any });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.id).toBe('good');
});
it('deduplicates paths (case-sensitive on unix)', async () => {
const locs = [
{ id: 'first', name: 'First', path: '/tmp/od-same' },
{ id: 'second', name: 'Second', path: '/tmp/od-same' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
// Single canonical entry, second deduplicated
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.path).toBe(path.normalize('/tmp/od-same'));
});
it('deduplicates by resolved path after normalization', async () => {
const locs = [
{ id: 'first', name: 'First', path: '/tmp/od-dup/../od-dup' },
{ id: 'second', name: 'Second', path: '/tmp/od-dup' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
const first = cfg.projectLocations![0]!;
expect(first.path).toBe(path.normalize('/tmp/od-dup'));
});
it('rejects reserved id "default" and falls back to auto-generated id', async () => {
const locs = [{ id: 'default', name: 'Hijack', path: '/tmp/od-hijack' }];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(1);
// The stored id must NOT be 'default'
const first = cfg.projectLocations![0]!;
expect(first.id).not.toBe('default');
// The auto-generated id follows the hash-backed base64url pattern
expect(first.id).toMatch(/^loc_[A-Za-z0-9_-]{1,16}$/);
expect(first.path).toBe(path.normalize('/tmp/od-hijack'));
});
it('generates distinct ids for sibling paths with long shared prefixes', async () => {
const locs = [
{ path: '/tmp/open-design-project-locations/shared-prefix-one' },
{ path: '/tmp/open-design-project-locations/shared-prefix-two' },
];
await writeAppConfig(dataDir, { projectLocations: locs });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(2);
const ids = cfg.projectLocations!.map((location) => location.id);
expect(new Set(ids).size).toBe(2);
expect(ids.every((id) => /^loc_[A-Za-z0-9_-]{1,16}$/.test(id))).toBe(true);
});
it('persists a defaultProjectLocationId preference', async () => {
await writeAppConfig(dataDir, {
projectLocations: [{ id: 'external-default', name: 'External', path: '/tmp/od-default-location' }],
defaultProjectLocationId: 'external-default',
});
const cfg = await readAppConfig(dataDir);
expect(cfg.defaultProjectLocationId).toBe('external-default');
});
it('normalizes invalid defaultProjectLocationId values', async () => {
await writeAppConfig(dataDir, { defaultProjectLocationId: '../bad' });
let cfg = await readAppConfig(dataDir);
expect(cfg.defaultProjectLocationId).toBe('default');
await writeAppConfig(dataDir, { defaultProjectLocationId: null });
cfg = await readAppConfig(dataDir);
expect(cfg.defaultProjectLocationId).toBeNull();
});
it('drops invalid scalar projectLocations (not an array)', async () => {
await writeAppConfig(dataDir, { projectLocations: 'not-array' } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toBeUndefined();
});
it('clears projectLocations when empty array is sent', async () => {
await writeAppConfig(dataDir, {
projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }],
onboardingCompleted: true,
});
expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1);
await writeAppConfig(dataDir, { projectLocations: [] });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toEqual([]);
expect(cfg.onboardingCompleted).toBe(true);
});
it('clears projectLocations when null is sent', async () => {
await writeAppConfig(dataDir, {
projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }],
onboardingCompleted: true,
});
expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1);
await writeAppConfig(dataDir, { projectLocations: null as any });
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toBeUndefined();
expect(cfg.onboardingCompleted).toBe(true);
});
it('validates projectLocations on read (filters corrupted stored data)', async () => {
// Write raw JSON with invalid entries
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
projectLocations: [
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
{ id: 'bad-relative', name: 'Bad', path: 'relative' },
{ id: 'no-path', name: 'No Path' },
'not-an-object',
null,
{ id: 'good2', name: 'Dup Path', path: '/tmp/od-good' },
{ id: 'default', name: 'Reserved', path: '/tmp/od-reserved' },
],
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.projectLocations).toHaveLength(2);
const ids = cfg.projectLocations!.map((l) => l.id);
expect(ids).not.toContain('default');
expect(ids).not.toContain('bad-relative');
expect(ids).not.toContain('no-path');
});
});
describe('app-config recentLinkedDirs', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-recentdirs-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('persists a clean list of working directories', async () => {
const cfg = await writeAppConfig(dataDir, {
recentLinkedDirs: ['/home/a/project', '/home/b/site'],
});
expect(cfg.recentLinkedDirs).toEqual(['/home/a/project', '/home/b/site']);
expect((await readAppConfig(dataDir)).recentLinkedDirs).toEqual([
'/home/a/project',
'/home/b/site',
]);
});
it('trims, drops empty entries, and de-dupes preserving order', async () => {
const cfg = await writeAppConfig(dataDir, {
recentLinkedDirs: [' /home/a ', '', '/home/a', ' ', '/home/b'],
});
expect(cfg.recentLinkedDirs).toEqual(['/home/a', '/home/b']);
});
it('caps the list at RECENT_LINKED_DIRS_MAX entries', async () => {
const many = Array.from({ length: 25 }, (_, i) => `/home/dir${i}`);
const cfg = await writeAppConfig(dataDir, { recentLinkedDirs: many });
expect(cfg.recentLinkedDirs).toEqual(many.slice(0, 5));
});
it('ignores a non-array value without touching other prefs', async () => {
await writeAppConfig(dataDir, { onboardingCompleted: true });
const cfg = await writeAppConfig(dataDir, {
recentLinkedDirs: 'not-an-array' as unknown as string[],
});
expect(cfg.recentLinkedDirs).toBeUndefined();
expect(cfg.onboardingCompleted).toBe(true);
});
it('updates recentLinkedDirs without clobbering unrelated prefs', async () => {
await writeAppConfig(dataDir, { skillId: 'keep-me' });
const cfg = await writeAppConfig(dataDir, {
recentLinkedDirs: ['/home/a'],
});
expect(cfg.recentLinkedDirs).toEqual(['/home/a']);
expect(cfg.skillId).toBe('keep-me');
});
});
describe('app-config origin guard', () => {
let server: http.Server;
let port: number;
let baseUrl: string;
beforeAll(
() =>
new Promise<void>((resolve) => {
const app = express();
app.use(express.json());
app.get('/api/app-config', (req, res) => {
if (!isLocalSameOrigin(req, port)) {
return res
.status(403)
.json({ error: 'cross-origin request rejected' });
}
res.json({ config: {} });
});
app.put('/api/app-config', (req, res) => {
if (!isLocalSameOrigin(req, port)) {
return res
.status(403)
.json({ error: 'cross-origin request rejected' });
}
res.json({ config: req.body });
});
server = app.listen(0, '127.0.0.1', () => {
port = (server.address() as { port: number }).port;
baseUrl = `http://127.0.0.1:${port}`;
resolve();
});
}),
);
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
it('allows GET from same-origin (no Origin header)', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: { Host: `127.0.0.1:${port}` },
});
expect(res.status).toBe(200);
});
it('allows PUT from same-origin', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Host: `127.0.0.1:${port}`,
Origin: `http://127.0.0.1:${port}`,
},
body: JSON.stringify({ onboardingCompleted: true }),
});
expect(res.status).toBe(200);
});
it('rejects GET with cross-origin Origin header', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: {
Host: `127.0.0.1:${port}`,
Origin: 'https://evil.com',
},
});
expect(res.status).toBe(403);
});
it('rejects PUT with cross-origin Origin header', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Host: `127.0.0.1:${port}`,
Origin: 'https://evil.com',
},
body: JSON.stringify({ agentId: 'hacked' }),
});
expect(res.status).toBe(403);
});
it('rejects request with wrong Host header', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: { Host: 'evil.com:9999' },
});
expect(res.status).toBe(403);
});
it('rejects no-Origin requests that only match configured deployment hosts', async () => {
process.env.OD_ALLOWED_ORIGINS = 'https://od.example.com';
try {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: { Host: 'od.example.com' },
});
expect(res.status).toBe(403);
} finally {
delete process.env.OD_ALLOWED_ORIGINS;
}
});
it('still rejects non-loopback Origin', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: {
Host: `127.0.0.1:${port}`,
Origin: 'https://evil.com',
},
});
expect(res.status).toBe(403);
});
});
describe('app-config odNextStrategyMode', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-next-mode-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('is absent until the installation chooses', async () => {
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBeUndefined();
});
it('persists each of the three modes', async () => {
for (const mode of ['active', 'observe', 'off'] as const) {
await writeAppConfig(dataDir, { odNextStrategyMode: mode });
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBe(mode);
}
});
it('refuses a write that is not a mode, and keeps the previous choice', async () => {
// A typo must not be able to switch the installation off while the CLI
// prints success. Every other preference here degrades to its default when
// it cannot store a value; this one decides whether OD Next runs, so a
// dropped value would be indistinguishable from an opt-out nobody asked
// for. It fails loudly instead.
await writeAppConfig(dataDir, { odNextStrategyMode: 'active' });
for (const bad of ['acive', '', 'ACTIVE', true, 1, [], {}]) {
await expect(writeAppConfig(dataDir, { odNextStrategyMode: bad } as never))
.rejects.toMatchObject({ code: 'INVALID_APP_CONFIG_VALUE' });
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBe('active');
}
});
it('does not reject the neighbouring keys of a refused write', async () => {
// The whole write is refused, so a rejected body must not half-apply.
await writeAppConfig(dataDir, { agentId: 'codex' });
await expect(writeAppConfig(dataDir, {
agentId: 'claude',
odNextStrategyMode: 'acive',
} as never)).rejects.toMatchObject({ code: 'INVALID_APP_CONFIG_VALUE' });
expect((await readAppConfig(dataDir)).agentId).toBe('codex');
});
it('reports an unreadable config as an error, not as an unconfigured one', async () => {
// The premise the rollout wiring depends on: `readAppConfig` answers `{}`
// only for the states that legitimately mean "nothing configured", and
// surfaces a real I/O fault instead of flattening it into the same answer.
// A directory where the file belongs is EISDIR for any user, unlike a
// chmod that a root test runner would walk straight through.
await mkdir(path.join(dataDir, 'app-config.json'), { recursive: true });
await expect(readAppConfig(dataDir)).rejects.toThrow();
});
it('reads a corrupted stored value as off rather than throwing', async () => {
// The read path stays fail-soft — a hand-edited or truncated file must not
// take the daemon down, and the rest of the config still comes through.
//
// What changed is which answer is safe. This assertion used to read
// `toBeUndefined()`, on the reasoning that "unconfigured is the safe answer
// (`off`)". That reasoning was true only while the default was `off`. With
// the default flipped, unconfigured is `active`, so the same fail-soft drop
// would hand OD Next to an installation whose stored choice we just failed
// to read. The mode now fails closed on its own; every other key keeps the
// ordinary fail-soft behaviour.
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ agentId: 'codex', odNextStrategyMode: 'acive' }),
'utf8',
);
const cfg = await readAppConfig(dataDir);
expect(cfg.odNextStrategyMode).toBe('off');
expect(cfg.agentId).toBe('codex');
});
it('keeps an opt-out through the whole chain when the saved mode goes unreadable', async () => {
// The join is where this guarantee actually lives, so assert it across the
// join rather than in either half. `readAppConfig` reads the file and
// `readOdNextRolloutPolicy` decides the mode; a mode that read as absent in
// the first would resolve to `active` in the second, and nothing in between
// would notice.
await writeAppConfig(dataDir, { odNextStrategyMode: 'off' });
expect(readOdNextRolloutPolicy({}, await readAppConfig(dataDir)))
.toMatchObject({ requestedMode: 'off', requestedModeSource: 'app_config' });
// Same installation, same user, the mode rewritten to something this build
// cannot read — a hand edit, or a value some other version writes.
const saved = JSON.parse(
await readFile(path.join(dataDir, 'app-config.json'), 'utf8'),
) as Record<string, unknown>;
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ ...saved, odNextStrategyMode: 'OFF' }),
'utf8',
);
expect(readOdNextRolloutPolicy({}, await readAppConfig(dataDir)))
.toMatchObject({ requestedMode: 'off' });
// And the negative control on the same chain: a fresh installation with no
// file must still reach the new default, or this guard has swallowed the
// rollout it was meant to protect.
const fresh = await mkdtemp(path.join(tmpdir(), 'od-appconfig-fresh-'));
try {
expect(readOdNextRolloutPolicy({}, await readAppConfig(fresh)))
.toMatchObject({ requestedMode: 'active', requestedModeSource: 'default' });
} finally {
await rm(fresh, { recursive: true, force: true });
}
});
it('opts back out when the key is cleared', async () => {
await writeAppConfig(dataDir, { odNextStrategyMode: 'active' });
await writeAppConfig(dataDir, { odNextStrategyMode: null });
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBeUndefined();
});
it('survives a later write that does not mention it', async () => {
// The web pushes an explicit key list that has no reason to carry this
// one. Saving an unrelated Settings change must not silently opt the
// installation back out from under the person who configured it.
await writeAppConfig(dataDir, { odNextStrategyMode: 'active' });
await writeAppConfig(dataDir, { agentId: 'claude', designSystemId: 'stripe' });
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBe('active');
});
it('does not disturb neighbouring preferences', async () => {
await writeAppConfig(dataDir, { agentId: 'codex', onboardingCompleted: true });
await writeAppConfig(dataDir, { odNextStrategyMode: 'active' });
const cfg = await readAppConfig(dataDir);
expect(cfg).toMatchObject({
agentId: 'codex',
onboardingCompleted: true,
odNextStrategyMode: 'active',
});
});
});