mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
# Conflicts: # apps/daemon/src/claude-diagnostics.ts # apps/daemon/tests/app-config.test.ts
1210 lines
42 KiB
TypeScript
1210 lines
42 KiB
TypeScript
import http from 'node:http';
|
|
import { mkdtemp, 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,
|
|
vi,
|
|
} from 'vitest';
|
|
|
|
import { AppConfigValidationError, readAppConfig, writeAppConfig } from '../src/app-config.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,
|
|
artifactManifest: false,
|
|
} 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 });
|
|
});
|
|
|
|
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 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('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' },
|
|
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' },
|
|
});
|
|
});
|
|
|
|
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('persists supported per-agent CLI env keys and rejects unknown keys', async () => {
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
claude: {
|
|
CLAUDE_CONFIG_DIR: ' ~/.claude-2 ',
|
|
ANTHROPIC_API_KEY: ' sk-proxy-anthropic ',
|
|
},
|
|
codex: {
|
|
CODEX_HOME: '~/.codex-alt',
|
|
CODEX_BIN: '~/bin/codex-next',
|
|
OPENAI_API_KEY: ' sk-proxy-openai ',
|
|
},
|
|
amr: {
|
|
VELA_BIN: '~/bin/vela',
|
|
OPEN_DESIGN_AMR_PROFILE: ' local ',
|
|
OPENCODE_TEST_HOME: ' ~/.open-design-amr-opencode ',
|
|
},
|
|
'trae-cli': {
|
|
TRAE_CLI_BIN: ' ~/bin/traecli-public ',
|
|
},
|
|
__proto__: {
|
|
CLAUDE_CONFIG_DIR: 'bad',
|
|
},
|
|
},
|
|
});
|
|
|
|
const cfg = await readAppConfig(dataDir);
|
|
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2', ANTHROPIC_API_KEY: 'sk-proxy-anthropic' },
|
|
codex: { CODEX_HOME: '~/.codex-alt', CODEX_BIN: '~/bin/codex-next', OPENAI_API_KEY: 'sk-proxy-openai' },
|
|
amr: {
|
|
VELA_BIN: '~/bin/vela',
|
|
OPEN_DESIGN_AMR_PROFILE: 'local',
|
|
OPENCODE_TEST_HOME: '~/.open-design-amr-opencode',
|
|
},
|
|
'trae-cli': { TRAE_CLI_BIN: '~/bin/traecli-public' },
|
|
});
|
|
});
|
|
|
|
it('rejects agentCliEnv entries that collide with Object.prototype keys', async () => {
|
|
// `toString` and `hasOwnProperty` are not valid agent ids, so the
|
|
// strict write path should reject the entire payload rather than
|
|
// silently dropping the prototype-polluting entries.
|
|
await expect(
|
|
writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
toString: {
|
|
CODEX_HOME: '~/.codex-prototype',
|
|
},
|
|
hasOwnProperty: {
|
|
CLAUDE_CONFIG_DIR: '~/.claude-prototype',
|
|
},
|
|
claude: {
|
|
CLAUDE_CONFIG_DIR: '~/.claude-2',
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toThrow(/unknown agent/);
|
|
});
|
|
|
|
it('rejects __proto__ and constructor agent ids on write without clearing prior config', async () => {
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
},
|
|
});
|
|
|
|
// JSON.parse preserves __proto__ as an own enumerable key, while the
|
|
// object literal `{ __proto__: ... }` sets the prototype instead.
|
|
// Express's body parser uses JSON.parse, so we mirror that here.
|
|
const protoPayload = JSON.parse(
|
|
'{"agentCliEnv":{"__proto__":{"CODEBUDDY_API_KEY":"x"}}}',
|
|
);
|
|
await expect(
|
|
writeAppConfig(dataDir, protoPayload),
|
|
).rejects.toThrow(/reserved agent id "__proto__"/);
|
|
const cfg1 = await readAppConfig(dataDir);
|
|
expect(cfg1.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
});
|
|
|
|
const ctorPayload = JSON.parse(
|
|
'{"agentCliEnv":{"constructor":{"CODEBUDDY_API_KEY":"x"}}}',
|
|
);
|
|
await expect(
|
|
writeAppConfig(dataDir, ctorPayload),
|
|
).rejects.toThrow(/reserved agent id "constructor"/);
|
|
const cfg2 = await readAppConfig(dataDir);
|
|
expect(cfg2.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
});
|
|
});
|
|
|
|
it('rejects CODEBUDDY_INTERNET_ENVIRONMENT with invalid enum value on write', async () => {
|
|
await expect(
|
|
writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: {
|
|
CODEBUDDY_INTERNET_ENVIRONMENT: 'internel',
|
|
CODEBUDDY_API_KEY: 'cb-test-key',
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toThrow(/CODEBUDDY_INTERNET_ENVIRONMENT/);
|
|
});
|
|
|
|
it('rejects non-string CODEBUDDY_INTERNET_ENVIRONMENT on write without clearing prior agentCliEnv', async () => {
|
|
// Save a valid agentCliEnv first.
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
},
|
|
});
|
|
// A non-string value (e.g. 42) for an allowlisted key should throw
|
|
// on the strict write path, not silently drop and clear the block.
|
|
await expect(
|
|
writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: {
|
|
CODEBUDDY_INTERNET_ENVIRONMENT: 42,
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toThrow(/CODEBUDDY_INTERNET_ENVIRONMENT.*expected string/);
|
|
// Previously saved config should remain intact.
|
|
const cfg = await readAppConfig(dataDir);
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
});
|
|
});
|
|
|
|
it('rejects non-object agent env block on write without clearing prior agentCliEnv', async () => {
|
|
// A string or array value for an allowlisted agent should throw
|
|
// on the strict write path, not silently drop and clear the block.
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
},
|
|
});
|
|
await expect(
|
|
writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: 'bad',
|
|
},
|
|
}),
|
|
).rejects.toThrow(/codebuddy.*expected object/);
|
|
const cfg = await readAppConfig(dataDir);
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
});
|
|
});
|
|
|
|
it('rejects non-object top-level agentCliEnv on write without clearing prior config', async () => {
|
|
// A primitive or array top-level agentCliEnv should throw on the
|
|
// strict write path, not silently drop and clear the block.
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
},
|
|
});
|
|
await expect(
|
|
writeAppConfig(dataDir, { agentCliEnv: 'bad' }),
|
|
).rejects.toThrow(/agentCliEnv.*expected object/);
|
|
const cfg1 = await readAppConfig(dataDir);
|
|
expect(cfg1.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
});
|
|
|
|
// Same for array top-level.
|
|
await expect(
|
|
writeAppConfig(dataDir, { agentCliEnv: [] }),
|
|
).rejects.toThrow(/agentCliEnv.*expected object/);
|
|
const cfg2 = await readAppConfig(dataDir);
|
|
expect(cfg2.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
});
|
|
});
|
|
|
|
it('tolerates persisted invalid CODEBUDDY_INTERNET_ENVIRONMENT on read', async () => {
|
|
// Manually write a config file with an invalid enum value (simulating
|
|
// a stale or hand-edited config). readAppConfig should not throw;
|
|
// instead it should drop the invalid value and keep the rest.
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
await writeFile(
|
|
path.join(dataDir, 'app-config.json'),
|
|
JSON.stringify({
|
|
agentCliEnv: {
|
|
codebuddy: {
|
|
CODEBUDDY_INTERNET_ENVIRONMENT: 'internel',
|
|
CODEBUDDY_API_KEY: 'cb-test-key',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const cfg = await readAppConfig(dataDir);
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
});
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('internel'),
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('persists valid CODEBUDDY_INTERNET_ENVIRONMENT enum values', async () => {
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: {
|
|
CODEBUDDY_INTERNET_ENVIRONMENT: 'internal',
|
|
},
|
|
},
|
|
});
|
|
let cfg = await readAppConfig(dataDir);
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_INTERNET_ENVIRONMENT: 'internal' },
|
|
});
|
|
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: {
|
|
CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa',
|
|
},
|
|
},
|
|
});
|
|
cfg = await readAppConfig(dataDir);
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa' },
|
|
});
|
|
});
|
|
|
|
it('rejects CODEBUDDY_INTERNET_ENVIRONMENT=public on write (undocumented value)', async () => {
|
|
// "public" is not a documented CLI value — the international/default
|
|
// path is represented by leaving the variable unset (empty string).
|
|
await expect(
|
|
writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: {
|
|
CODEBUDDY_INTERNET_ENVIRONMENT: 'public',
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toThrow(/CODEBUDDY_INTERNET_ENVIRONMENT/);
|
|
});
|
|
|
|
it('persists empty-string CODEBUDDY_INTERNET_ENVIRONMENT as explicit unset marker', async () => {
|
|
// When the user selects "International (default)" in Settings, the UI
|
|
// persists an empty string as an "unset" marker. This must survive
|
|
// the write path so spawnEnvForAgent can detect it and delete the
|
|
// inherited value.
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: {
|
|
CODEBUDDY_INTERNET_ENVIRONMENT: '',
|
|
CODEBUDDY_API_KEY: 'cb-test-key',
|
|
},
|
|
},
|
|
});
|
|
const cfg = await readAppConfig(dataDir);
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_INTERNET_ENVIRONMENT: '', CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
});
|
|
});
|
|
|
|
it('persists empty-string CODEBUDDY_INTERNET_ENVIRONMENT without other keys', async () => {
|
|
// The empty-string marker should be persisted even when it's the only
|
|
// value for the codebuddy agent (no API key alongside it).
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: {
|
|
CODEBUDDY_INTERNET_ENVIRONMENT: '',
|
|
},
|
|
},
|
|
});
|
|
const cfg = await readAppConfig(dataDir);
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_INTERNET_ENVIRONMENT: '' },
|
|
});
|
|
});
|
|
|
|
it('rejects unknown agent id on write without clearing prior agentCliEnv', async () => {
|
|
// A typoed agent id like "codebudy" should throw on the strict write
|
|
// path, not silently drop and clear the existing agentCliEnv block.
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
},
|
|
});
|
|
await expect(
|
|
writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebudy: { CODEBUDDY_API_KEY: 'x' },
|
|
},
|
|
}),
|
|
).rejects.toThrow(/unknown agent "codebudy"/);
|
|
const cfg = await readAppConfig(dataDir);
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
});
|
|
});
|
|
|
|
it('rejects unknown env key on write without clearing prior agentCliEnv', async () => {
|
|
// A typoed env key like "CODEBUDDY_APIKYE" should throw on the strict
|
|
// write path, not silently drop and clear the existing agentCliEnv block.
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
},
|
|
});
|
|
await expect(
|
|
writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
codebuddy: { CODEBUDDY_APIKYE: 'x' },
|
|
},
|
|
}),
|
|
).rejects.toThrow(/CODEBUDDY_APIKYE.*unknown env key/);
|
|
const cfg = await readAppConfig(dataDir);
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' },
|
|
});
|
|
});
|
|
|
|
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 validation error type', () => {
|
|
it('invalid agentCliEnv writes throw AppConfigValidationError (not plain Error)', async () => {
|
|
const dataDir = await mkdtemp(path.join(tmpdir(), 'od-valtype-'));
|
|
try {
|
|
// Save valid config first.
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: { codebuddy: { CODEBUDDY_API_KEY: 'cb-test-key' } },
|
|
});
|
|
// A bad top-level shape should throw AppConfigValidationError.
|
|
try {
|
|
await writeAppConfig(dataDir, { agentCliEnv: 'bad' });
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(AppConfigValidationError);
|
|
expect((err as Error).message).toContain('agentCliEnv');
|
|
}
|
|
// An invalid enum value should also throw AppConfigValidationError.
|
|
try {
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: { codebuddy: { CODEBUDDY_INTERNET_ENVIRONMENT: 'internel' } },
|
|
});
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(AppConfigValidationError);
|
|
}
|
|
} finally {
|
|
await rm(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('AppConfigValidationError instances are distinguishable from generic errors', () => {
|
|
const valErr = new AppConfigValidationError('test');
|
|
expect(valErr.name).toBe('AppConfigValidationError');
|
|
expect(valErr).toBeInstanceOf(Error);
|
|
// A plain Error should NOT match.
|
|
expect(new Error('test')).not.toBeInstanceOf(AppConfigValidationError);
|
|
});
|
|
});
|
|
|
|
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 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);
|
|
});
|
|
});
|