mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat: add Orbit activity summaries * fix(orbit): make runs navigable while agent continues * fix(web): widen minimum chat panel * feat: support Orbit template selection * fix(daemon): avoid bogus skill side-file preflight * fix(web): collapse orbit artifact project cards * fix(web): preserve orbit project card titles * fix: improve Orbit run daily briefing * fix: handle Orbit digest data failures * fix: load Orbit templates and connector tools reliably * fix: keep Orbit summary counts consistent Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: apply Orbit template skill context * fix: cache and curate connector tools for Orbit * fix: align Orbit defaults and connector discovery * fix: simplify Orbit template settings * fix: move connectors into settings * fix: compact connector settings catalog * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: address Orbit PR feedback Generated-By: looper 0.6.1 (runner=fixer, agent=opencode) * fix: prevent connector action button from stretching into pill The icon-only connect/disconnect buttons in the embedded connectors catalog inherited min-width: 92px / 106px from the non-embedded pill rules, overriding the 24px square sizing and causing the buttons to overlap the card head text. Reset min-width to 0 in the embedded icon-only rule so the compact square layout holds. * fix(web): align live artifact file rows * fix: clean up Orbit connector settings lifecycle Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix: address Orbit review regressions Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * feat(web): localize Orbit and connector settings * feat(web): gate Orbit runs without connectors * feat(web): refine connector settings UX * feat(web): safeguard Composio key clearing * fix(web): refresh Composio tool badges * feat(web): show connector logos * feat(daemon): localize Orbit prompt window * fix(daemon): clarify blocked connector callback closes * test(daemon): harden flaky async probes * fix(web): align Indonesian connector locale keys * test(web): align connector browser props * fix(web): preserve explicit credential clears Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): time out Composio logo proxy fetches Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): localize Indonesian connector settings copy Translate the new connector settings strings in the Indonesian locale and lock them with a regression test so this surface no longer silently falls back to English. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): preserve discovered connector tools Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): preserve onboarding autosave completion Keep settings autosave from clearing onboarding completion after the close gesture, and expose the desktop main types from source so workspace validation can typecheck packaged imports without a prior desktop build. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): defer Composio catalog cache hydration Load persisted Composio catalog data only after the runtime data directory is configured so startup cannot read another namespace's cache. Add a regression test that exercises the module-load singleton path. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): treat discovery completion independently Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): preserve latest settings draft on close Use the latest persisted settings draft when the dialog closes so onboarding completion does not race a stale daemon sync and overwrite newer Orbit/template selections. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): avoid syncing draft Composio key on Orbit run Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): localize Orbit settings copy Translate the new Indonesian Orbit and autosave strings so the settings UI no longer falls back to English and the locale regression stays covered. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): prefer fresh connector catalog state Keep refetched connector status/auth data authoritative while retaining discovery-only tool metadata so the connectors UI stays consistent after refreshes. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): declare Indonesian locale fallback keys explicitly Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): inline Indonesian fallback strings for CI Replace the Indonesian locale's per-key English lookups with explicit strings so workspace typecheck no longer depends on brittle build-mode resolution in CI. Add a regression test that blocks those per-key English lookups from reappearing in the CI-sensitive fallback sections. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): restrict proxied connector logos to image MIME types Reject non-image upstream logo responses so the daemon never serves third-party HTML from its localhost origin. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * test(e2e): align settings dialog regressions Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): decouple Orbit runs from media sync failures Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): keep SPA catch-all export-compatible Disable dynamic catch-all params for the exported SPA shell so Next.js static builds can emit the root route again. Add a regression test covering the route config against the web export mode. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): preserve Orbit config and workspace routes Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): block SVG in connector logo proxy Reject SVG and other unsafe proxied logo responses so third-party logo content cannot execute under the daemon origin, while keeping raster logo fetches working and making rejected responses non-cacheable. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): fall back to static catalog for empty cache Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): disable Orbit run before connector gate resolves Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(desktop): export shipped desktop types Point the desktop ./main type export at the generated declaration so installed consumers resolve the published file set. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): restore persisted question form selections Render historical submitted answers directly so reloaded question forms keep their locked selections visible. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): retry forced media sync autosave Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): keep Composio logo timeout through body read Keep the Composio logo fetch timeout active until the response body is fully consumed so stalled body reads abort and clear the inflight cache entry. Add a regression test that proves a delayed body read times out and the next request can recover.\n\nGenerated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): refresh Orbit gate after connector auth Re-check connector availability when the settings window regains focus so Orbit unlocks as soon as a connector finishes authenticating in the same settings session. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): keep connector detail tool lists intact Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): ignore malformed Orbit summaries Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(e2e): stabilize design-system multi-select flow Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): cap Composio logo cache growth Bound the Composio logo cache with LRU eviction and expired-entry pruning so repeated untrusted logo requests cannot grow daemon memory without limit. Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(daemon): bound proxied Composio logo payloads Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): align autosave settings tests Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): remove stray CSS conflict marker Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fixer: address PR #681 follow-up items Generated-By: looper 0.6.2 (runner=fixer, agent=opencode) * fix(web): restore restart routes and connector flows * fix(web): keep SPA export route static * fix(web): stabilize chat scroll tests --------- Co-authored-by: lefarcen <935902669@qq.com>
521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
import http from 'node:http';
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import express from 'express';
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
} from 'vitest';
|
|
|
|
import { readAppConfig, writeAppConfig } from '../src/app-config.js';
|
|
import { isLocalSameOrigin } from '../src/origin-validation.js';
|
|
|
|
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 {} when config file does not exist', async () => {
|
|
expect(await readAppConfig(dataDir)).toEqual({});
|
|
});
|
|
|
|
it('returns parsed config from existing file', async () => {
|
|
await writeFile(
|
|
path.join(dataDir, 'app-config.json'),
|
|
JSON.stringify({ onboardingCompleted: true }),
|
|
);
|
|
const cfg = await readAppConfig(dataDir);
|
|
expect(cfg.onboardingCompleted).toBe(true);
|
|
});
|
|
|
|
it('returns {} for corrupted JSON without crashing', async () => {
|
|
await writeFile(path.join(dataDir, 'app-config.json'), '{not valid');
|
|
const cfg = await readAppConfig(dataDir);
|
|
expect(cfg).toEqual({});
|
|
});
|
|
|
|
it('returns {} 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({});
|
|
});
|
|
|
|
it('returns {} when file contains a JSON primitive', async () => {
|
|
await writeFile(path.join(dataDir, 'app-config.json'), '"hello"');
|
|
const cfg = await readAppConfig(dataDir);
|
|
expect(cfg).toEqual({});
|
|
});
|
|
|
|
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' });
|
|
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({});
|
|
});
|
|
|
|
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' });
|
|
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({});
|
|
});
|
|
|
|
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 drops everything else', async () => {
|
|
await writeAppConfig(dataDir, {
|
|
agentCliEnv: {
|
|
claude: {
|
|
CLAUDE_CONFIG_DIR: ' ~/.claude-2 ',
|
|
ANTHROPIC_API_KEY: 'sk-should-not-persist',
|
|
},
|
|
codex: {
|
|
CODEX_HOME: '~/.codex-alt',
|
|
CODEX_BIN: '~/bin/codex-next',
|
|
OPENAI_API_KEY: 'sk-should-not-persist',
|
|
},
|
|
gemini: {
|
|
GEMINI_API_KEY: 'should-not-persist',
|
|
},
|
|
__proto__: {
|
|
CLAUDE_CONFIG_DIR: 'bad',
|
|
},
|
|
},
|
|
});
|
|
|
|
const cfg = await readAppConfig(dataDir);
|
|
|
|
expect(cfg.agentCliEnv).toEqual({
|
|
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
|
|
codex: { CODEX_HOME: '~/.codex-alt', CODEX_BIN: '~/bin/codex-next' },
|
|
});
|
|
});
|
|
|
|
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 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);
|
|
});
|
|
});
|