From cba8bf151d5ed05e6baa08e36fd99a2631f618ac Mon Sep 17 00:00:00 2001 From: PerishCode Date: Thu, 14 May 2026 16:35:46 +0800 Subject: [PATCH] chore: align namespace lifecycle packaging --- .github/workflows/release-beta.yml | 2 +- .github/workflows/release-stable.yml | 2 +- CHANGELOG.md | 2 +- apps/daemon/src/cli.ts | 138 +++++----- .../src/{mcp-daemon-url.ts => daemon-url.ts} | 37 +-- apps/daemon/src/mcp-install-info.ts | 4 +- apps/daemon/src/mcp-routes.ts | 23 +- apps/daemon/src/plugins/atoms/patch-edit.ts | 44 ++-- apps/daemon/src/plugins/installer.ts | 7 +- apps/daemon/src/plugins/registry.ts | 16 +- apps/daemon/src/server.ts | 16 +- apps/daemon/tests/daemon-lifecycle.test.ts | 2 + ...-daemon-url.test.ts => daemon-url.test.ts} | 47 ++-- apps/daemon/tests/mcp-install-info.test.ts | 69 +++-- apps/daemon/tests/plugins-apply.test.ts | 4 +- apps/daemon/tests/plugins-patch-edit.test.ts | 14 ++ apps/daemon/tests/plugins-upgrade.test.ts | 6 +- apps/packaged/src/headless.ts | 6 +- docs/plans/plugins-implementation.md | 4 +- docs/plugins-spec.md | 23 +- docs/plugins-spec.zh-CN.md | 23 +- e2e/lib/vitest/tools-dev.ts | 11 +- e2e/specs/namespace/main.spec.ts | 235 ++++++++++++++++++ scripts/seed-test-projects.ts | 38 ++- tools/pack/docker-compose.yml | 1 - tools/pack/helm/open-design/values.yaml | 1 - .../resources/web-standalone-after-pack.cjs | 14 +- tools/pack/src/cache.ts | 2 +- tools/pack/src/linux.ts | 12 +- tools/pack/src/mac-prebundle.ts | 1 - tools/pack/src/mac/constants.ts | 2 + tools/pack/src/win-prebundle.ts | 1 - tools/pack/src/win/app.ts | 2 +- tools/pack/src/win/constants.ts | 2 + tools/pack/src/workspace-build.ts | 14 +- tools/pack/tests/mac-prebundle.test.ts | 7 +- .../tests/web-standalone-after-pack.test.ts | 3 +- tools/pack/tests/win-prebundle.test.ts | 7 +- tools/pack/tests/workspace-build.test.ts | 6 + 39 files changed, 572 insertions(+), 276 deletions(-) rename apps/daemon/src/{mcp-daemon-url.ts => daemon-url.ts} (51%) rename apps/daemon/tests/{mcp-daemon-url.test.ts => daemon-url.test.ts} (55%) create mode 100644 e2e/specs/namespace/main.spec.ts diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 8ac682a6f..948d72a03 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -375,7 +375,7 @@ jobs: id: win_tools_pack_cache_key shell: pwsh env: - WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/contracts/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-beta.yml', '.github/scripts/release/cache/win.ps1') }} + WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/agui-adapter/**', 'packages/contracts/**', 'packages/plugin-runtime/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-beta.yml', '.github/scripts/release/cache/win.ps1') }} run: | if ([string]::IsNullOrWhiteSpace($env:WIN_TOOLS_PACK_ORIGIN_KEY)) { throw "Windows tools-pack cache origin key is empty" diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 726226729..00779c32e 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -439,7 +439,7 @@ jobs: id: win_tools_pack_cache_key shell: pwsh env: - WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/contracts/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-stable.yml', '.github/scripts/release/cache/win.ps1') }} + WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/agui-adapter/**', 'packages/contracts/**', 'packages/plugin-runtime/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-stable.yml', '.github/scripts/release/cache/win.ps1') }} run: | if ([string]::IsNullOrWhiteSpace($env:WIN_TOOLS_PACK_ORIGIN_KEY)) { throw "Windows tools-pack cache origin key is empty" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dbc73b4a..972cfa705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -234,7 +234,7 @@ A memory-plus-UI release: **auto-memory store** carries agent context across run - **`docs/atoms.md`.** New canonical reference for the first-party atom catalog: implemented vs planned ids, task-kind mapping, how the daemon resolves an atom at run time, the closed `until`-signal vocabulary, and the §22.5 community-plugin → first-party-atom promotion path. - **Plugin & marketplace system — Phase 2A finished + Phase 1 follow-up + Phase 1.5 + Phase 2B (composer mount) + Phase 2C entry slice + Phase 3 entry slice + early Phase 5 (earlier landing).** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md). - **CLI canonical agent-facing API (spec §11.7).** Every UI action now has a CLI equivalent: `od project create/list/info/delete`, `od run start/watch/cancel/list/info` (with `--plugin`, `--inputs`, `--grant-caps`, `--snapshot-id`, `--follow`), `od files list/read/write/upload/delete`, `od conversation list/info`. Plugin runs route through the §3.A1 snapshot resolver. - - **Phase 1.5 headless lifecycle.** New `od daemon start [--headless] [--serve-web] [--port] [--host] [--namespace]`, `od daemon status [--json]`, `od daemon stop`. Backed by new HTTP routes `GET /api/daemon/status` and `POST /api/daemon/shutdown` (loopback-only). The default `od` (no subcommand) keeps its desktop behaviour for back-compat. e2e-3's headless install→project→run loop is now anchored in a daemon supertest (`apps/daemon/tests/plugins-headless-run.test.ts`). + - **Phase 1.5 headless lifecycle.** New `od daemon start [--headless] [--serve-web] [--port] [--host]`, `od daemon status [--json]`, `od daemon stop`. Backed by new HTTP routes `GET /api/daemon/status` and `POST /api/daemon/shutdown` (loopback-only). The default `od` (no subcommand) keeps its desktop behaviour for back-compat. e2e-3's headless install→project→run loop is now anchored in a daemon supertest (`apps/daemon/tests/plugins-headless-run.test.ts`). - **Phase 3 marketplace plugin install resolution.** `POST /api/plugins/install` with a bare plugin name walks every configured `plugin_marketplaces` row in registration order and re-routes to the canonical `github:…` / `https://…` source recorded in the matched manifest. CLI: `od plugin install ` works against any catalog the operator added via `od marketplace add `. - **Web composer mount.** New `PluginsSection` widget combines `InlinePluginsRail`, `ContextChipStrip`, `PluginInputsForm`, plus `renderPluginBriefTemplate`'s `{{var}}` substitution. Mounted in `NewProjectPanel` below the project-name input as the Phase 2A discovery surface. Purely additive: the existing Send button rules are unchanged. - The earlier work in this changelog block remains in place (snapshot resolver, trust mutation, connector tool-token gate, fallback rejection, GitHub tarball + HTTPS install sources, pipeline runner with cross-conversation cache, marketplace registry, snapshot GC worker, web component primitives, definition-of-done suite, …). diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index e3b13684f..104e9914c 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -6,6 +6,7 @@ import { runConnectorsToolCli } from './tools-connectors-cli.js'; import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js'; import { splitResearchSubcommand } from './research/cli-args.js'; import { openBrowser } from './browser-open.js'; +import { resolveDaemonUrl } from './daemon-url.js'; const argv = process.argv.slice(2); @@ -128,7 +129,7 @@ const UI_BOOLEAN_FLAGS = new Set([ // to their handlers); we just export the bindings up here so the // dispatch path always sees an initialized value. const DAEMON_STRING_FLAGS = new Set([ - 'daemon-url', 'port', 'host', 'namespace', + 'daemon-url', 'port', 'host', ]); const DAEMON_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'json', 'headless', 'serve-web', 'no-open', @@ -376,8 +377,7 @@ async function runResearchSearch(rawArgs) { console.error('--query required'); process.exit(2); } - const daemonUrl = - flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; + const daemonUrl = await cliDaemonUrl(flags); const maxSources = flags['max-sources'] == null ? undefined : Number(flags['max-sources']); const url = `${daemonUrl.replace(/\/$/, '')}/api/research/search`; @@ -414,7 +414,7 @@ Output is JSON only on stdout: Flags: --query Required search query. --max-sources Optional source cap. Defaults to 5, clamped to Tavily's max. - --daemon-url Local daemon URL. Defaults to OD_DAEMON_URL or http://127.0.0.1:7456.`); + --daemon-url Local daemon URL. Defaults to OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456.`); } // --------------------------------------------------------------------------- @@ -452,7 +452,7 @@ async function runMediaGenerate(rawArgs) { process.exit(2); } - const daemonUrl = flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; + const daemonUrl = await cliDaemonUrl(flags); const projectId = flags.project || process.env.OD_PROJECT_ID; if (!projectId) { console.error( @@ -533,8 +533,7 @@ async function runMediaWait(rawArgs) { printMediaHelp(); process.exit(2); } - const daemonUrl = - flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; + const daemonUrl = await cliDaemonUrl(flags); const since = Number.isFinite(Number(flags.since)) ? Number(flags.since) : 0; @@ -716,6 +715,14 @@ function parseFlags(argv, opts = {}) { return out; } +async function cliDaemonUrl(flags) { + return resolveDaemonUrl({ flagUrl: flags?.['daemon-url'] }); +} + +async function cliDaemonBaseUrl(flags) { + return (await cliDaemonUrl(flags)).replace(/\/$/, ''); +} + function printMediaHelp() { console.log(`Usage: od media generate --surface --model [opts] "$OD_NODE_BIN" "$OD_BIN" media generate --surface --model [opts] @@ -745,7 +752,7 @@ Common options: future image-edit endpoints). Daemon reads the file from the project, base64-encodes it, and forwards it to the upstream API. - --daemon-url http://127.0.0.1:7456 + --daemon-url Output: a single line of JSON: {"file": { name, size, kind, mime, ... }}. @@ -775,8 +782,7 @@ async function runMcp(args) { return; } - const { resolveMcpDaemonUrl } = await import('./mcp-daemon-url.js'); - const daemonUrl = await resolveMcpDaemonUrl({ flagUrl: flags['daemon-url'] }); + const daemonUrl = await cliDaemonUrl(flags); const { runMcpStdio } = await import('./mcp.js'); await runMcpStdio({ daemonUrl }); @@ -792,9 +798,7 @@ project without exporting a zip every iteration. Options: --daemon-url Open Design daemon HTTP base URL. Resolution - order: this flag, OD_DAEMON_URL, the running - daemon's sidecar IPC status socket - (/tmp/open-design/ipc//daemon.sock), + order: this flag, OD_DAEMON_URL, OD_SIDECAR_IPC_PATH, then http://127.0.0.1:7456. Each new MCP spawn discovers the live daemon URL at startup, so MCP client configs stay valid across daemon @@ -992,7 +996,7 @@ Exit codes: // can't resolve. let registry; if (!flags['no-daemon']) { - const base = libraryDaemonUrl(flags).replace(/\/$/, ''); + const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); try { const [skillsResp, dsResp, atomsResp] = await Promise.all([ fetch(`${base}/api/skills`).catch(() => null), @@ -1162,7 +1166,7 @@ view is the single source of truth.`); const out = typeof flags.out === 'string' && flags.out.length > 0 ? flags.out : process.cwd(); - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const resp = await fetch(`${base}/api/applied-plugins/export`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -1197,14 +1201,14 @@ async function runMarketplace(args) { Update the marketplace trust tier. Common options: - --daemon-url Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456). + --daemon-url Open Design daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456). --json Emit raw JSON (suitable for scripts).`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); switch (sub) { case 'list': { const resp = await fetch(`${base}/api/marketplaces`); @@ -1349,7 +1353,7 @@ async function runPluginSnapshots(args) { process.exit(args.length === 0 ? 2 : 0); } const flags = parseFlags(args.slice(1), { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); if (sub === 'show') { const positional = args.slice(1).filter((a) => !a.startsWith('-')); const id = positional[0]; @@ -1486,7 +1490,7 @@ async function runPluginRun(rest) { const grantCaps = typeof flags['grant-caps'] === 'string' && flags['grant-caps'].length > 0 ? flags['grant-caps'].split(',').map((c) => c.trim()).filter(Boolean) : []; - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); // 1. Apply (returns ApplyResult + manifestSourceDigest). const applyResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}/apply`, { method: 'POST', @@ -1537,8 +1541,8 @@ async function runPluginRun(rest) { } } -function pluginDaemonUrl(flags) { - return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; +async function pluginDaemonUrl(flags) { + return cliDaemonUrl(flags); } // Plan §3.Y1 — filter knobs on `od plugin list` (and feeds @@ -1623,7 +1627,7 @@ Prints an at-a-glance plugin + snapshot inventory: - Oldest / newest applied snapshot timestamps.`); process.exit(0); } - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const url = `${base}/api/plugins/stats`; const resp = await fetch(url); if (!resp.ok) { @@ -1674,7 +1678,7 @@ function formatTimestamp(ts) { } async function fetchPluginList(flags) { - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins`; const resp = await fetch(url); if (!resp.ok) { console.error(`GET /api/plugins failed: ${resp.status} ${await resp.text()}`); @@ -1737,7 +1741,7 @@ async function runPluginInfo(rest) { console.error('Usage: od plugin info '); process.exit(2); } - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`; const resp = await fetch(url); if (!resp.ok) { console.error(`GET /api/plugins/${id} failed: ${resp.status} ${await resp.text()}`); @@ -1759,7 +1763,7 @@ async function runPluginManifest(rest) { console.error('Usage: od plugin manifest '); process.exit(2); } - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`; const resp = await fetch(url); if (resp.status === 404) { console.error(`plugin ${id} not found`); @@ -1784,7 +1788,7 @@ async function runPluginManifest(rest) { // authors comparing their fork to its upstream installs. async function runPluginSources(rest) { const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins`; const resp = await fetch(url); if (!resp.ok) { console.error(`GET /api/plugins failed: ${resp.status} ${await resp.text()}`); @@ -1833,7 +1837,7 @@ async function runPluginInstall(rest) { ' od plugin install # resolves through configured marketplaces'); process.exit(2); } - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/install`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/install`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, @@ -1911,7 +1915,7 @@ Lifecycle vocabulary: string: new Set([...PLUGIN_STRING_FLAGS, 'since', 'kind', 'plugin-id']), boolean: new Set([...PLUGIN_BOOLEAN_FLAGS, 'f', 'follow']), }); - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const since = typeof flags.since === 'string' ? Number(flags.since) : 0; const kindFilter = typeof flags.kind === 'string' && flags.kind.length > 0 ? flags.kind : null; const pluginIdFilter = typeof flags['plugin-id'] === 'string' && flags['plugin-id'].length > 0 @@ -2132,7 +2136,7 @@ Exit codes: 2 CLI usage error / plugin not found / config malformed`); process.exit(id ? 0 : 2); } - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); // 1. Resolve the plugin record (fsPath + manifest). const pluginResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}`); @@ -2304,7 +2308,7 @@ Closed signal vocabulary: } // Fetch the plugin from the daemon so we get the resolved // manifest (including pipeline). - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const resp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}`); if (resp.status === 404) { console.error(`plugin ${id} not found`); @@ -2386,7 +2390,7 @@ exits 4 on mismatch. Useful for committing renderPluginBlock() fixtures into a plugin's own tests/.`); process.exit(id ? 0 : 2); } - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const url = `${base}/api/applied-plugins/${encodeURIComponent(id)}/canon`; const checkPath = typeof flags.check === 'string' ? flags.check : null; // --check always wants the raw text output; force text/plain. @@ -2458,7 +2462,7 @@ into 'added' / 'removed' / 'changed' with one line per field.`); process.exit(positional.length < 2 ? 2 : 0); } const [idA, idB] = positional; - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); const [respA, respB] = await Promise.all([ fetch(`${base}/api/plugins/${encodeURIComponent(idA)}`), fetch(`${base}/api/plugins/${encodeURIComponent(idB)}`), @@ -2507,7 +2511,7 @@ async function runPluginUpgrade(rest) { console.error('Usage: od plugin upgrade '); process.exit(2); } - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/upgrade`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/upgrade`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, @@ -2557,7 +2561,7 @@ async function runPluginUninstall(rest) { console.error('Usage: od plugin uninstall '); process.exit(2); } - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/uninstall`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/uninstall`; const resp = await fetch(url, { method: 'POST' }); if (!resp.ok) { console.error(`POST /api/plugins/${id}/uninstall failed: ${resp.status} ${await resp.text()}`); @@ -2604,7 +2608,7 @@ async function runPluginApply(rest) { const grantCaps = typeof flags['grant-caps'] === 'string' && flags['grant-caps'].length > 0 ? flags['grant-caps'].split(',').map((c) => c.trim()).filter(Boolean) : []; - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/apply`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/apply`; let resp; try { resp = await fetch(url, { @@ -2615,7 +2619,7 @@ async function runPluginApply(rest) { } catch (err) { return exitWithStructuredError({ code: 'daemon-not-running', - message: `Cannot reach daemon at ${pluginDaemonUrl(flags)}: ${err?.message ?? err}`, + message: `Cannot reach daemon at ${await pluginDaemonUrl(flags)}: ${err?.message ?? err}`, }); } const data = await resp.json().catch(() => ({})); @@ -2688,7 +2692,7 @@ publish from a frozen run snapshot rather than the live installed copy.`); console.error('--to is required (one of: anthropics-skills, awesome-agent-skills, clawhub, skills-sh)'); process.exit(2); } - const base = pluginDaemonUrl(flags).replace(/\/$/, ''); + const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); // Pull the plugin metadata from the daemon. We do this through the // existing /api/plugins/:id endpoint so the CLI never needs a direct // SQLite handle; everything stays loopback-mediated. @@ -2753,7 +2757,7 @@ async function runPluginDoctor(rest) { console.error('Usage: od plugin doctor [--strict] [--json]'); process.exit(2); } - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/doctor`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/doctor`; const resp = await fetch(url, { method: 'POST' }); if (!resp.ok) { console.error(`POST /api/plugins/${id}/doctor failed: ${resp.status} ${await resp.text()}`); @@ -2811,7 +2815,7 @@ async function runPluginReplay(rest) { console.error('--snapshot-id is required (runs are in-memory in Phase 2A; pass the snapshot id returned by od plugin apply)'); process.exit(2); } - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/replay`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/replay`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -2856,7 +2860,7 @@ async function runPluginTrust(rest) { process.exit(2); } const action = flags.revoke ? 'revoke' : 'grant'; - const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/trust`; + const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/trust`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -2906,13 +2910,13 @@ async function runUi(args) { } } -function uiDaemonUrl(flags) { - return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; +async function uiDaemonUrl(flags) { + return cliDaemonUrl(flags); } async function runUiList(rest) { const flags = parseFlags(rest, { string: UI_STRING_FLAGS, boolean: UI_BOOLEAN_FLAGS }); - const base = uiDaemonUrl(flags).replace(/\/$/, ''); + const base = (await uiDaemonUrl(flags)).replace(/\/$/, ''); let url; if (flags.run) url = `${base}/api/runs/${encodeURIComponent(flags.run)}/genui`; else if (flags.project) url = `${base}/api/projects/${encodeURIComponent(flags.project)}/genui`; @@ -2958,7 +2962,7 @@ async function runUiShow(rest) { console.error('Usage: od ui show --run '); process.exit(2); } - const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}`; + const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}`; const resp = await fetch(url); if (!resp.ok) { console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`); @@ -3008,7 +3012,7 @@ async function runUiRespond(rest) { } else if (typeof flags.value === 'string') { value = flags.value; } - const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}/respond`; + const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}/respond`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -3044,7 +3048,7 @@ async function runUiRevoke(rest) { console.error('Usage: od ui revoke --project '); process.exit(2); } - const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/${encodeURIComponent(surfaceId)}/revoke`; + const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/${encodeURIComponent(surfaceId)}/revoke`; const resp = await fetch(url, { method: 'POST' }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { @@ -3086,7 +3090,7 @@ async function runUiPrefill(rest) { } else if (typeof flags.value === 'string') { value = flags.value; } - const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/prefill`; + const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/prefill`; const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -3123,7 +3127,7 @@ function printUiHelp() { Pre-answer a surface so the run never broadcasts it. Common options: - --daemon-url Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456). + --daemon-url Open Design daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456). --json Emit raw JSON (suitable for scripts) instead of human-readable output.`); } @@ -3162,7 +3166,7 @@ function printPluginHelp() { folder for distribution. Common options: - --daemon-url Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456). + --daemon-url Open Design daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456). --json Emit raw JSON (suitable for scripts) instead of human-readable output. Phase 1 only supports local-folder installs. The github / https tarball @@ -3180,8 +3184,8 @@ sources arrive in Phase 2A. The marketplace surface comes in Phase 4.`); // reachable via the CLI; we wrap rather than duplicate. // --------------------------------------------------------------------------- -function projectDaemonUrl(flags) { - return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; +async function projectDaemonUrl(flags) { + return cliDaemonUrl(flags); } function safeReadJsonFile(p) { @@ -3212,7 +3216,7 @@ Common options: const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); - const base = projectDaemonUrl(flags).replace(/\/$/, ''); + const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); switch (sub) { case 'list': { const resp = await fetch(`${base}/api/projects`); @@ -3324,7 +3328,7 @@ Common options: const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); - const base = projectDaemonUrl(flags).replace(/\/$/, ''); + const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); switch (sub) { case 'list': { const url = flags.project @@ -3485,7 +3489,7 @@ Common options: const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); - const base = projectDaemonUrl(flags).replace(/\/$/, ''); + const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); switch (sub) { case 'list': { const id = rest.find((a) => !a.startsWith('-')); @@ -3608,7 +3612,7 @@ Common options: const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); - const base = projectDaemonUrl(flags).replace(/\/$/, ''); + const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); switch (sub) { case 'list': { const id = rest.find((a) => !a.startsWith('-')); @@ -3660,7 +3664,6 @@ async function runDaemon(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od daemon start [--headless] [--serve-web] [--port ] [--host ] [--no-open] - [--namespace ] Start the daemon (Phase 1.5 headless mode). od daemon status [--json] [--daemon-url ] Print the daemon's runtime snapshot. @@ -3718,7 +3721,7 @@ vacuum: sizes + elapsed ms.`); process.exit(sub ? 0 : 2); } - const base = libraryDaemonUrl(flags).replace(/\/$/, ''); + const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); if (sub === 'vacuum') { const resp = await fetch(`${base}/api/daemon/db/vacuum`, { method: 'POST' }); if (!resp.ok) { @@ -3807,7 +3810,6 @@ async function runDaemonStart(flags) { const port = Number(flags.port ?? process.env.OD_PORT ?? 7456); const host = String(flags.host ?? process.env.OD_BIND_HOST ?? '127.0.0.1'); const headless = Boolean(flags.headless || flags['no-open'] || flags['serve-web']); - if (flags.namespace) process.env.OD_NAMESPACE = String(flags.namespace); process.env.OD_BIND_HOST = host; process.env.OD_PORT = String(port); const { startServer: startHeadless } = await import('./server.js'); @@ -3847,7 +3849,7 @@ async function runDaemonStart(flags) { } async function runDaemonStatus(flags) { - const base = (flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456').replace(/\/$/, ''); + const base = await cliDaemonBaseUrl(flags); let resp; try { resp = await fetch(`${base}/api/daemon/status`); @@ -3860,11 +3862,11 @@ async function runDaemonStatus(flags) { if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); - console.log(`[daemon] ${data.bindHost}:${data.port} v${data.version} pid=${data.pid} plugins=${data.installedPlugins} namespace=${data.namespace ?? '-'}`); + console.log(`[daemon] ${data.bindHost}:${data.port} v${data.version} pid=${data.pid} plugins=${data.installedPlugins}`); } async function runDaemonStop(flags) { - const base = (flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456').replace(/\/$/, ''); + const base = await cliDaemonBaseUrl(flags); let resp; try { resp = await fetch(`${base}/api/daemon/shutdown`, { method: 'POST' }); @@ -3886,8 +3888,8 @@ async function runDaemonStop(flags) { // (the §11.7 "headless = canonical" invariant). // --------------------------------------------------------------------------- -function libraryDaemonUrl(flags) { - return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456'; +async function libraryDaemonUrl(flags) { + return cliDaemonUrl(flags); } async function runAtoms(args) { @@ -3905,7 +3907,7 @@ Common options: const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS }); - const base = libraryDaemonUrl(flags).replace(/\/$/, ''); + const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); switch (sub) { case 'list': { const resp = await fetch(`${base}/api/atoms`); @@ -3979,7 +3981,7 @@ async function runLibraryList(name, args) { const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS }); - const base = libraryDaemonUrl(flags).replace(/\/$/, ''); + const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); const apiPath = name === 'design-systems' ? '/api/design-systems' : `/api/${name}`; switch (sub) { case 'list': { @@ -4023,7 +4025,7 @@ async function runStatus(args) { async function runVersion(args) { const flags = parseFlags(args, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS }); - const base = libraryDaemonUrl(flags).replace(/\/$/, ''); + const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); let resp; try { resp = await fetch(`${base}/api/version`); @@ -4072,7 +4074,7 @@ Exit code is non-zero when any installed plugin's doctor returns ok=false or the daemon cannot be reached.`); process.exit(0); } - const base = libraryDaemonUrl(flags).replace(/\/$/, ''); + const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); const report = { daemon: null, plugins: [], @@ -4189,7 +4191,7 @@ Common options: const sub = args[0]; const rest = args.slice(1); const flags = parseFlags(rest, { string: CONFIG_STRING_FLAGS, boolean: CONFIG_BOOLEAN_FLAGS }); - const base = libraryDaemonUrl(flags).replace(/\/$/, ''); + const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); const fetchConfig = async () => { const resp = await fetch(`${base}/api/app-config`); diff --git a/apps/daemon/src/mcp-daemon-url.ts b/apps/daemon/src/daemon-url.ts similarity index 51% rename from apps/daemon/src/mcp-daemon-url.ts rename to apps/daemon/src/daemon-url.ts index dec5ef786..b8e6116e5 100644 --- a/apps/daemon/src/mcp-daemon-url.ts +++ b/apps/daemon/src/daemon-url.ts @@ -1,38 +1,31 @@ import { - APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, - SIDECAR_DEFAULTS, SIDECAR_ENV, SIDECAR_MESSAGES, type DaemonStatusSnapshot, } from "@open-design/sidecar-proto"; -import { requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; +import { requestJsonIpc } from "@open-design/sidecar"; -export const MCP_DEFAULT_DAEMON_URL = "http://127.0.0.1:7456"; +export const DEFAULT_DAEMON_URL = "http://127.0.0.1:7456"; -export interface ResolveMcpDaemonUrlOptions { +export interface ResolveDaemonUrlOptions { /** Value passed via `--daemon-url`. Empty string is treated as unset. */ flagUrl?: string | null; /** Defaults to `process.env`; injected for tests. */ env?: NodeJS.ProcessEnv; - /** IPC discovery timeout. Short by default so an absent daemon does not stall MCP startup. */ + /** IPC discovery timeout. Short by default so an absent daemon does not stall CLI startup. */ timeoutMs?: number; } /** - * Resolve the daemon HTTP base URL for `od mcp`. + * Resolve the daemon HTTP base URL for `od` client commands. * * Spawn order: explicit `--daemon-url` flag, `OD_DAEMON_URL` env, then - * a STATUS roundtrip to the sidecar IPC socket the running daemon - * already publishes (`/tmp/open-design/ipc//daemon.sock`). - * Falls back to the legacy default for direct `od` launches that do - * not run as a sidecar. Discovery means the install snippet never has - * to bake a port: every spawn rediscovers the live URL, so an - * ephemeral daemon port (tools-dev, packaged) cannot invalidate a - * previously-installed MCP client config. + * a STATUS roundtrip to the concrete sidecar IPC endpoint supplied by + * the lifecycle owner in `OD_SIDECAR_IPC_PATH`. Falls back to the + * legacy default for direct `od` launches that do not run as a sidecar. */ -export async function resolveMcpDaemonUrl( - options: ResolveMcpDaemonUrlOptions = {}, +export async function resolveDaemonUrl( + options: ResolveDaemonUrlOptions = {}, ): Promise { const env = options.env ?? process.env; const flagUrl = options.flagUrl ?? null; @@ -41,20 +34,16 @@ export async function resolveMcpDaemonUrl( if (envUrl != null && envUrl.length > 0) return envUrl; const discovered = await discoverDaemonUrlFromIpc(env, options.timeoutMs ?? 800); if (discovered != null) return discovered; - return MCP_DEFAULT_DAEMON_URL; + return DEFAULT_DAEMON_URL; } async function discoverDaemonUrlFromIpc( env: NodeJS.ProcessEnv, timeoutMs: number, ): Promise { + const socketPath = env[SIDECAR_ENV.IPC_PATH]; + if (socketPath == null || socketPath.length === 0) return null; try { - const socketPath = resolveAppIpcPath({ - app: APP_KEYS.DAEMON, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - env, - namespace: env[SIDECAR_ENV.NAMESPACE] ?? SIDECAR_DEFAULTS.namespace, - }); const status = await requestJsonIpc( socketPath, { type: SIDECAR_MESSAGES.STATUS }, diff --git a/apps/daemon/src/mcp-install-info.ts b/apps/daemon/src/mcp-install-info.ts index c1ecce0fa..84b81680e 100644 --- a/apps/daemon/src/mcp-install-info.ts +++ b/apps/daemon/src/mcp-install-info.ts @@ -3,7 +3,7 @@ // share the exact env/argv/buildHint shape; a divergence here is the // difference between an MCP snippet that works and one that EPERMs out // when pasted into Antigravity / Cursor / VS Code (issue #848), or -// silently misses a non-default sidecar namespace. +// silently misses the sidecar transport endpoint. // // Side effects (the fs.existsSync probes, process.execPath, the // ELECTRON_RUN_AS_NODE env read, OD_DATA_DIR resolution, sidecar IPC @@ -24,7 +24,7 @@ export interface BuildMcpInstallPayloadInputs { * spawned `od mcp` should discover the live URL via the IPC * status socket instead of a baked --daemon-url. */ isSidecarMode: boolean; - /** Already-filtered sidecar env entries (namespace, IPC base) the + /** Already-filtered sidecar transport env entries the * caller wants propagated into the snippet. The caller decides * what's worth propagating; this builder just merges. */ sidecarEnv: Record; diff --git a/apps/daemon/src/mcp-routes.ts b/apps/daemon/src/mcp-routes.ts index 16ca1f88e..6ec5a0e1e 100644 --- a/apps/daemon/src/mcp-routes.ts +++ b/apps/daemon/src/mcp-routes.ts @@ -1,6 +1,6 @@ import type { Express } from 'express'; import fs from 'node:fs'; -import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto'; +import { SIDECAR_ENV } from '@open-design/sidecar-proto'; import { buildMcpInstallPayload } from './mcp-install-info.js'; import { MCP_TEMPLATES, buildAcpMcpServers, buildClaudeMcpJson, isManagedProjectCwd, readMcpConfig, writeMcpConfig } from './mcp-config.js'; import { beginAuth, exchangeCodeForToken, refreshAccessToken } from './mcp-oauth.js'; @@ -42,27 +42,16 @@ export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) { // The daemon was bootstrapped as a sidecar (tools-dev, packaged) iff // bootstrapSidecarRuntime stamped OD_SIDECAR_IPC_PATH into the env. // In sidecar mode the snippet omits --daemon-url and the spawned - // `od mcp` discovers the live URL via the IPC status socket on + // `od mcp` discovers the live URL via the concrete IPC endpoint on // every spawn, so the client config survives ephemeral-port - // restarts. We also propagate OD_SIDECAR_NAMESPACE (and IPC_BASE - // when overridden) so a non-default namespace daemon stays - // reachable - the MCP client does not inherit the daemon's env, - // so without this the spawned `od mcp` would probe the default - // namespace socket and miss. For direct `od` / `od --port X` - // launches there is no IPC socket; the helper bakes --daemon-url - // so custom ports keep working. + // restarts. For direct `od` / `od --port X` launches there is no + // IPC socket; the helper bakes --daemon-url so custom ports keep + // working. const sidecarIpcPath = process.env[SIDECAR_ENV.IPC_PATH]; const isSidecarMode = sidecarIpcPath != null && sidecarIpcPath.length > 0; const sidecarEnv: Record = {}; if (isSidecarMode) { - const ns = process.env[SIDECAR_ENV.NAMESPACE]; - if (ns != null && ns !== SIDECAR_DEFAULTS.namespace) { - sidecarEnv[SIDECAR_ENV.NAMESPACE] = ns; - } - const ipcBase = process.env[SIDECAR_ENV.IPC_BASE]; - if (ipcBase != null && ipcBase.length > 0) { - sidecarEnv[SIDECAR_ENV.IPC_BASE] = ipcBase; - } + sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath; } const payload = buildMcpInstallPayload({ cliPath, diff --git a/apps/daemon/src/plugins/atoms/patch-edit.ts b/apps/daemon/src/plugins/atoms/patch-edit.ts index 3f1024245..595878443 100644 --- a/apps/daemon/src/plugins/atoms/patch-edit.ts +++ b/apps/daemon/src/plugins/atoms/patch-edit.ts @@ -179,8 +179,9 @@ export async function applyPatchForStep(input: ApplyPatchInput): Promise'; return { status: 'failed', filesTouched: [...filesTouched], added, removed, - reason: `hunk apply failed for ${hunk.targetFile}: ${(err as Error).message}` }; + reason: `hunk apply failed for ${touched}: ${(err as Error).message}` }; } } @@ -314,30 +315,42 @@ function parseHunkHeader(line: string): { oldStart: number; oldLines: number; ne }; } +function resolvePatchFile(cwd: string, file: string): string { + const unsafe = `unsafe path '${file}'`; + if (file.includes('\0')) throw new Error(unsafe); + if (/^[A-Za-z]:/.test(file)) throw new Error(unsafe); + if (path.isAbsolute(file) || path.win32.isAbsolute(file) || path.posix.isAbsolute(file)) { + throw new Error(unsafe); + } + if (file.replace(/\\/g, '/').split('/').some((segment) => segment === '..')) { + throw new Error(unsafe); + } + const abs = path.resolve(cwd, file); + const relative = path.relative(cwd, abs); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(unsafe); + } + return abs; +} + async function applyOneFileHunks(cwd: string, bundle: FileHunkBundle): Promise<{ added: number; removed: number }> { if (bundle.sourceFile === null && bundle.targetFile === null) { throw new Error('hunk has /dev/null on both sides'); } - // Path-traversal guard. - for (const f of [bundle.sourceFile, bundle.targetFile]) { - if (f && (f.startsWith('/') || f.includes('..'))) { - throw new Error(`unsafe path '${f}'`); - } - } + const sourceAbs = bundle.sourceFile === null ? null : resolvePatchFile(cwd, bundle.sourceFile); + const targetAbs = bundle.targetFile === null ? null : resolvePatchFile(cwd, bundle.targetFile); if (bundle.targetFile === null) { // File deletion. - const abs = path.join(cwd, bundle.sourceFile!); let body: string; - try { body = await fsp.readFile(abs, 'utf8'); } catch { throw new Error(`file not found: ${bundle.sourceFile}`); } - await fsp.unlink(abs); + try { body = await fsp.readFile(sourceAbs!, 'utf8'); } catch { throw new Error(`file not found: ${bundle.sourceFile}`); } + await fsp.unlink(sourceAbs!); return { added: 0, removed: body.split('\n').length }; } if (bundle.sourceFile === null) { // File creation. - const abs = path.join(cwd, bundle.targetFile); - await fsp.mkdir(path.dirname(abs), { recursive: true }); + await fsp.mkdir(path.dirname(targetAbs!), { recursive: true }); let added = 0; const lines: string[] = []; for (const hunk of bundle.hunks) { @@ -346,14 +359,13 @@ async function applyOneFileHunks(cwd: string, bundle: FileHunkBundle): Promise<{ else if (l === '' || l.startsWith('\\')) { /* trailing newline marker */ } } } - await atomicWriteFile(abs, lines.join('\n') + (lines.length > 0 ? '\n' : '')); + await atomicWriteFile(targetAbs!, lines.join('\n') + (lines.length > 0 ? '\n' : '')); return { added, removed: 0 }; } // Plain edit. - const abs = path.join(cwd, bundle.targetFile); let original: string; - try { original = await fsp.readFile(abs, 'utf8'); } catch { throw new Error(`file not found: ${bundle.targetFile}`); } + try { original = await fsp.readFile(targetAbs!, 'utf8'); } catch { throw new Error(`file not found: ${bundle.targetFile}`); } const originalLines = original.split('\n'); // Trailing newline produces an empty last element after split; we // preserve that and add it back at the end. @@ -373,7 +385,7 @@ async function applyOneFileHunks(cwd: string, bundle: FileHunkBundle): Promise<{ working = result.working; } const final = working.join('\n') + (trailingNL ? '\n' : ''); - await atomicWriteFile(abs, final); + await atomicWriteFile(targetAbs!, final); return { added, removed }; } diff --git a/apps/daemon/src/plugins/installer.ts b/apps/daemon/src/plugins/installer.ts index 6cc782d02..9a84878e0 100644 --- a/apps/daemon/src/plugins/installer.ts +++ b/apps/daemon/src/plugins/installer.ts @@ -3,7 +3,8 @@ // - `./folder` / `/abs/path` — local-copy backend (Phase 1). // - `github:owner/repo[@ref][/subpath]` — fetched from // codeload.github.com as a tar.gz, extracted into a temp dir, then -// copied into ~/.open-design/plugins// via the local backend. +// copied into the daemon data-root-derived plugin registry via the local +// backend. // - `https://…tar.gz` / `…tgz` — same extraction path, no path-rewrite. // // Hard install constraints (spec §7.2 / plan §3.A6): @@ -55,8 +56,8 @@ export type InstallEvent = InstallProgressEvent | InstallSuccessEvent | InstallE export interface InstallOptions { source: string; - // Forwarded via env override or CLI flag; defaults to defaultRegistryRoots() - // so daemon tests can point at a sandboxed home. + // Forwarded from daemon runtime context; defaults to defaultRegistryRoots() + // so daemon tests can point at a sandboxed data root. roots?: RegistryRoots; // 50 MiB default mirrors spec §7.2; tests pin a tighter cap. maxBytes?: number; diff --git a/apps/daemon/src/plugins/registry.ts b/apps/daemon/src/plugins/registry.ts index 9c81a7e31..4cf852b29 100644 --- a/apps/daemon/src/plugins/registry.ts +++ b/apps/daemon/src/plugins/registry.ts @@ -1,7 +1,7 @@ // Plugin registry. Phase 1 scope: // -// - Scans `/.open-design/plugins//` (the OD-canonical install -// root) for manifest folders. +// - Scans `/plugins//` (the OD-canonical install root) for +// manifest folders. // - Resolves a plugin folder into either an `open-design.json`-anchored // manifest or a synthesized one derived from `SKILL.md` / // `.claude-plugin/plugin.json` (per spec §3 compatibility matrix). @@ -14,7 +14,6 @@ // schema migration. import path from 'node:path'; -import os from 'node:os'; import fs from 'node:fs'; import { promises as fsp } from 'node:fs'; import { @@ -37,16 +36,21 @@ type SqliteDb = Database.Database; type DbRow = Record; export interface RegistryRoots { - // Defaults to `/.open-design/plugins/`. + // User-installed plugin bytes. Production passes a daemon data-root-derived + // value; tests can point this at a sandbox. userPluginsRoot: string; } -export function defaultRegistryRoots(): RegistryRoots { +export function registryRootsForDataDir(dataDir: string): RegistryRoots { return { - userPluginsRoot: path.join(os.homedir(), '.open-design', 'plugins'), + userPluginsRoot: path.join(dataDir, 'plugins'), }; } +export function defaultRegistryRoots(): RegistryRoots { + return registryRootsForDataDir(path.resolve(process.env.OD_DATA_DIR ?? path.join(process.cwd(), '.od'))); +} + export interface ScannedPlugin { record: InstalledPluginRecord; warnings: string[]; diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 055194daf..461666d91 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -47,7 +47,6 @@ import { listDesignSystems, readDesignSystem, readDesignSystemAssets } from './d import { applyDiffReviewDecisionToCwd, applyPlugin, - defaultRegistryRoots, defaultBundledRoot, doctorPlugin, FIRST_PARTY_ATOMS, @@ -63,6 +62,7 @@ import { pruneExpiredSnapshots, registerBuiltInAtomWorkers, registerBundledPlugins, + registryRootsForDataDir, resolvePluginSnapshot, runPipelineForRun, runStageWithRegistry, @@ -1090,6 +1090,7 @@ const CRITIQUE_ARTIFACTS_DIR = path.join(RUNTIME_DATA_DIR, 'critique-artifacts') const PROJECTS_DIR = path.join(RUNTIME_DATA_DIR, 'projects'); const USER_SKILLS_DIR = path.join(RUNTIME_DATA_DIR, 'skills'); const USER_DESIGN_SYSTEMS_DIR = path.join(RUNTIME_DATA_DIR, 'design-systems'); +const PLUGIN_REGISTRY_ROOTS = registryRootsForDataDir(RUNTIME_DATA_DIR); // User-imported design templates mirror USER_SKILLS_DIR but are scanned // against DESIGN_TEMPLATES_DIR rather than SKILLS_DIR so the EntryView // Templates surface and the Settings → Skills surface stay decoupled. @@ -1109,7 +1110,7 @@ const ALL_SKILL_LIKE_ROOTS = [ DESIGN_TEMPLATES_DIR, ]; fs.mkdirSync(PROJECTS_DIR, { recursive: true }); -for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR, USER_DESIGN_TEMPLATES_DIR]) { +for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR, USER_DESIGN_TEMPLATES_DIR, PLUGIN_REGISTRY_ROOTS.userPluginsRoot]) { fs.mkdirSync(dir, { recursive: true }); } fs.mkdirSync(CRITIQUE_ARTIFACTS_DIR, { recursive: true }); @@ -2699,7 +2700,7 @@ export async function startServer({ }); // Plan §3.F2 / spec §11.7 — daemon lifecycle status. Returns the - // host / port the server is bound to plus the data dir + namespace, + // host / port the server is bound to plus the data dir, // so `od daemon status --json` can render a one-shot health snapshot // without depending on /api/version's content shape. app.get('/api/daemon/status', async (_req, res) => { @@ -2711,7 +2712,6 @@ export async function startServer({ port: Number(process.env.OD_PORT ?? 7456), dataDir: RUNTIME_DATA_DIR, mediaConfigDir: process.env.OD_MEDIA_CONFIG_DIR ?? null, - namespace: process.env.OD_NAMESPACE ?? null, pid: process.pid, shuttingDown: daemonShuttingDown, installedPlugins: (() => { @@ -4245,7 +4245,7 @@ export async function startServer({ }; try { - for await (const ev of installPlugin(db, { source })) { + for await (const ev of installPlugin(db, { source, roots: PLUGIN_REGISTRY_ROOTS })) { writeEvent(ev.kind, ev); if (ev.kind === 'success' || ev.kind === 'error') break; } @@ -4258,7 +4258,7 @@ export async function startServer({ app.post('/api/plugins/:id/uninstall', async (req, res) => { try { - const result = await uninstallPlugin(db, req.params.id, defaultRegistryRoots()); + const result = await uninstallPlugin(db, req.params.id, PLUGIN_REGISTRY_ROOTS); if (!result.ok && !result.removedFolder) { return res.status(404).json({ error: 'plugin not found', warning: result.warning }); } @@ -4318,7 +4318,7 @@ export async function startServer({ writeEvent('progress', { kind: 'progress', phase: 'resolving', message: `Upgrading ${id} from ${source}` }); try { - for await (const ev of installPlugin(db, { source, eventKind: 'upgraded' })) { + for await (const ev of installPlugin(db, { source, roots: PLUGIN_REGISTRY_ROOTS, eventKind: 'upgraded' })) { writeEvent(ev.kind, ev); if (ev.kind === 'success' || ev.kind === 'error') break; } @@ -6465,7 +6465,7 @@ export async function startServer({ const log = []; let plugin = null; let message = 'Install finished.'; - for await (const ev of installPlugin(db, { source: folder })) { + for await (const ev of installPlugin(db, { source: folder, roots: PLUGIN_REGISTRY_ROOTS })) { if (ev.message) log.push(ev.message); if (Array.isArray(ev.warnings)) warnings.splice(0, warnings.length, ...ev.warnings); if (ev.kind === 'success') { diff --git a/apps/daemon/tests/daemon-lifecycle.test.ts b/apps/daemon/tests/daemon-lifecycle.test.ts index 1ca33b3b0..9cb4addd2 100644 --- a/apps/daemon/tests/daemon-lifecycle.test.ts +++ b/apps/daemon/tests/daemon-lifecycle.test.ts @@ -41,6 +41,7 @@ describe('GET /api/daemon/status', () => { pid: unknown; installedPlugins: unknown; shuttingDown: boolean; + namespace?: unknown; }; expect(body.ok).toBe(true); expect(typeof body.version === 'string' || typeof body.version === 'object').toBe(true); @@ -49,6 +50,7 @@ describe('GET /api/daemon/status', () => { expect(typeof body.pid).toBe('number'); expect(typeof body.installedPlugins).toBe('number'); expect(body.shuttingDown).toBe(false); + expect(body).not.toHaveProperty('namespace'); }); }); diff --git a/apps/daemon/tests/mcp-daemon-url.test.ts b/apps/daemon/tests/daemon-url.test.ts similarity index 55% rename from apps/daemon/tests/mcp-daemon-url.test.ts rename to apps/daemon/tests/daemon-url.test.ts index 4dcc31089..11996b103 100644 --- a/apps/daemon/tests/mcp-daemon-url.test.ts +++ b/apps/daemon/tests/daemon-url.test.ts @@ -1,22 +1,16 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, test } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createJsonIpcServer, type JsonIpcServerHandle } from "@open-design/sidecar"; import { SIDECAR_ENV, SIDECAR_MESSAGES } from "@open-design/sidecar-proto"; -import { resolveMcpDaemonUrl, MCP_DEFAULT_DAEMON_URL } from "../src/mcp-daemon-url.js"; - -// On Windows the sidecar IPC contract switches to named pipes whose -// names are not relocatable via OD_SIDECAR_IPC_BASE, so the discovery -// case cannot use a per-test temp socket; skip just that case there. -const ipcTest = process.platform === "win32" ? test.skip : test; +import { resolveDaemonUrl, DEFAULT_DAEMON_URL } from "../src/daemon-url.js"; // Verifies the resolution chain: --daemon-url > OD_DAEMON_URL > sidecar -// IPC status discovery > legacy default. Each layer must short-circuit -// the next so the spawned `od mcp` follows the live daemon across -// restarts without re-pasting the install snippet. +// IPC status discovery > legacy default. Each layer must short-circuit the next +// so `od` clients follow the live daemon across ephemeral-port restarts. -describe("resolveMcpDaemonUrl", () => { +describe("resolveDaemonUrl", () => { let ipcBaseDir: string; beforeAll(() => { @@ -28,44 +22,40 @@ describe("resolveMcpDaemonUrl", () => { }); it("prefers the explicit --daemon-url flag", async () => { - const url = await resolveMcpDaemonUrl({ + const url = await resolveDaemonUrl({ flagUrl: "http://flag.example:1111", env: { OD_DAEMON_URL: "http://env.example:2222", - [SIDECAR_ENV.IPC_BASE]: ipcBaseDir, + [SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "daemon.sock"), }, }); expect(url).toBe("http://flag.example:1111"); }); it("falls back to OD_DAEMON_URL when no flag given", async () => { - const url = await resolveMcpDaemonUrl({ + const url = await resolveDaemonUrl({ env: { OD_DAEMON_URL: "http://env.example:2222", - [SIDECAR_ENV.IPC_BASE]: ipcBaseDir, + [SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "daemon.sock"), }, }); expect(url).toBe("http://env.example:2222"); }); it("returns the legacy default when no flag/env/socket is available", async () => { - const url = await resolveMcpDaemonUrl({ + const url = await resolveDaemonUrl({ env: { - // Point IPC discovery at a directory with no socket; discovery - // should fail silently and we fall back to the default. - [SIDECAR_ENV.IPC_BASE]: ipcBaseDir, - [SIDECAR_ENV.NAMESPACE]: "missing-ns", + [SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "missing.sock"), }, timeoutMs: 200, }); - expect(url).toBe(MCP_DEFAULT_DAEMON_URL); + expect(url).toBe(DEFAULT_DAEMON_URL); }); - ipcTest("discovers the live daemon URL via the sidecar IPC status socket", async () => { - const namespace = "discover-test"; - const namespaceDir = path.join(ipcBaseDir, namespace); - fs.mkdirSync(namespaceDir, { recursive: true }); - const socketPath = path.join(namespaceDir, "daemon.sock"); + it("discovers the live daemon URL via the concrete sidecar IPC status endpoint", async () => { + const socketPath = process.platform === "win32" + ? `\\\\.\\pipe\\open-design-daemon-url-${process.pid}-${Date.now()}` + : path.join(ipcBaseDir, "daemon.sock"); let ipc: JsonIpcServerHandle | null = null; try { ipc = await createJsonIpcServer({ @@ -87,10 +77,9 @@ describe("resolveMcpDaemonUrl", () => { }, }); - const url = await resolveMcpDaemonUrl({ + const url = await resolveDaemonUrl({ env: { - [SIDECAR_ENV.IPC_BASE]: ipcBaseDir, - [SIDECAR_ENV.NAMESPACE]: namespace, + [SIDECAR_ENV.IPC_PATH]: socketPath, }, timeoutMs: 1000, }); diff --git a/apps/daemon/tests/mcp-install-info.test.ts b/apps/daemon/tests/mcp-install-info.test.ts index 50cc1499f..cc7859896 100644 --- a/apps/daemon/tests/mcp-install-info.test.ts +++ b/apps/daemon/tests/mcp-install-info.test.ts @@ -3,7 +3,7 @@ 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 { 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'; @@ -20,7 +20,7 @@ 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 + * non-sidecar daemon launches and custom transport endpoints without * mutating the real process env. */ env?: NodeJS.ProcessEnv; /** Stand-in for the daemon's resolved RUNTIME_DATA_DIR (issue #848). @@ -72,14 +72,7 @@ function makeInstallInfoApp({ cliPath, port, env = {}, dataDir }: InstallInfoOpt const isSidecarMode = sidecarIpcPath != null && sidecarIpcPath.length > 0; const sidecarEnv: Record = {}; 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; - } + sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath; } const payload = buildMcpInstallPayload({ cliPath, @@ -249,12 +242,11 @@ describe('GET /api/mcp/install-info', () => { expect(after - before).toBeLessThanOrEqual(1); }); - it('sidecar default namespace omits --daemon-url and emits only OD_DATA_DIR', async () => { + it('sidecar launch omits --daemon-url and emits the concrete IPC path with 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, ); @@ -262,41 +254,37 @@ describe('GET /api/mcp/install-info', () => { const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`); const body = await readInstallInfo(res); 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((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 readInstallInfo(res); - 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', + [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/default/daemon.sock', }); } finally { await new Promise((done) => server?.close(() => done())); } }); - it('sidecar with custom IPC base propagates OD_SIDECAR_IPC_BASE alongside OD_DATA_DIR', async () => { + it('sidecar non-default endpoint still propagates only the concrete IPC path', async () => { + const { port, server } = await startHarness( + cliPath, + { + [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock', + }, + dataDir, + ); + try { + const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`); + const body = await readInstallInfo(res); + expect(body.args).toEqual([cliPath, 'mcp']); + expect(body.env).toEqual({ + OD_DATA_DIR: dataDir, + [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock', + }); + } finally { + await new Promise((done) => server?.close(() => done())); + } + }); + + it('sidecar with custom IPC base does not propagate namespace or base hints', async () => { const { port, server } = await startHarness( cliPath, { @@ -311,8 +299,7 @@ describe('GET /api/mcp/install-info', () => { const body = await readInstallInfo(res); expect(body.env).toEqual({ OD_DATA_DIR: dataDir, - [SIDECAR_ENV.NAMESPACE]: 'foo', - [SIDECAR_ENV.IPC_BASE]: '/var/run/open-design', + [SIDECAR_ENV.IPC_PATH]: '/var/run/open-design/foo/daemon.sock', }); } finally { await new Promise((done) => server?.close(() => done())); diff --git a/apps/daemon/tests/plugins-apply.test.ts b/apps/daemon/tests/plugins-apply.test.ts index 3c320ef82..dd4defb21 100644 --- a/apps/daemon/tests/plugins-apply.test.ts +++ b/apps/daemon/tests/plugins-apply.test.ts @@ -6,6 +6,7 @@ // - Throw MissingInputError when a required input is absent. import { describe, expect, it } from 'vitest'; +import path from 'node:path'; import { applyPlugin, MissingInputError } from '../src/plugins/apply.js'; import { defaultRegistryRoots } from '../src/plugins/registry.js'; import { TRUSTED_DEFAULT_CAPABILITIES } from '../src/plugins/trust.js'; @@ -119,7 +120,8 @@ describe('applyPlugin', () => { it('does not require a registry roots argument (no FS access at apply time)', () => { // Sanity: the function must not reach for the on-disk plugin folder. const roots = defaultRegistryRoots(); - expect(roots.userPluginsRoot).toMatch(/\.open-design[\/\\]plugins$/); + const expectedDataDir = path.resolve(process.env.OD_DATA_DIR ?? path.join(process.cwd(), '.od')); + expect(roots.userPluginsRoot).toBe(path.join(expectedDataDir, 'plugins')); const result = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY }); expect(result.result.appliedPlugin.pluginId).toBe('sample-plugin'); }); diff --git a/apps/daemon/tests/plugins-patch-edit.test.ts b/apps/daemon/tests/plugins-patch-edit.test.ts index 26eaf6050..3f2fa10e2 100644 --- a/apps/daemon/tests/plugins-patch-edit.test.ts +++ b/apps/daemon/tests/plugins-patch-edit.test.ts @@ -145,6 +145,20 @@ describe('applyPatchForStep — safety guards', () => { expect(result.reason).toMatch(/unsafe path/); }); + it('refuses Windows absolute targets', async () => { + await seedPlan([ + { id: 'naughty', files: ['C:/escape.ts'], rationale: '', risk: 'low', status: 'pending' }, + ]); + const diff = `--- /dev/null ++++ b/C:/escape.ts +@@ -0,0 +1,1 @@ ++x +`; + const result = await applyPatchForStep({ cwd, stepId: 'naughty', diff }); + expect(result.status).toBe('failed'); + expect(result.reason).toMatch(/unsafe path/); + }); + it('rejects context mismatches (stale patch detection)', async () => { await seedPlan([ { id: 'rewrite-x', files: ['x.ts'], rationale: '', risk: 'low', status: 'pending' }, diff --git a/apps/daemon/tests/plugins-upgrade.test.ts b/apps/daemon/tests/plugins-upgrade.test.ts index 2422047bb..34270a48b 100644 --- a/apps/daemon/tests/plugins-upgrade.test.ts +++ b/apps/daemon/tests/plugins-upgrade.test.ts @@ -68,7 +68,7 @@ describe('od plugin upgrade — installer round-trip', () => { await writeSource('1.0.1', { description: 'with notes' }); // Upgrade re-runs the installer against the recorded source. - await drain(installPlugin(db, { source: before!.source }) as AsyncGenerator); + await drain(installPlugin(db, { source: before!.source, roots: { userPluginsRoot: pluginsRoot } }) as AsyncGenerator); const after = getInstalledPlugin(db, 'upgrade-fixture'); expect(after?.version).toBe('1.0.1'); expect(after?.manifest.description).toBe('with notes'); @@ -77,7 +77,7 @@ describe('od plugin upgrade — installer round-trip', () => { it('re-running with the same source on disk is idempotent on the registry version', async () => { await drain(installFromLocalFolder(db, { source: sourceFolder, roots: { userPluginsRoot: pluginsRoot } }) as AsyncGenerator); const before = getInstalledPlugin(db, 'upgrade-fixture'); - await drain(installPlugin(db, { source: before!.source }) as AsyncGenerator); + await drain(installPlugin(db, { source: before!.source, roots: { userPluginsRoot: pluginsRoot } }) as AsyncGenerator); const after = getInstalledPlugin(db, 'upgrade-fixture'); expect(after?.version).toBe(before!.version); expect(after?.id).toBe(before!.id); @@ -87,7 +87,7 @@ describe('od plugin upgrade — installer round-trip', () => { await drain(installFromLocalFolder(db, { source: sourceFolder, roots: { userPluginsRoot: pluginsRoot } }) as AsyncGenerator); const before = getInstalledPlugin(db, 'upgrade-fixture'); await writeSource('1.0.2'); - await drain(installPlugin(db, { source: before!.source }) as AsyncGenerator); + await drain(installPlugin(db, { source: before!.source, roots: { userPluginsRoot: pluginsRoot } }) as AsyncGenerator); // The on-disk manifest the installer just wrote should match the // one in the SQLite row. diff --git a/apps/packaged/src/headless.ts b/apps/packaged/src/headless.ts index 3c827e7b4..442086b99 100644 --- a/apps/packaged/src/headless.ts +++ b/apps/packaged/src/headless.ts @@ -15,7 +15,7 @@ import { } from "@open-design/sidecar-proto"; import { bootstrapSidecarRuntime, createJsonIpcServer, resolveAppIpcPath } from "@open-design/sidecar"; -import type { PackagedConfig } from "./config.js"; +import { PACKAGED_NAMESPACE_ENV, type PackagedConfig } from "./config.js"; import { writePackagedDesktopIdentity, writePackagedWebIdentity } from "./identity.js"; import { resolvePackagedNamespacePaths } from "./paths.js"; import { startPackagedSidecars } from "./sidecars.js"; @@ -38,9 +38,7 @@ function resolveHeadlessNamespaceBaseRoot(): string { function resolveHeadlessConfig(): PackagedConfig { const namespace = OPEN_DESIGN_SIDECAR_CONTRACT.normalizeNamespace( - process.env.OD_NAMESPACE ?? - process.env.OD_SIDECAR_NAMESPACE ?? - SIDECAR_DEFAULTS.namespace, + process.env[PACKAGED_NAMESPACE_ENV] ?? SIDECAR_DEFAULTS.namespace, ); const namespaceBaseRoot = resolveHeadlessNamespaceBaseRoot(); diff --git a/docs/plans/plugins-implementation.md b/docs/plans/plugins-implementation.md index 3ce741154..6df5c7a7c 100644 --- a/docs/plans/plugins-implementation.md +++ b/docs/plans/plugins-implementation.md @@ -375,7 +375,7 @@ Deliverables - [x] `od daemon start --headless` flag (no electron, no web bundle). - [x] `od daemon start --serve-web` flag (web UI without electron). Today this is an alias of `--headless` because the v1 daemon serves both API and web UI from the same Express app; the flag is reserved so packaged callers can branch on it. -- [x] Honor `OD_BIND_HOST`, `OD_PORT`, `OD_NAMESPACE` in headless mode (the flags forward into the env so the existing daemon code path picks them up unchanged). +- [x] Honor `OD_BIND_HOST` and `OD_PORT` in headless mode (the flags forward into the env so the existing daemon code path picks them up unchanged). - [x] `od daemon stop`, `od daemon status --json`. Validation @@ -549,7 +549,7 @@ These were originally spec §18 open questions; they are now resolved and propag v1 ships when **all** of the following pass on a clean Linux CI container without electron. Each row links to the daemon / e2e test path that asserts it. - [x] **e2e-1 cold install** — `od plugin install ./fixtures/sample-plugin` → - - `~/.open-design/plugins/sample-plugin/` exists. + - `/plugins/sample-plugin/` exists. - `installed_plugins` has one row with `trust='restricted'`, `source_kind='local'`. - Test path: `apps/daemon/tests/plugins-e2e-fixture.test.ts` - [x] **e2e-2 pure apply** — two consecutive applies share `manifestSourceDigest`; the project cwd byte size is unchanged; `applied_plugin_snapshots` is not written by `applyPlugin()` itself (the resolver is the writer). diff --git a/docs/plugins-spec.md b/docs/plugins-spec.md index 025a5ca3a..d7d3073dc 100644 --- a/docs/plugins-spec.md +++ b/docs/plugins-spec.md @@ -450,12 +450,12 @@ Multiple marketplaces coexist — the user runs `od marketplace add ` to re | -------- | ------------------------------------------------ | ------------------ | ---------------------------------------------------------------------- | | 1 | `/.open-design/plugins//` | plugin bundle | New; explicitly installed into the project and committed with user code | | 2 | `/.claude/skills//` | legacy `SKILL.md` | Keeps the project-private skill path from [`skills-protocol.md`](skills-protocol.md) compatible | -| 3 | `~/.open-design/plugins//` | plugin bundle | New; written by `od plugin install` | +| 3 | `/plugins//` | plugin bundle | New; written by `od plugin install` under the daemon data root | | 4 | `~/.open-design/skills//` | legacy `SKILL.md` | OD canonical skill install path; may symlink into other agents | | 5 | `~/.claude/skills//` | legacy `SKILL.md` | Compatibility scan for external Claude Code / skills tooling | | 6 | repo root `skills/`, `design-systems/`, `craft/` | bundled resources | Existing first-party resources, unchanged | -Conflict resolution uses normalized `name` / plugin id; lower numeric priority wins. Legacy `SKILL.md` locations are synthesized into plugin records by the adapter, but are not copied into `~/.open-design/plugins/` unless the user explicitly runs `od plugin install`. This keeps existing Claude skills zero-config while giving plugin bundles a clear install root. +Conflict resolution uses normalized `name` / plugin id; lower numeric priority wins. Legacy `SKILL.md` locations are synthesized into plugin records by the adapter, but are not copied into `/plugins/` unless the user explicitly runs `od plugin install`. This keeps existing Claude skills zero-config while giving plugin bundles a clear install root. ### 7.2 Install sources @@ -1389,12 +1389,12 @@ od atoms list [--json] # first-party atoms ( #### Daemon control (new) ``` -od daemon start [--headless] [--serve-web] [--port ] [--host ] [--namespace ] +od daemon start [--headless] [--serve-web] [--port ] [--host ] # explicit lifecycle (§11.7); # default `od` (no args) keeps current behavior -od daemon stop [--namespace ] +od daemon stop [--daemon-url ] od daemon status [--json] # alias of `od status` -od status [--json] # daemon up? port? namespace? installed plugins count +od status [--json] # daemon up? port? installed plugins count od doctor # diagnostics: skills/DS/craft/plugins, providers, MCP od version [--json] od config get|set|list|unset [--key ...] [--value ...] # backed by media-config.json + db @@ -1428,7 +1428,7 @@ od mcp live-artifacts # specialized MCP server | Exit | Meaning | Recovery hint | Structured stderr `data` (excerpt) | | --- | --- | --- | --- | -| 64 | Daemon not running | `od status`, then start daemon | `{ host, port, namespace }` | +| 64 | Daemon not running | `od status`, then start daemon | `{ host, port }` | | 65 | Plugin not found / not installed | `od plugin list` then `od plugin install ` | `{ pluginId, candidateSources[] }` | | 66 | Plugin restricted, capability required | `od plugin trust --caps …` or retry with `--grant-caps …` | `{ pluginId, pluginVersion, required[], granted[], remediation[] }` | | 67 | Required input missing on apply | re-run with `--input k=v` for each missing field | `{ pluginId, missing[], schema }` | @@ -1617,7 +1617,7 @@ Three paths the operator should mount as volumes; they map onto existing OD env | Mount path | Env var | Purpose | | ----------------- | ------------------------ | ------------------------------------------------------ | -| `/data/od` | `OD_DATA_DIR` | Projects, SQLite, artifacts, installed plugins (`.od/` and `~/.open-design/plugins/` collapse here) | +| `/data/od` | `OD_DATA_DIR` | Projects, SQLite, artifacts, installed plugins (`/plugins`) | | `/data/config` | `OD_MEDIA_CONFIG_DIR` | Provider credentials (`media-config.json`) | | `/data/marketplaces` | (under `OD_DATA_DIR`) | Cached marketplace indexes | @@ -1632,7 +1632,6 @@ OD_PORT=17456 OD_BIND_HOST=0.0.0.0 # the variable the daemon already reads ([`apps/daemon/src/server.ts`](../apps/daemon/src/server.ts)) OD_DATA_DIR=/data/od OD_MEDIA_CONFIG_DIR=/data/config -OD_NAMESPACE=production # multi-tenant isolation key OD_TRUST_DEFAULT=restricted # safe default for hosted (§9) — introduced in Phase 5 OD_AGENT_BACKEND=claude # default code agent backend OD_API_TOKEN= # required when OD_BIND_HOST != 127.0.0.1 — Phase 5 introduces the bearer middleware @@ -1755,7 +1754,7 @@ Phase 1 contents (merges the original Phase 1 with the minimum subset of the ori - Endpoints: `GET /api/plugins`, `GET /api/plugins/:id`, `POST /api/plugins/install` (folder + github tarball), `POST /api/plugins/:id/uninstall`, `POST /api/plugins/:id/apply`, `GET /api/atoms`, `GET /api/applied-plugins/:snapshotId`. - **Plugin CLI verbs:** `od plugin install/list/info/uninstall/apply/doctor`. `od plugin apply --json` is required by Phase 2's inline rail and by external code agents, and must already return an `ApplyResult` containing `appliedPlugin: AppliedPluginSnapshot`. - **Headless MVP CLI loop (newly pulled forward):** `od project create/list/info`, `od run start/watch/cancel` (with `--follow` and ND-JSON streaming), `od files list/read`. These wrap endpoints already used by the desktop UI today (`POST /api/projects`, `POST /api/runs`, `GET /api/runs/:id/events`, project list/read endpoints) — no new HTTP surface, only CLI surface. -- `~/.open-design/plugins//` write path with safe extraction (path-traversal guard, size cap, symlink rejection). +- `/plugins//` write path with safe extraction (path-traversal guard, size cap, symlink rejection). Validation: @@ -1957,7 +1956,7 @@ Open questions worth confirming before code lands: - **`od plugin run` headless contract** — sufficient as-is, or also expose an HTTP POST endpoint for non-CLI agents? (Default: CLI only in v1; HTTP added in Phase 4 if needed.) - **Multi-tenant auth (per-user OAuth, RBAC, project ownership, billing)** is explicitly out of scope for v1. The Docker image is single-tenant by design (one `OD_API_TOKEN`). Multi-tenancy is a post-v1 story that needs its own spec — confirm this scoping is acceptable for the first ecosystem release. - **Trust propagation in hosted mode** — current spec locks arbitrary GitHub / URL / local plugins to `restricted` by default, and third-party marketplaces do not propagate trust by default. Confirm whether hosted deployments may trust individual plugins through `OD_TRUSTED_PLUGINS`, or whether operators must first trust the source marketplace. -- **Discovery-time hot reload** — should the daemon watch `~/.open-design/plugins/` for filesystem changes (developer ergonomics), or only reload after `od plugin install/update/uninstall` (stability)? (Default: watch, with a 500ms debounce.) +- **Discovery-time hot reload** — should the daemon watch `/plugins/` for filesystem changes (developer ergonomics), or only reload after `od plugin install/update/uninstall` (stability)? (Default: watch, with a 500ms debounce.) - **Versioning policy** — pin to a tag/SHA on install, or always track the default branch with an opt-in pin? (Default: pin to the resolved ref at install time; `od plugin update` re-resolves.) - ~~**When to lift the plugin prompt block into contracts**~~ — **resolved (PB1, see `docs/plans/plugins-implementation.md` §7).** Lift in Phase 2A as a pure `renderPluginBlock(snapshot)` function in `packages/contracts/src/prompts/plugin-block.ts`; both composers import it; v1 fallback rejection rule (§11.8) is preserved; Phase 4 turns on fallback support as a one-line wiring change. The Phase 1–4 byte-equality CI fixture is no longer needed. - ~~**`AppliedPluginSnapshot` retention**~~ — **resolved (PB2, see `docs/plans/plugins-implementation.md` §7).** Snapshots referenced by any run / conversation / project stay pinned forever (`expires_at = NULL`); unreferenced snapshots get `expires_at = applied_at + OD_SNAPSHOT_UNREFERENCED_TTL_DAYS` (default `30`, `0` disables). The "expire even referenced rows" knob `OD_SNAPSHOT_RETENTION_DAYS` is operator-opt-in only (default unset), and applies only when the referencing row is terminal. The `expires_at` column lands in Phase 1 (§11.4); the GC worker lands in Phase 5 (§16). The `od plugin snapshots prune` CLI remains as a forced-cleanup escape hatch. @@ -2314,7 +2313,7 @@ Three categories cover everything OD does today: | --- | --- | --- | | **A. Userspace (movable to plugin)** | A first-party plugin's manifest + SKILL.md | atom prompt fragments, default reference pipelines, atom-bundled MCP servers, critique scoring axes, discovery question wording | | **B. Kernel (must stay in daemon)** | `apps/daemon/src/...` | snapshot SQLite writes, GenUI persistence + reuse, capability gate + tool-token issuance, devloop scheduler + `until` evaluator + ceiling, OAuth token storage, `composeSystemPrompt()` *as assembler*, project metadata block | -| **C. Already plugin-driven in v1** | Already in `~/.open-design/plugins/...` or per-project plugin folders | Active SKILL.md, DESIGN.md, craft .md, plugin-declared MCP servers, plugin-declared GenUI surfaces, plugin-declared pipelines | +| **C. Already plugin-driven in v1** | Already in `/plugins/...` or per-project plugin folders | Active SKILL.md, DESIGN.md, craft .md, plugin-declared MCP servers, plugin-declared GenUI surfaces, plugin-declared pipelines | The category-B list is the **kernel boundary**: not "these things would be hard to plugin-ize", but "these things must stay in the daemon for security, persistence, or runtime-state reasons." A plugin runtime that lets plugins write `applied_plugin_snapshots` rows or issue connector tool tokens is a broken runtime. @@ -2383,7 +2382,7 @@ The `bundled` tier is what makes patches 2 and 3 safe: the daemon does not capab Daemon startup adds a step: 1. Walk `/plugins/_official/**` and register every plugin under `installed_plugins.source_kind='bundled'`, `trust='bundled'`, capabilities = the plugin's declared `od.capabilities`. -2. Bundled plugins are not copied into `~/.open-design/plugins/`; they live and reload from the repo path so daemon upgrades replace them in lockstep with daemon code. +2. Bundled plugins are not copied into `/plugins/`; they live and reload from the repo path so daemon upgrades replace them in lockstep with daemon code. 3. `od plugin uninstall` refuses to uninstall a `bundled` plugin (would break the daemon); `od plugin update` is a no-op for bundled. 4. A user may install a `trusted` or `restricted` plugin with the same id as a bundled one; the user-installed copy wins for normal apply, but the daemon retains the bundled copy as a fallback for replays of older `AppliedPluginSnapshot` rows that pinned the bundled version. diff --git a/docs/plugins-spec.zh-CN.md b/docs/plugins-spec.zh-CN.md index 35e3c964e..b79084317 100644 --- a/docs/plugins-spec.zh-CN.md +++ b/docs/plugins-spec.zh-CN.md @@ -450,12 +450,12 @@ Marketplace 顶层 `version` 是 catalog snapshot 版本;每个 `plugins[]` en | --- | --- | --- | --- | | 1 | `/.open-design/plugins//` | plugin bundle | 新增,与用户代码一起提交;必须显式安装到 project | | 2 | `/.claude/skills//` | legacy `SKILL.md` | 沿用 [`skills-protocol.md`](skills-protocol.md) 的 project-private skill 兼容路径 | -| 3 | `~/.open-design/plugins//` | plugin bundle | 新增,由 `od plugin install` 写入 | +| 3 | `/plugins//` | plugin bundle | 新增,由 `od plugin install` 写入 daemon data root | | 4 | `~/.open-design/skills//` | legacy `SKILL.md` | OD canonical skill install path;可 symlink 到其它 agent | | 5 | `~/.claude/skills//` | legacy `SKILL.md` | 外部 Claude Code / skills 工具写入的兼容路径,只读扫描 | | 6 | repo root `skills/`, `design-systems/`, `craft/` | bundled resources | 现有一方资源,不变 | -冲突解决:按 normalized `name` / plugin id;数字越小优先级越高。legacy `SKILL.md` location 被 adapter 合成为 plugin record,但不会被复制到 `~/.open-design/plugins/`,除非用户显式执行 `od plugin install`。这样现有 Claude skills 继续零配置可用,新 plugin bundle 也有清晰的安装根。 +冲突解决:按 normalized `name` / plugin id;数字越小优先级越高。legacy `SKILL.md` location 被 adapter 合成为 plugin record,但不会被复制到 `/plugins/`,除非用户显式执行 `od plugin install`。这样现有 Claude skills 继续零配置可用,新 plugin bundle 也有清晰的安装根。 ### 7.2 安装源 @@ -1387,12 +1387,12 @@ od atoms list [--json] # first-party atoms ( #### Daemon control(新增) ``` -od daemon start [--headless] [--serve-web] [--port ] [--host ] [--namespace ] +od daemon start [--headless] [--serve-web] [--port ] [--host ] # explicit lifecycle (§11.7); # default `od` (no args) keeps current behavior -od daemon stop [--namespace ] +od daemon stop [--daemon-url ] od daemon status [--json] # alias of `od status` -od status [--json] # daemon up? port? namespace? installed plugins count +od status [--json] # daemon up? port? installed plugins count od doctor # diagnostics: skills/DS/craft/plugins, providers, MCP od version [--json] od config get|set|list|unset [--key ...] [--value ...] # backed by media-config.json + db @@ -1426,7 +1426,7 @@ od mcp live-artifacts # specialized MCP server | Exit | Meaning | Recovery hint | Structured stderr `data` 字段(节选) | | --- | --- | --- | --- | -| 64 | Daemon not running | `od status`,然后启动 daemon | `{ host, port, namespace }` | +| 64 | Daemon not running | `od status`,然后启动 daemon | `{ host, port }` | | 65 | Plugin not found / not installed | `od plugin list` 后 `od plugin install ` | `{ pluginId, candidateSources[] }` | | 66 | Plugin restricted, capability required | `od plugin trust --caps …` 或重试时加 `--grant-caps …` | `{ pluginId, pluginVersion, required[], granted[], remediation[] }` | | 67 | Required input missing on apply | 用每个缺失字段重新运行 `--input k=v` | `{ pluginId, missing[], schema }` | @@ -1615,7 +1615,7 @@ operator 应挂载三个 volumes;它们映射到根 [`AGENTS.md`](../AGENTS.md | Mount path | Env var | Purpose | | --- | --- | --- | -| `/data/od` | `OD_DATA_DIR` | Projects、SQLite、artifacts、installed plugins(`.od/` 与 `~/.open-design/plugins/` 合并到这里) | +| `/data/od` | `OD_DATA_DIR` | Projects、SQLite、artifacts、installed plugins(`/plugins`) | | `/data/config` | `OD_MEDIA_CONFIG_DIR` | Provider credentials(`media-config.json`) | | `/data/marketplaces` | (位于 `OD_DATA_DIR` 下) | Cached marketplace indexes | @@ -1630,7 +1630,6 @@ OD_PORT=17456 OD_BIND_HOST=0.0.0.0 # 当前 daemon 已读取的变量名([`apps/daemon/src/server.ts`](../apps/daemon/src/server.ts)) OD_DATA_DIR=/data/od OD_MEDIA_CONFIG_DIR=/data/config -OD_NAMESPACE=production # multi-tenant isolation key OD_TRUST_DEFAULT=restricted # safe default for hosted (§9) — Phase 5 引入 OD_AGENT_BACKEND=claude # default code agent backend OD_API_TOKEN= # required when OD_BIND_HOST != 127.0.0.1 — Phase 5 引入 bearer middleware @@ -1750,7 +1749,7 @@ Phase 1 内容(合并原 Phase 1 + Phase 2C 的最小子集): - Endpoints:`GET /api/plugins`、`GET /api/plugins/:id`、`POST /api/plugins/install`(folder + github tarball)、`POST /api/plugins/:id/uninstall`、`POST /api/plugins/:id/apply`、`GET /api/atoms`、`GET /api/applied-plugins/:snapshotId`。 - **Plugin CLI verbs:** `od plugin install/list/info/uninstall/apply/doctor`。`od plugin apply --json` 是 Phase 2 inline rail 与外部 code agents 的必要能力,必须在此返回包含 `appliedPlugin: AppliedPluginSnapshot` 的 `ApplyResult`。 - **Headless MVP CLI 闭环(新提前):** `od project create/list/info`、`od run start/watch/cancel`(含 `--follow` 与 ND-JSON streaming)、`od files list/read`。这些包装 desktop UI 今天已经使用的 endpoints(`POST /api/projects`、`POST /api/runs`、`GET /api/runs/:id/events`、project list/read endpoints),不新增 HTTP surface,只新增 CLI surface。 -- `~/.open-design/plugins//` 写入路径,带 safe extraction(path-traversal guard、size cap、symlink rejection)。 +- `/plugins//` 写入路径,带 safe extraction(path-traversal guard、size cap、symlink rejection)。 Validation: @@ -1952,7 +1951,7 @@ installer 会把 nested skills/design-systems/craft fan out 到 registry 的 nam - **`od plugin run` headless contract**:当前是否足够,还是也为非 CLI agents 暴露 HTTP POST endpoint?(默认:v1 只 CLI;Phase 4 如需要再补 HTTP。) - **Multi-tenant auth(per-user OAuth、RBAC、project ownership、billing)** 明确不属于 v1。Docker image 设计为 single-tenant(一个 `OD_API_TOKEN`)。Multi-tenancy 是 post-v1 story,需要单独 spec。请确认 first ecosystem release 这个 scope 是否可接受。 - **Hosted mode 的 trust propagation**:当前 spec 已锁定 arbitrary GitHub / URL / local 插件默认 `restricted`,第三方 marketplace 也默认不传递 trust。还需确认 hosted deployments 是否允许 operator 通过 `OD_TRUSTED_PLUGINS` 直接 trust 单个插件,还是必须先 trust 它所属 marketplace。 -- **Discovery-time hot reload**:daemon 是否 watch `~/.open-design/plugins/`(开发体验好),还是只在 `od plugin install/update/uninstall` 后 reload(稳定性高)?(默认:watch,500ms debounce。) +- **Discovery-time hot reload**:daemon 是否 watch `/plugins/`(开发体验好),还是只在 `od plugin install/update/uninstall` 后 reload(稳定性高)?(默认:watch,500ms debounce。) - **Versioning policy**:安装时 pin tag/SHA,还是默认跟踪 default branch 并提供 opt-in pin?(默认:安装时 pin resolved ref;`od plugin update` 重新 resolve。) - ~~**Plugin prompt block 提到 contracts 共享层的时机**~~:**已按 PB1 解决。** Phase 2A 已把 `renderPluginBlock(snapshot)` 放到 `packages/contracts/src/prompts/plugin-block.ts`;两份 composer import 同一函数;v1 fallback rejection 规则保留。 - ~~**`AppliedPluginSnapshot` 留存策略**~~:**已按 PB2 解决。** 被 run / conversation / project 引用的 snapshot 永久 pin;未引用 snapshot 默认 30 天过期;`OD_SNAPSHOT_RETENTION_DAYS` 是 operator opt-in;`od plugin snapshots prune` 保留为强制清理出口。 @@ -2309,7 +2308,7 @@ OD 今天做的事可以归到三类: | --- | --- | --- | | **A. 用户态(可移到 plugin)** | 一方 plugin 的 manifest + SKILL.md | atom prompt fragment、默认 reference pipeline、atom 自带的 MCP server、critique 评分坐标、discovery 提问措辞 | | **B. 内核(必须留在 daemon)** | `apps/daemon/src/...` | snapshot SQLite 写表、GenUI 持久化与复用、capability gate + tool-token 颁发、devloop scheduler + `until` 求值 + ceiling、OAuth token 存储、`composeSystemPrompt()` *作为 assembler*、project metadata block | -| **C. v1 已经是 plugin 驱动** | 已经在 `~/.open-design/plugins/...` 或 per-project plugin 文件夹 | 当前 SKILL.md、DESIGN.md、craft .md、plugin 声明的 MCP server、plugin 声明的 GenUI surface、plugin 声明的 pipeline | +| **C. v1 已经是 plugin 驱动** | 已经在 `/plugins/...` 或 per-project plugin 文件夹 | 当前 SKILL.md、DESIGN.md、craft .md、plugin 声明的 MCP server、plugin 声明的 GenUI surface、plugin 声明的 pipeline | B 类的清单是**内核边界**:不是"难以 plugin 化",而是"出于安全、持久化、运行时状态原因必须留在 daemon"。一个让 plugin 直接写 `applied_plugin_snapshots` 行或颁发 connector tool token 的 plugin runtime 是坏掉的 runtime。 @@ -2378,7 +2377,7 @@ Bundled scenario plugins 与 pipeline fallback resolver 现在已经存在。剩 daemon 启动多一步: 1. 走 `/plugins/_official/**`,把每个 plugin 注册到 `installed_plugins` 中:`source_kind='bundled'`、`trust='bundled'`、capabilities = 该 plugin 声明的 `od.capabilities`。 -2. bundled plugin **不**复制到 `~/.open-design/plugins/`;它们直接从 repo path 加载并热重载,daemon 升级时与 daemon 代码同步替换。 +2. bundled plugin **不**复制到 `/plugins/`;它们直接从 repo path 加载并热重载,daemon 升级时与 daemon 代码同步替换。 3. `od plugin uninstall` 拒绝 uninstall `bundled` plugin(会让 daemon 失能);`od plugin update` 对 bundled 是 no-op。 4. 用户可以安装一个 id 与 bundled 相同的 `trusted` 或 `restricted` plugin;正常 apply 时用户 copy 胜出,但 daemon 保留 bundled copy 作为 fallback,给那些 pin 了 bundled 版本的旧 `AppliedPluginSnapshot` replay 用。 diff --git a/e2e/lib/vitest/tools-dev.ts b/e2e/lib/vitest/tools-dev.ts index 3cc841a30..fc02659a9 100644 --- a/e2e/lib/vitest/tools-dev.ts +++ b/e2e/lib/vitest/tools-dev.ts @@ -5,6 +5,8 @@ import { promisify } from 'node:util'; import { e2eWorkspaceRoot, type SmokeSuite } from './smoke-suite.ts'; const execFileAsync = promisify(execFile); +const pnpmCommand = process.env.OD_E2E_PNPM_COMMAND ?? 'pnpm'; +const pnpmExecPath = process.env.npm_execpath; export type ToolsDevAppStatus = { pid?: number; @@ -138,7 +140,13 @@ export async function readToolsDevLogs(suite: SmokeSuite): Promise(suite: SmokeSuite, args: string[]): Promise { - const { stdout } = await execFileAsync('pnpm', ['tools-dev', ...args], { + const command = process.env.OD_E2E_PNPM_COMMAND == null && pnpmExecPath + ? process.execPath + : pnpmCommand; + const commandArgs = command === process.execPath && process.env.OD_E2E_PNPM_COMMAND == null && pnpmExecPath + ? [pnpmExecPath, 'tools-dev', ...args] + : ['tools-dev', ...args]; + const { stdout } = await execFileAsync(command, commandArgs, { cwd: e2eWorkspaceRoot(), env: { ...process.env, @@ -147,6 +155,7 @@ async function runToolsDevJson(suite: SmokeSuite, args: string[]): Promise OD_MEDIA_CONFIG_DIR: suite.dataDir, }, maxBuffer: 20 * 1024 * 1024, + shell: process.platform === 'win32' && command !== process.execPath, }); return parseJsonOutput(stdout); } diff --git a/e2e/specs/namespace/main.spec.ts b/e2e/specs/namespace/main.spec.ts new file mode 100644 index 000000000..280466a00 --- /dev/null +++ b/e2e/specs/namespace/main.spec.ts @@ -0,0 +1,235 @@ +// @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/smoke-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; +}; + +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 concrete IPC transport', 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(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(webUrl, '/api/mcp/install-info'); + const ipcPath = installInfo.env.OD_SIDECAR_IPC_PATH; + if (typeof ipcPath !== 'string' || ipcPath.length === 0) { + throw new Error('MCP install-info did not include OD_SIDECAR_IPC_PATH'); + } + expect(ipcPath).toEqual(expect.any(String)); + 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( + ['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_IPC_PATH: ipcPath, + 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_IPC_PATH: ipcPath }, + ); + 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(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(), + ipcPath, + }, + plugin: pluginInfo, + runtime, + toolsDevNamespace: status.namespace, + }); + }); + }, 180_000); +}); + +async function writeLocalPluginFixture(root: string): Promise { + 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(args: string[], env: Record): Promise { + const result = await runDaemonCli(args, env); + return parseJsonOutput(result.stdout); +} + +async function runDaemonCliExpectFailure( + args: string[], + env: Record, +): 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, +): 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(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}/`; +} diff --git a/scripts/seed-test-projects.ts b/scripts/seed-test-projects.ts index 31b27120e..15e9fde15 100644 --- a/scripts/seed-test-projects.ts +++ b/scripts/seed-test-projects.ts @@ -9,12 +9,15 @@ // pnpm seed:test-projects # default bundle // pnpm seed:test-projects --decks 2 --webs 2 # cap counts // pnpm seed:test-projects --daemon http://127.0.0.1:17456 +// pnpm seed:test-projects --namespace work-a # discover tools-dev namespace // pnpm seed:test-projects --offline # ingest into ./.od before boot // pnpm seed:test-projects --clear # remove previously seeded projects // // The daemon URL is resolved in this order: --daemon flag > $OD_DAEMON_URL > // http://127.0.0.1:$OD_PORT > whatever `pnpm tools-dev status --json` reports -// for the daemon app. The discovery step is what makes the two-shell flow +// for the daemon app. --namespace is only passed to that tools-dev discovery +// step; it is not forwarded to the od CLI or stored in daemon data. The +// discovery step is what makes the two-shell flow // (`pnpm tools-dev` then `pnpm seed:test-projects`) work without extra flags, // because tools-dev defaults to an ephemeral daemon port that isn't exported // to sibling shells. @@ -224,6 +227,7 @@ const COMMUNITY_PLUGINS: SeedFixture[] = [ interface Args { daemonUrl: string | null; dataDir: string | null; + namespace: string | null; mode: SeedMode; decks: number; webs: number; @@ -236,6 +240,7 @@ function parseArgs(argv: string[]): Args { const out: Args = { daemonUrl: null, dataDir: null, + namespace: null, mode: 'auto', decks: DECKS.length, webs: WEBS.length, @@ -259,6 +264,13 @@ function parseArgs(argv: string[]): Args { process.exit(2); } out.dataDir = value; + } else if (a === '--namespace') { + const value = argv[++i]; + if (!value) { + console.error('--namespace requires a name argument'); + process.exit(2); + } + out.namespace = value; } else if (a === '--mode') { const value = argv[++i]; if (value !== 'auto' && value !== 'online' && value !== 'offline') { @@ -317,6 +329,9 @@ Options: --online Alias for --mode online. --offline Alias for --mode offline. --data-dir Offline target data dir (default: \$OD_DATA_DIR or ./.od). + --namespace Tools-dev namespace for online auto-discovery. This does + not affect od CLI behavior. Offline mode requires + --data-dir or OD_DATA_DIR when --namespace is set. --decks Number of slide decks to seed (default: ${DECKS.length}, max: ${DECKS.length}) --webs Number of web prototypes to seed (default: ${WEBS.length}, max: ${WEBS.length}) --default-plugins @@ -337,6 +352,8 @@ Online daemon URL resolution (first match wins): extra flags: pnpm tools-dev # in one shell pnpm seed:test-projects # in another — discovers the running daemon + For a non-default tools-dev namespace, pass \`--namespace \` so the + status lookup reads that namespace. Offline ingest before boot: pnpm seed:test-projects --offline --data-dir ./.od @@ -353,7 +370,7 @@ function isDiscoverablePort(value: string | undefined): value is string { return Number.isInteger(n) && n > 0 && n < 65536; } -async function discoverDaemonUrlFromToolsDev(): Promise { +async function discoverDaemonUrlFromToolsDev(namespace: string | null): Promise { return await new Promise((resolve) => { let child; try { @@ -361,7 +378,9 @@ async function discoverDaemonUrlFromToolsDev(): Promise { // engine" when the local node version doesn't match the repo's // engines.node). Without it, those warnings land on stdout under a // nested pnpm context and break the JSON parse below. - child = spawn('pnpm', ['--silent', 'exec', 'tools-dev', 'status', '--json'], { + const statusArgs = ['--silent', 'exec', 'tools-dev', 'status', '--json']; + if (namespace) statusArgs.push('--namespace', namespace); + child = spawn('pnpm', statusArgs, { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -410,7 +429,7 @@ async function resolveDaemonUrl(args: Args, { required }: { required: boolean }) if (isDiscoverablePort(process.env.OD_PORT)) { return `http://127.0.0.1:${process.env.OD_PORT}`; } - const discovered = await discoverDaemonUrlFromToolsDev(); + const discovered = await discoverDaemonUrlFromToolsDev(args.namespace); if (discovered) return discovered; if (!required) return null; throw new Error( @@ -576,6 +595,16 @@ function resolveDataDir(raw: string | null): string { return path.isAbsolute(expanded) ? expanded : path.resolve(REPO_ROOT, expanded); } +function assertOfflineDataDirIsExplicit(args: Args): void { + if (args.namespace && !args.dataDir && !process.env.OD_DATA_DIR) { + throw new Error( + '--namespace is only a tools-dev discovery selector. Offline mode with ' + + '--namespace requires --data-dir or OD_DATA_DIR so the script does not ' + + 'guess a namespace-scoped daemon data directory.', + ); + } +} + function loadBetterSqlite(): new (filename: string) => OfflineDatabase { const daemonRequire = createRequire(path.join(REPO_ROOT, 'apps', 'daemon', 'package.json')); return daemonRequire('better-sqlite3') as new (filename: string) => OfflineDatabase; @@ -664,6 +693,7 @@ function migrateOffline(db: OfflineDatabase): void { } async function openOfflineSeedContext(args: Args): Promise { + assertOfflineDataDirIsExplicit(args); const dataDir = resolveDataDir(args.dataDir); await mkdir(dataDir, { recursive: true }); const Database = loadBetterSqlite(); diff --git a/tools/pack/docker-compose.yml b/tools/pack/docker-compose.yml index 7cc369eb3..eae29e6c7 100644 --- a/tools/pack/docker-compose.yml +++ b/tools/pack/docker-compose.yml @@ -39,7 +39,6 @@ services: # exposing to the public internet. Phase 5 wires the daemon to # refuse OD_BIND_HOST=0.0.0.0 without OD_API_TOKEN. OD_API_TOKEN: ${OD_API_TOKEN:-} - OD_NAMESPACE: ${OD_NAMESPACE:-production} # Snapshot retention knobs (spec §11.4 PB2). Default ttl is 30 # days; set to 0 to keep snapshots forever. OD_SNAPSHOT_UNREFERENCED_TTL_DAYS: ${OD_SNAPSHOT_UNREFERENCED_TTL_DAYS:-30} diff --git a/tools/pack/helm/open-design/values.yaml b/tools/pack/helm/open-design/values.yaml index baf08c764..b9f625c7f 100644 --- a/tools/pack/helm/open-design/values.yaml +++ b/tools/pack/helm/open-design/values.yaml @@ -41,7 +41,6 @@ env: OD_PORT: "7456" OD_DATA_DIR: /data/od OD_MEDIA_CONFIG_DIR: /data/config - OD_NAMESPACE: production OD_SNAPSHOT_UNREFERENCED_TTL_DAYS: "30" OD_SNAPSHOT_GC_INTERVAL_MS: "21600000" diff --git a/tools/pack/resources/web-standalone-after-pack.cjs b/tools/pack/resources/web-standalone-after-pack.cjs index 7ab70d711..950c74d05 100644 --- a/tools/pack/resources/web-standalone-after-pack.cjs +++ b/tools/pack/resources/web-standalone-after-pack.cjs @@ -183,16 +183,20 @@ async function copyOptional(sourcePath, destinationPath, options = {}) { return true; } -async function linkRelative(sourcePath, destinationPath) { +async function linkRelative(sourcePath, destinationPath, options = {}) { if (!(await pathExists(sourcePath))) return false; if (await pathLstatExists(destinationPath)) return false; await mkdir(path.dirname(destinationPath), { recursive: true }); + if (options.copyInsteadOfSymlink === true) { + await copyRequired(sourcePath, destinationPath, { dereference: true }); + return true; + } const relativeTarget = path.relative(path.dirname(destinationPath), sourcePath); await symlink(relativeTarget.length === 0 ? "." : relativeTarget, destinationPath); return true; } -async function linkPnpmPublicHoist(destinationRoot) { +async function linkPnpmPublicHoist(destinationRoot, options = {}) { const nodeModulesRoot = path.join(destinationRoot, "node_modules"); const hoistRoot = path.join(nodeModulesRoot, ".pnpm", "node_modules"); const entries = await readdir(hoistRoot, { withFileTypes: true }).catch(() => []); @@ -205,13 +209,13 @@ async function linkPnpmPublicHoist(destinationRoot) { for (const scopedEntry of scopedEntries) { const scopedSource = path.join(sourcePath, scopedEntry); const scopedDestination = path.join(nodeModulesRoot, entry.name, scopedEntry); - if (await linkRelative(scopedSource, scopedDestination)) linked.push(scopedDestination); + if (await linkRelative(scopedSource, scopedDestination, options)) linked.push(scopedDestination); } continue; } const destinationPath = path.join(nodeModulesRoot, entry.name); - if (await linkRelative(sourcePath, destinationPath)) linked.push(destinationPath); + if (await linkRelative(sourcePath, destinationPath, options)) linked.push(destinationPath); } return linked; @@ -243,7 +247,7 @@ async function installStandaloneResource(config, resourcesRoot, platformName) { await copyRequired(path.join(sourceWebRoot, "server.js"), path.join(destinationWebRoot, "server.js")); await copyOptional(path.join(sourceWebRoot, "package.json"), path.join(destinationWebRoot, "package.json")); const copiedNestedNodeModules = await copyOptional(path.join(sourceWebRoot, "node_modules"), path.join(destinationWebRoot, "node_modules"), copyOptions); - const linkedHoistEntries = await linkPnpmPublicHoist(destinationRoot); + const linkedHoistEntries = await linkPnpmPublicHoist(destinationRoot, { copyInsteadOfSymlink: platformName === "win32" }); await copyRequired(path.join(sourceWebRoot, ".next"), path.join(destinationWebRoot, ".next")); const copiedStatic = await copyOptional(config.webStaticSourceRoot, path.join(destinationWebRoot, ".next", "static")); const copiedPublic = await copyOptional(config.webPublicSourceRoot, path.join(destinationWebRoot, "public")); diff --git a/tools/pack/src/cache.ts b/tools/pack/src/cache.ts index 96123eb5b..c9f9968bb 100644 --- a/tools/pack/src/cache.ts +++ b/tools/pack/src/cache.ts @@ -158,7 +158,7 @@ export class ToolPackCache { const nextManifest = manifest ?? await (async () => { status = existingManifest == null ? "miss" : "stale"; reason = invalidation?.reason ?? "missing manifest"; - const stagingPath = join(dirname(entryPath), `${basename(entryPath)}.tmp-${process.pid}-${randomUUID()}`); + const stagingPath = join(dirname(entryPath), `${basename(entryPath).slice(0, 12)}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`); await rm(stagingPath, { force: true, recursive: true }); await mkdir(stagingPath, { recursive: true }); try { diff --git a/tools/pack/src/linux.ts b/tools/pack/src/linux.ts index 7c91ef1c5..d2f12e979 100644 --- a/tools/pack/src/linux.ts +++ b/tools/pack/src/linux.ts @@ -38,6 +38,8 @@ const INTERNAL_PACKAGES = [ { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/platform", name: "@open-design/platform" }, + { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, + { directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" }, { directory: "apps/daemon", name: "@open-design/daemon" }, { directory: "apps/web", name: "@open-design/web" }, { directory: "apps/desktop", name: "@open-design/desktop" }, @@ -301,6 +303,8 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise { await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); await runPnpm(config, ["--filter", "@open-design/platform", "build"]); + await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]); + await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]); await runPnpm(config, ["--filter", "@open-design/daemon", "build"]); try { await runPnpm(config, ["--filter", "@open-design/web", "build"], { OD_WEB_OUTPUT_MODE: "server" }); @@ -1185,7 +1189,7 @@ export async function installPackedLinuxHeadless(config: ToolPackConfig): Promis const script = [ "#!/bin/sh", `# Open Design headless launcher — namespace: ${config.namespace}`, - `OD_NAMESPACE=${JSON.stringify(config.namespace)} OD_DATA_DIR=${JSON.stringify(dataDir)} OD_RESOURCE_ROOT=${JSON.stringify(paths.resourceRoot)} exec ${JSON.stringify(nodePath)} ${JSON.stringify(entryPath)} "$@"`, + `OD_PACKAGED_NAMESPACE=${JSON.stringify(config.namespace)} OD_DATA_DIR=${JSON.stringify(dataDir)} OD_RESOURCE_ROOT=${JSON.stringify(paths.resourceRoot)} exec ${JSON.stringify(nodePath)} ${JSON.stringify(entryPath)} "$@"`, ].join("\n") + "\n"; await writeFile(launcherPath, script, { encoding: "utf8", mode: 0o755 }); @@ -1226,9 +1230,9 @@ export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise< cwd: dirname(entryPath), env: { ...process.env, - // Bake in the namespace so headless uses the same namespace as the - // tools-pack config regardless of the caller's environment. - OD_NAMESPACE: config.namespace, + // Bake in the packaged namespace so headless uses the same namespace + // as the tools-pack config regardless of the caller's environment. + OD_PACKAGED_NAMESPACE: config.namespace, // Point the headless data root at the tools-pack runtime directory so // the identity marker is written to the path this function polls. // headless.ts computes: join(OD_DATA_DIR, "namespaces") which must diff --git a/tools/pack/src/mac-prebundle.ts b/tools/pack/src/mac-prebundle.ts index bc6d097d4..22217d761 100644 --- a/tools/pack/src/mac-prebundle.ts +++ b/tools/pack/src/mac-prebundle.ts @@ -19,7 +19,6 @@ export const MAC_PREBUNDLE_RUNTIME_DEPENDENCIES = { } as const; export const MAC_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES = [ - "@open-design/contracts", "@open-design/daemon", "@open-design/desktop", "@open-design/packaged", diff --git a/tools/pack/src/mac/constants.ts b/tools/pack/src/mac/constants.ts index c956dd986..ab4053232 100644 --- a/tools/pack/src/mac/constants.ts +++ b/tools/pack/src/mac/constants.ts @@ -5,6 +5,8 @@ export const INTERNAL_PACKAGES = [ { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/platform", name: "@open-design/platform" }, + { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, + { directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" }, { directory: "apps/daemon", name: "@open-design/daemon" }, { directory: "apps/web", name: "@open-design/web" }, { directory: "apps/desktop", name: "@open-design/desktop" }, diff --git a/tools/pack/src/win-prebundle.ts b/tools/pack/src/win-prebundle.ts index cb50167e1..670d97433 100644 --- a/tools/pack/src/win-prebundle.ts +++ b/tools/pack/src/win-prebundle.ts @@ -19,7 +19,6 @@ export const WIN_PREBUNDLE_RUNTIME_DEPENDENCIES = { } as const; export const WIN_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES = [ - "@open-design/contracts", "@open-design/daemon", "@open-design/desktop", "@open-design/packaged", diff --git a/tools/pack/src/win/app.ts b/tools/pack/src/win/app.ts index 3a07620a7..1968c136a 100644 --- a/tools/pack/src/win/app.ts +++ b/tools/pack/src/win/app.ts @@ -152,7 +152,7 @@ export async function createWorkspaceTarballsCacheKey(config: ToolPackConfig): P packageManager: rootPackageJson.packageManager, pnpmLock: await hashPath(join(config.workspaceRoot, "pnpm-lock.yaml")), prebundle: shouldUseWinStandalonePrebundle(config.webOutputMode), - schemaVersion: 5, + schemaVersion: 6, webOutputMode: config.webOutputMode, }); } diff --git a/tools/pack/src/win/constants.ts b/tools/pack/src/win/constants.ts index 8c7af23fb..946f78ee8 100644 --- a/tools/pack/src/win/constants.ts +++ b/tools/pack/src/win/constants.ts @@ -34,6 +34,8 @@ export const INTERNAL_PACKAGES = [ { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/platform", name: "@open-design/platform" }, + { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, + { directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" }, { directory: "apps/daemon", name: "@open-design/daemon" }, { directory: "apps/web", name: "@open-design/web" }, { directory: "apps/desktop", name: "@open-design/desktop" }, diff --git a/tools/pack/src/workspace-build.ts b/tools/pack/src/workspace-build.ts index f0178e777..c141c19cf 100644 --- a/tools/pack/src/workspace-build.ts +++ b/tools/pack/src/workspace-build.ts @@ -11,6 +11,8 @@ const WORKSPACE_BUILD_PACKAGES = [ { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/platform", name: "@open-design/platform" }, + { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, + { directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" }, { directory: "apps/daemon", name: "@open-design/daemon" }, { directory: "apps/web", name: "@open-design/web" }, { directory: "apps/desktop", name: "@open-design/desktop" }, @@ -22,6 +24,8 @@ const BUILD_COMMANDS = [ { args: ["--filter", "@open-design/sidecar-proto", "build"] }, { args: ["--filter", "@open-design/sidecar", "build"] }, { args: ["--filter", "@open-design/platform", "build"] }, + { args: ["--filter", "@open-design/agui-adapter", "build"] }, + { args: ["--filter", "@open-design/plugin-runtime", "build"] }, { args: ["--filter", "@open-design/daemon", "build"] }, { args: ["--filter", "@open-design/web", "build"], env: ["OD_WEB_OUTPUT_MODE"] }, { args: ["--filter", "@open-design/web", "build:sidecar"] }, @@ -74,7 +78,7 @@ async function createWorkspaceBuildCacheKey(config: ToolPackConfig): Promise { it("excludes internal packages replaced by mac standalone prebundles", () => { for (const packageName of [ - "@open-design/contracts", "@open-design/daemon", "@open-design/desktop", "@open-design/packaged", @@ -58,6 +57,12 @@ describe("mac standalone prebundle policy", () => { }), ).toBe(false); } + expect( + shouldInstallInternalPackageForMacPrebundle({ + packageName: "@open-design/contracts", + webOutputMode: "standalone", + }), + ).toBe(true); }); it("documents the explicit code-level bundle boundaries", () => { diff --git a/tools/pack/tests/web-standalone-after-pack.test.ts b/tools/pack/tests/web-standalone-after-pack.test.ts index 6a631c59f..602bc7dcb 100644 --- a/tools/pack/tests/web-standalone-after-pack.test.ts +++ b/tools/pack/tests/web-standalone-after-pack.test.ts @@ -9,6 +9,7 @@ const require = createRequire(import.meta.url); const runWebStandaloneAfterPack = require("../resources/web-standalone-after-pack.cjs") as (context: unknown) => Promise; const CONFIG_ENV = "OD_TOOLS_PACK_WEB_STANDALONE_HOOK_CONFIG"; +const darwinSymlinkIt = process.platform === "win32" ? it.skip : it; async function pathExists(filePath: string): Promise { try { @@ -246,7 +247,7 @@ describe("web standalone afterPack hook", () => { } }); - it("rewrites darwin copied pnpm symlinks to stay inside the packaged resource", async () => { + darwinSymlinkIt("rewrites darwin copied pnpm symlinks to stay inside the packaged resource", async () => { const fixture = await runFixture({ includeWebNext: true, platformName: "darwin", diff --git a/tools/pack/tests/win-prebundle.test.ts b/tools/pack/tests/win-prebundle.test.ts index 5786d828d..724b0bc20 100644 --- a/tools/pack/tests/win-prebundle.test.ts +++ b/tools/pack/tests/win-prebundle.test.ts @@ -42,7 +42,6 @@ describe("win standalone prebundle policy", () => { it("excludes internal packages replaced by win standalone prebundles", () => { for (const packageName of [ - "@open-design/contracts", "@open-design/daemon", "@open-design/desktop", "@open-design/packaged", @@ -58,6 +57,12 @@ describe("win standalone prebundle policy", () => { }), ).toBe(false); } + expect( + shouldInstallInternalPackageForWinPrebundle({ + packageName: "@open-design/contracts", + webOutputMode: "standalone", + }), + ).toBe(true); }); it("documents the explicit code-level bundle boundaries", () => { diff --git a/tools/pack/tests/workspace-build.test.ts b/tools/pack/tests/workspace-build.test.ts index df02bf3d3..470c5f00c 100644 --- a/tools/pack/tests/workspace-build.test.ts +++ b/tools/pack/tests/workspace-build.test.ts @@ -13,6 +13,8 @@ const PACKAGE_DIRS = [ "packages/sidecar-proto", "packages/sidecar", "packages/platform", + "packages/agui-adapter", + "packages/plugin-runtime", "apps/daemon", "apps/web", "apps/desktop", @@ -28,6 +30,10 @@ const OUTPUT_FILES = [ "packages/sidecar/dist/index.d.ts", "packages/platform/dist/index.mjs", "packages/platform/dist/index.d.ts", + "packages/agui-adapter/dist/index.mjs", + "packages/agui-adapter/dist/index.d.ts", + "packages/plugin-runtime/dist/index.mjs", + "packages/plugin-runtime/dist/index.d.ts", "apps/daemon/dist/cli.js", "apps/daemon/dist/cli.d.ts", "apps/daemon/dist/sidecar/index.js",