mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* reland(sidecar): converge product clients after weekly release
Restore the sidecar convergence from #7244 after the release-scheduling revert in #7634. This tree matches the previously reviewed and cross-platform QA-accepted implementation at d83d4e032f.
* fix(sidecar): clarify identity and cache witnesses
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(packaged): probe cross-source desktop owners
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(sidecar): preserve cross-source launch ownership
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* test(pack): assert cross-source Windows launch ownership
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(sidecar): align HyperFrames with client boundary
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(e2e): keep HyperFrames fixture in helper lane
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* docs(sidecar): restore review and handoff contracts
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(sidecar): restore cache and daemon boundaries
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(pack): restore sourcemaps and Linux smoke discovery
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(sidecar): make payload handoff generation-atomic
* fix(sidecar): preserve packaged action and mode parity
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* test(pack): align Linux cleanup resource count
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(packaged): preserve headless sidecar mode
* test: normalize source line endings
* fix(daemon): launch Codex cmd shims on Windows
---------
Co-authored-by: Looper <looper@noreply.github.com>
238 lines
7.9 KiB
TypeScript
238 lines
7.9 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { execFile } from 'node:child_process';
|
|
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
|
|
import { describe, expect, test } from 'vitest';
|
|
|
|
import { requestJson } from '@/vitest/http';
|
|
import { createSmokeSuite, e2eWorkspaceRoot } from '@/vitest/suite';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
type DaemonStatusResponse = {
|
|
dataDir?: string;
|
|
installedPlugins?: unknown;
|
|
mediaConfigDir?: string | null;
|
|
namespace?: unknown;
|
|
pid?: number;
|
|
port?: number;
|
|
};
|
|
|
|
type McpInstallInfoResponse = {
|
|
args: string[];
|
|
env: Record<string, string>;
|
|
};
|
|
|
|
type InstalledPlugin = {
|
|
fsPath?: string;
|
|
id: string;
|
|
source?: string;
|
|
sourceKind?: string;
|
|
version?: string;
|
|
};
|
|
|
|
type SseEvent = {
|
|
data: unknown;
|
|
event: string;
|
|
};
|
|
|
|
describe('namespace isolation spec', () => {
|
|
test('keeps namespace in lifecycle infrastructure while daemon clients use URL or an opaque sidecar capability', async () => {
|
|
const suite = await createSmokeSuite('namespace-isolation');
|
|
|
|
await suite.with.toolsDev(async ({ runtime, status, webUrl }) => {
|
|
expect(status.namespace).toBe(suite.namespace);
|
|
|
|
const daemonStatus = await requestJson<DaemonStatusResponse>(webUrl, '/api/daemon/status');
|
|
expect(daemonStatus.port).toBe(runtime.daemonPort);
|
|
expect(daemonStatus.dataDir).toBe(suite.dataDir);
|
|
expect(daemonStatus).not.toHaveProperty('namespace');
|
|
|
|
const installInfo = await requestJson<McpInstallInfoResponse>(webUrl, '/api/mcp/install-info');
|
|
const clientEndpoint = installInfo.env.OD_SIDECAR_CLIENT_ENDPOINT;
|
|
if (typeof clientEndpoint !== 'string' || clientEndpoint.length === 0) {
|
|
throw new Error('MCP install-info did not include the opaque sidecar client capability');
|
|
}
|
|
expect(Object.keys(installInfo.env).sort()).toEqual([
|
|
'OD_DATA_DIR',
|
|
'OD_SIDECAR_CLIENT_ENDPOINT',
|
|
]);
|
|
expect(installInfo.args).not.toContain('--daemon-url');
|
|
expect(installInfo.env.OD_DATA_DIR).toBe(suite.dataDir);
|
|
expect(installInfo.env).not.toHaveProperty('OD_NAMESPACE');
|
|
expect(installInfo.env).not.toHaveProperty('OD_SIDECAR_NAMESPACE');
|
|
expect(installInfo.env).not.toHaveProperty('OD_SIDECAR_IPC_BASE');
|
|
|
|
const cliStatus = await runDaemonCliJson<DaemonStatusResponse>(
|
|
['daemon', 'status', '--json'],
|
|
{
|
|
OD_DATA_DIR: suite.dataDir,
|
|
OD_NAMESPACE: 'wrong-daemon-namespace',
|
|
OD_SIDECAR_IPC_BASE: path.join(suite.scratchDir, 'wrong-ipc-base'),
|
|
OD_SIDECAR_CLIENT_ENDPOINT: clientEndpoint,
|
|
OD_SIDECAR_NAMESPACE: 'wrong-sidecar-namespace',
|
|
},
|
|
);
|
|
expect(cliStatus.port).toBe(runtime.daemonPort);
|
|
expect(cliStatus.dataDir).toBe(suite.dataDir);
|
|
expect(cliStatus).not.toHaveProperty('namespace');
|
|
|
|
const rejected = await runDaemonCliExpectFailure(
|
|
['daemon', 'status', '--json', '--namespace', 'should-not-parse'],
|
|
{ OD_SIDECAR_CLIENT_ENDPOINT: clientEndpoint },
|
|
);
|
|
expect(`${rejected.stdout}\n${rejected.stderr}`).toContain('unknown flag: --namespace');
|
|
|
|
const pluginSource = await writeLocalPluginFixture(suite.scratchDir);
|
|
const sourceForDaemon = `./${path.relative(e2eWorkspaceRoot(), pluginSource).replace(/\\/g, '/')}`;
|
|
const installResponse = await fetch(new URL('/api/plugins/install', ensureTrailingSlash(webUrl)), {
|
|
body: JSON.stringify({ source: sourceForDaemon }),
|
|
headers: { 'content-type': 'application/json' },
|
|
method: 'POST',
|
|
});
|
|
expect(installResponse.ok).toBe(true);
|
|
const installEvents = parseSseEvents(await installResponse.text());
|
|
const success = installEvents.find((entry) => entry.event === 'success')?.data as
|
|
| { plugin?: InstalledPlugin }
|
|
| undefined;
|
|
const expectedPluginRoot = path.join(suite.dataDir, 'plugins', 'e2e-namespace-plugin');
|
|
expect(success?.plugin?.id).toBe('e2e-namespace-plugin');
|
|
expect(success?.plugin?.fsPath).toBe(expectedPluginRoot);
|
|
|
|
const pluginInfo = await requestJson<InstalledPlugin>(webUrl, '/api/plugins/e2e-namespace-plugin');
|
|
expect(pluginInfo.fsPath).toBe(expectedPluginRoot);
|
|
await access(path.join(expectedPluginRoot, 'open-design.json'));
|
|
expect(await readFile(path.join(expectedPluginRoot, 'SKILL.md'), 'utf8')).toContain('E2E namespace plugin');
|
|
|
|
await suite.report.json('summary.json', {
|
|
cliStatus,
|
|
daemonStatus,
|
|
installInfo: {
|
|
args: installInfo.args,
|
|
envKeys: Object.keys(installInfo.env).sort(),
|
|
},
|
|
plugin: pluginInfo,
|
|
runtime,
|
|
toolsDevNamespace: status.namespace,
|
|
});
|
|
});
|
|
}, 180_000);
|
|
});
|
|
|
|
async function writeLocalPluginFixture(root: string): Promise<string> {
|
|
const pluginRoot = path.join(root, 'plugin-source');
|
|
await mkdir(pluginRoot, { recursive: true });
|
|
await writeFile(
|
|
path.join(pluginRoot, 'open-design.json'),
|
|
JSON.stringify(
|
|
{
|
|
name: 'e2e-namespace-plugin',
|
|
od: {
|
|
inputs: [{ name: 'topic', required: true, type: 'string' }],
|
|
kind: 'skill',
|
|
taskKind: 'new-generation',
|
|
useCase: { query: 'Make a {{topic}} brief.' },
|
|
},
|
|
title: 'E2E Namespace Plugin',
|
|
version: '1.0.0',
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
await writeFile(
|
|
path.join(pluginRoot, 'SKILL.md'),
|
|
[
|
|
'# E2E namespace plugin',
|
|
'',
|
|
'Create a deterministic fixture artifact for namespace isolation tests.',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
return pluginRoot;
|
|
}
|
|
|
|
async function runDaemonCliJson<T>(args: string[], env: Record<string, string>): Promise<T> {
|
|
const result = await runDaemonCli(args, env);
|
|
return parseJsonOutput<T>(result.stdout);
|
|
}
|
|
|
|
async function runDaemonCliExpectFailure(
|
|
args: string[],
|
|
env: Record<string, string>,
|
|
): Promise<{ stderr: string; stdout: string }> {
|
|
try {
|
|
await runDaemonCli(args, env);
|
|
} catch (error) {
|
|
const failure = error as { stderr?: string; stdout?: string };
|
|
return {
|
|
stderr: failure.stderr ?? '',
|
|
stdout: failure.stdout ?? '',
|
|
};
|
|
}
|
|
throw new Error(`expected daemon CLI to fail for args: ${args.join(' ')}`);
|
|
}
|
|
|
|
async function runDaemonCli(
|
|
args: string[],
|
|
env: Record<string, string>,
|
|
): Promise<{ stderr: string; stdout: string }> {
|
|
const mergedEnv: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
...env,
|
|
};
|
|
delete mergedEnv.OD_DAEMON_URL;
|
|
delete mergedEnv.OD_PORT;
|
|
|
|
const { stderr, stdout } = await execFileAsync(
|
|
process.execPath,
|
|
['--import', 'tsx', 'apps/daemon/src/cli.ts', ...args],
|
|
{
|
|
cwd: e2eWorkspaceRoot(),
|
|
env: mergedEnv,
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
},
|
|
);
|
|
return { stderr, stdout };
|
|
}
|
|
|
|
function parseJsonOutput<T>(stdout: string): T {
|
|
const trimmed = stdout.trim();
|
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
return JSON.parse(trimmed) as T;
|
|
}
|
|
const objectStart = stdout.lastIndexOf('\n{');
|
|
const arrayStart = stdout.lastIndexOf('\n[');
|
|
const jsonStart = Math.max(objectStart, arrayStart);
|
|
if (jsonStart < 0) {
|
|
throw new Error(`Expected JSON output from daemon CLI, got: ${stdout}`);
|
|
}
|
|
return JSON.parse(stdout.slice(jsonStart + 1)) as T;
|
|
}
|
|
|
|
function parseSseEvents(text: string): SseEvent[] {
|
|
return text
|
|
.split(/\r?\n\r?\n/)
|
|
.map((block) => block.trim())
|
|
.filter(Boolean)
|
|
.map((block) => {
|
|
let event = 'message';
|
|
const dataLines: string[] = [];
|
|
for (const line of block.split(/\r?\n/)) {
|
|
if (line.startsWith('event:')) event = line.slice('event:'.length).trim();
|
|
if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).trim());
|
|
}
|
|
return {
|
|
data: dataLines.length > 0 ? JSON.parse(dataLines.join('\n')) : null,
|
|
event,
|
|
};
|
|
});
|
|
}
|
|
|
|
function ensureTrailingSlash(value: string): string {
|
|
return value.endsWith('/') ? value : `${value}/`;
|
|
}
|