mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
* Add Docker Compose deployment workflow * Address Docker deployment review feedback Harden publishing inputs and temporary credential handling, and tighten Docker runtime defaults requested by the PR review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix Docker publish build in CI mode Set CI=true during the image build so pnpm prune can run non-interactively inside Docker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix Docker runtime dependency layout Use pnpm deploy for the daemon package so the runtime image includes production dependencies where Node resolves them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Use legacy pnpm deploy in Docker build Allow pnpm v10 deploy to package the daemon workspace without requiring injected workspace packages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Align Docker runtime with Node 24 Use Node 24 for both build and runtime stages and update image verification for the workspace daemon dependency layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Remove legacy OD_HOST Docker binding fallback Use OD_BIND_HOST as the single daemon bind-host setting for Docker deployment and origin validation. * Update Docker image verifier for daemon dist runtime Check the packaged daemon dist entrypoint and allow npm from the Node 24 runtime image while still rejecting build-only tools. * Allow private LAN browser origins for daemon * Share daemon origin validation helpers Move browser origin validation into a shared daemon module so tests exercise the production logic and cover the remaining private LAN edge cases. * Harden Docker Compose port exposure Bind the Compose deployment to localhost by default and pass the published port through to the daemon origin checks so host-port overrides remain same-origin. * Keep deployment hosts out of local-only no-origin checks Require an actual matching Origin before configured deployment origins can satisfy local-only daemon guards, preventing no-Origin remote clients from bypassing those checks. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: mrcfps <mrc@powerformer.com> Co-authored-by: lefarcen <935902669@qq.com>
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
// @ts-nocheck
|
|
import http from 'node:http';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import express from 'express';
|
|
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
import { isLocalSameOrigin } from '../src/origin-validation.js';
|
|
import { buildMcpInstallPayload } from '../src/mcp-install-info.js';
|
|
|
|
// The install-info endpoint is a self-contained handler that resolves
|
|
// absolute paths to node + cli.js so the Settings → MCP server panel
|
|
// can render snippets that work regardless of PATH. We re-build a
|
|
// minimal Express app rather than booting the full daemon (which needs
|
|
// SQLite, sidecar, fs scaffolding), but the payload itself is built by
|
|
// the same exported helper the production handler uses, so any shape
|
|
// divergence (env, args, buildHint) would fail this test.
|
|
|
|
interface InstallInfoOpts {
|
|
cliPath: string;
|
|
port: number;
|
|
/** Stand-in for `process.env`. Lets each test simulate sidecar vs
|
|
* non-sidecar daemon launches and custom namespaces without
|
|
* mutating the real process env. */
|
|
env?: NodeJS.ProcessEnv;
|
|
/** Stand-in for the daemon's resolved RUNTIME_DATA_DIR (issue #848).
|
|
* Pinned in the snippet env so IDE-spawned MCP processes write to
|
|
* the same directory the daemon already uses. */
|
|
dataDir: string;
|
|
}
|
|
|
|
function makeInstallInfoApp({ cliPath, port, env = {}, dataDir }: InstallInfoOpts) {
|
|
const app = express();
|
|
|
|
const TTL_MS = 5000;
|
|
let cache: { t: number; payload: object } | null = null;
|
|
let resolveCalls = 0;
|
|
|
|
app.get('/api/mcp/install-info', (req, res) => {
|
|
if (!isLocalSameOrigin(req, port)) {
|
|
return res.status(403).json({ error: 'cross-origin request rejected' });
|
|
}
|
|
const now = Date.now();
|
|
if (cache && now - cache.t < TTL_MS) {
|
|
return res.json(cache.payload);
|
|
}
|
|
resolveCalls += 1;
|
|
|
|
// Mirror the production handler's sidecar detection so this test
|
|
// exercises the same path; the helper below is the same one
|
|
// server.ts calls.
|
|
const sidecarIpcPath = env[SIDECAR_ENV.IPC_PATH];
|
|
const isSidecarMode = sidecarIpcPath != null && sidecarIpcPath.length > 0;
|
|
const sidecarEnv: Record<string, string> = {};
|
|
if (isSidecarMode) {
|
|
const ns = env[SIDECAR_ENV.NAMESPACE];
|
|
if (ns != null && ns !== SIDECAR_DEFAULTS.namespace) {
|
|
sidecarEnv[SIDECAR_ENV.NAMESPACE] = ns;
|
|
}
|
|
const ipcBase = env[SIDECAR_ENV.IPC_BASE];
|
|
if (ipcBase != null && ipcBase.length > 0) {
|
|
sidecarEnv[SIDECAR_ENV.IPC_BASE] = ipcBase;
|
|
}
|
|
}
|
|
const payload = buildMcpInstallPayload({
|
|
cliPath,
|
|
cliExists: fs.existsSync(cliPath),
|
|
execPath: process.execPath,
|
|
nodeExists: fs.existsSync(process.execPath),
|
|
port,
|
|
platform: process.platform,
|
|
dataDir,
|
|
electronAsNode: env.ELECTRON_RUN_AS_NODE === '1',
|
|
isSidecarMode,
|
|
sidecarEnv,
|
|
});
|
|
cache = { t: now, payload };
|
|
res.json(payload);
|
|
});
|
|
|
|
// Test-only escape hatch so assertions can prove the cache cold-paths.
|
|
(app as any)._resolveCalls = () => resolveCalls;
|
|
return app;
|
|
}
|
|
|
|
interface Harness {
|
|
app: express.Express;
|
|
server: http.Server;
|
|
port: number;
|
|
baseUrl: string;
|
|
}
|
|
|
|
async function startHarness(
|
|
cliPath: string,
|
|
env: NodeJS.ProcessEnv,
|
|
dataDir: string,
|
|
): Promise<Harness> {
|
|
// Pick a free port first so the handler can compare against it for
|
|
// isLocalSameOrigin.
|
|
const port: number = await new Promise((resolveListen) => {
|
|
const tmp = http.createServer();
|
|
tmp.listen(0, '127.0.0.1', () => {
|
|
const p = (tmp.address() as { port: number }).port;
|
|
tmp.close(() => resolveListen(p));
|
|
});
|
|
});
|
|
const app = makeInstallInfoApp({ cliPath, port, env, dataDir });
|
|
const server: http.Server = await new Promise((resolveStart) => {
|
|
const handle = app.listen(port, '127.0.0.1', () => resolveStart(handle));
|
|
});
|
|
return { app, server, port, baseUrl: `http://127.0.0.1:${port}` };
|
|
}
|
|
|
|
describe('GET /api/mcp/install-info', () => {
|
|
let tmpDir: string;
|
|
let cliPath: string;
|
|
let dataDir: string;
|
|
// Tests share the tmpDir but each top-level case spins its own
|
|
// app instance so different env configurations stay isolated.
|
|
let nonSidecar: { server: http.Server; port: number; app: express.Express };
|
|
|
|
beforeAll(
|
|
() =>
|
|
new Promise<void>((resolveBoot) => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-mcp-info-'));
|
|
cliPath = path.join(tmpDir, 'cli.js');
|
|
fs.writeFileSync(cliPath, '// stub\n', 'utf8');
|
|
dataDir = path.join(tmpDir, 'data');
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
const tmp = http.createServer();
|
|
tmp.listen(0, '127.0.0.1', () => {
|
|
const port = (tmp.address() as { port: number }).port;
|
|
tmp.close(() => {
|
|
const app = makeInstallInfoApp({ cliPath, port, env: {}, dataDir });
|
|
const server = app.listen(port, '127.0.0.1', () => {
|
|
nonSidecar = { server, port, app };
|
|
resolveBoot();
|
|
});
|
|
});
|
|
});
|
|
}),
|
|
);
|
|
|
|
afterAll(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
nonSidecar.server.close(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
resolve();
|
|
});
|
|
}),
|
|
);
|
|
|
|
afterEach(() => {
|
|
delete process.env.OD_ALLOWED_ORIGINS;
|
|
delete process.env.OD_BIND_HOST;
|
|
});
|
|
|
|
it('non-sidecar launch bakes --daemon-url so custom ports keep working', async () => {
|
|
const { port } = nonSidecar;
|
|
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.command).toBe(process.execPath);
|
|
// Direct `od` launches have no IPC socket; the snippet bakes the
|
|
// URL so the spawned `od mcp` reaches the right port without any
|
|
// discovery.
|
|
expect(body.args).toEqual([cliPath, 'mcp', '--daemon-url', `http://127.0.0.1:${port}`]);
|
|
// env always carries OD_DATA_DIR (issue #848); no sidecar keys in
|
|
// a non-sidecar launch.
|
|
expect(body.env).toEqual({ OD_DATA_DIR: dataDir });
|
|
expect(body.daemonUrl).toBe(`http://127.0.0.1:${port}`);
|
|
expect(body.platform).toBe(process.platform);
|
|
expect(body.cliExists).toBe(true);
|
|
expect(body.nodeExists).toBe(true);
|
|
expect(body.buildHint).toBeNull();
|
|
});
|
|
|
|
it('pins OD_DATA_DIR in the env so IDE-spawned MCP processes write to the daemon data dir (issue #848)', async () => {
|
|
const { port } = nonSidecar;
|
|
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
|
const body = await res.json();
|
|
expect(body.env).toBeDefined();
|
|
expect(body.env.OD_DATA_DIR).toBe(dataDir);
|
|
});
|
|
|
|
it('rejects cross-origin requests with 403', async () => {
|
|
const { port } = nonSidecar;
|
|
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`, {
|
|
headers: { Origin: 'https://evil.com' },
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('accepts requests with no Origin header (loopback fetch)', async () => {
|
|
const { port } = nonSidecar;
|
|
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('accepts requests with matching localhost Origin', async () => {
|
|
const { port } = nonSidecar;
|
|
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`, {
|
|
headers: { Origin: `http://127.0.0.1:${port}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('accepts explicitly configured deployment origins', async () => {
|
|
const { port } = nonSidecar;
|
|
process.env.OD_ALLOWED_ORIGINS = `https://od.example.com,http://203.0.113.10:${port}`;
|
|
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`, {
|
|
headers: {
|
|
Host: 'od.example.com',
|
|
Origin: 'https://od.example.com',
|
|
},
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('caches the payload across rapid calls', async () => {
|
|
const { port, app } = nonSidecar;
|
|
const before = (app as any)._resolveCalls();
|
|
await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
|
await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
|
await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
|
const after = (app as any)._resolveCalls();
|
|
// 3 rapid calls add at most 1 fresh resolve, not 3.
|
|
expect(after - before).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
it('sidecar default namespace omits --daemon-url and emits only OD_DATA_DIR', async () => {
|
|
const { port, server } = await startHarness(
|
|
cliPath,
|
|
{
|
|
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/default/daemon.sock',
|
|
[SIDECAR_ENV.NAMESPACE]: SIDECAR_DEFAULTS.namespace,
|
|
},
|
|
dataDir,
|
|
);
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
|
const body = await res.json();
|
|
expect(body.args).toEqual([cliPath, 'mcp']);
|
|
// Default namespace + default IPC base means the spawned `od mcp`
|
|
// can derive the right socket without any sidecar env hints. The
|
|
// OD_DATA_DIR pin still rides along so the data dir is correct.
|
|
expect(body.env).toEqual({ OD_DATA_DIR: dataDir });
|
|
} finally {
|
|
await new Promise<void>((done) => server?.close(() => done()));
|
|
}
|
|
});
|
|
|
|
it('sidecar non-default namespace propagates OD_SIDECAR_NAMESPACE alongside OD_DATA_DIR', async () => {
|
|
const { port, server } = await startHarness(
|
|
cliPath,
|
|
{
|
|
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock',
|
|
[SIDECAR_ENV.NAMESPACE]: 'foo',
|
|
},
|
|
dataDir,
|
|
);
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
|
const body = await res.json();
|
|
expect(body.args).toEqual([cliPath, 'mcp']);
|
|
// Without this propagation the MCP client would launch `od mcp`
|
|
// with no namespace env, fall back to "default", and miss the
|
|
// foo daemon entirely.
|
|
expect(body.env).toEqual({
|
|
OD_DATA_DIR: dataDir,
|
|
[SIDECAR_ENV.NAMESPACE]: 'foo',
|
|
});
|
|
} finally {
|
|
await new Promise<void>((done) => server?.close(() => done()));
|
|
}
|
|
});
|
|
|
|
it('sidecar with custom IPC base propagates OD_SIDECAR_IPC_BASE alongside OD_DATA_DIR', async () => {
|
|
const { port, server } = await startHarness(
|
|
cliPath,
|
|
{
|
|
[SIDECAR_ENV.IPC_PATH]: '/var/run/open-design/foo/daemon.sock',
|
|
[SIDECAR_ENV.NAMESPACE]: 'foo',
|
|
[SIDECAR_ENV.IPC_BASE]: '/var/run/open-design',
|
|
},
|
|
dataDir,
|
|
);
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
|
const body = await res.json();
|
|
expect(body.env).toEqual({
|
|
OD_DATA_DIR: dataDir,
|
|
[SIDECAR_ENV.NAMESPACE]: 'foo',
|
|
[SIDECAR_ENV.IPC_BASE]: '/var/run/open-design',
|
|
});
|
|
} finally {
|
|
await new Promise<void>((done) => server?.close(() => done()));
|
|
}
|
|
});
|
|
});
|