From 083e90872194b705c9636a0370cf4c037426cb4d Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 06:51:20 +0800 Subject: [PATCH] fix: [AI-8361] carry the IDE entry's env when wiring the datamate stdio MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `datamate_manager add` reused the command + args from the IDE's `mcp.json` `datamate` entry but dropped its `env` block, both in the immediate spawn and in the entry persisted to `.altimate-code/altimate-code.json`. On desktop editors the command is the editor's Electron binary and `env` carries `ELECTRON_RUN_AS_NODE=1` — spawned without it, the editor GUI boots and opens `datamate-cli.js` as a document, the MCP client reports `-32000 Connection closed`, and the broken persisted entry re-pops the file on every subsequent session launch. - `readDatamateTransportFromIde` now returns the entry's env (minus `ALTIMATE_EXTENSION_RPC`, mirroring the sync path) and `updatedAt`; `handleAdd` carries the env into the runtime config and persists it as `environment`, plus `updatedAt` on disk so the sync recognizes the entry as current. - The sync path's inline env-strip is extracted into the shared `extractSpawnEnvironment` helper so both paths stay in lockstep. - The TUI worker and `run` now run `syncDatamateUrlFromVscodeMcp` before the first session (as `serve` already did), so entries already persisted without `environment` self-heal on the next launch. --- .../src/altimate/datamate-transport.ts | 40 +++++- .../opencode/src/altimate/tools/datamate.ts | 28 +++- packages/opencode/src/cli/cmd/run.ts | 9 ++ packages/opencode/src/cli/tui/worker.ts | 21 +++ .../mcp-datamate-stdio-env.test.ts | 133 ++++++++++++++++++ 5 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 8a5e967233..98543f7b19 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -21,7 +21,26 @@ const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const export type DatamateTransport = | { type: "remote"; url: string } - | { type: "local"; command: string[] } + | { type: "local"; command: string[]; environment?: Record; updatedAt?: string } + +/** + * Env block to carry over when spawning the datamate CLI from an IDE mcp.json + * entry, minus ALTIMATE_EXTENSION_RPC (the extension-private RPC socket path, + * which goes stale whenever the extension restarts and is re-resolved by the + * CLI itself). ELECTRON_RUN_AS_NODE must survive: on desktop editors the + * entry's command is the editor's Electron binary, and without the flag the + * spawn boots the editor GUI — which opens datamate-cli.js as a document in + * the IDE — instead of running it as a Node script. + */ +function extractSpawnEnvironment(raw: unknown): Record | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined + const env: Record = {} + for (const [key, value] of Object.entries(raw as Record)) { + if (key === "ALTIMATE_EXTENSION_RPC") continue + if (typeof value === "string") env[key] = value + } + return Object.keys(env).length > 0 ? env : undefined +} /** * Parse a single mcp.json file and return the servers map, trying each of the @@ -108,11 +127,21 @@ export async function readDatamateTransportFromIde( return { type: "remote", url: entry["url"] } } - // stdio entry — reuse the exact command + args the extension registered + // stdio entry — reuse the exact command + args + env the extension + // registered. Dropping env here regresses desktop editors: the entry's + // command is the editor's Electron binary and only runs as Node when + // ELECTRON_RUN_AS_NODE=1 is passed through. const cmd = typeof entry["command"] === "string" ? entry["command"] : undefined const args = Array.isArray(entry["args"]) ? (entry["args"] as string[]) : [] if (cmd) { - return { type: "local", command: [cmd, ...args] } + const environment = extractSpawnEnvironment(entry["env"]) + const updatedAt = typeof entry["updatedAt"] === "string" ? entry["updatedAt"] : undefined + return { + type: "local", + command: [cmd, ...args], + ...(environment ? { environment } : {}), + ...(updatedAt ? { updatedAt } : {}), + } } // Entry exists but has no usable command — treat as local marker @@ -221,8 +250,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise if ("command" in datamateVscode) { - const env = datamateVscode["env"] as Record | undefined - const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {} + const environment = extractSpawnEnvironment(datamateVscode["env"]) const cmd = typeof datamateVscode["command"] === "string" ? (datamateVscode["command"] as string) @@ -231,7 +259,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise 0 ? { environment: restEnv } : {}), + ...(environment ? { environment } : {}), updatedAt: vscodeUpdatedAt, } } else { diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 7e1bb6944d..562072abdf 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -206,11 +206,17 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p transport?.type === "remote" ? { type: "remote" as const, url: transport.url } : transport?.type === "local" - // Use the exact command from the IDE config so we reuse the process the - // extension manages rather than spawning a second one. The extension and - // altimate-code would otherwise maintain two separate stdio child processes - // connected to the same datamate binary, wasting resources. - ? { type: "local" as const, command: transport.command } + // Use the exact command + env from the IDE config so we reuse the process + // the extension manages rather than spawning a second one. The env block + // must be carried: on desktop editors the command is the editor's Electron + // binary, which only runs as Node when ELECTRON_RUN_AS_NODE=1 is set — + // spawned without it, the editor GUI boots and opens datamate-cli.js as a + // document instead. + ? { + type: "local" as const, + command: transport.command, + ...(transport.environment ? { environment: transport.environment } : {}), + } : AltimateApi.buildMcpConfig(creds!, args.datamate_id) const isGlobal = args.scope === "global" @@ -258,12 +264,20 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p }) await MCP.connect(DATAMATE_KEY) } else { - // Not in config yet — write to disk then connect + // Not in config yet — write to disk then connect. The persisted entry + // additionally carries the IDE entry's updatedAt (disk-only; the runtime + // config schema has no such field) so the mcp.json sync recognizes the + // entry as current instead of rewriting it on the next serve boot. log.info("handleAdd: adding new datamate entry", { serverName: DATAMATE_KEY, type: mcpConfig.type, }) - await addMcpToConfig(DATAMATE_KEY, { ...mcpConfig, enabled: true }, configPath) + const diskEntry = { + ...mcpConfig, + enabled: true, + ...(transport?.type === "local" && transport.updatedAt ? { updatedAt: transport.updatedAt } : {}), + } + await addMcpToConfig(DATAMATE_KEY, diskEntry as Parameters[1], configPath) await MCP.add(DATAMATE_KEY, mcpConfig) } } else { diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 50638bcd14..93b9e73cff 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -942,6 +942,15 @@ You are speaking to a non-technical business executive. Follow these rules stric return await execute(sdk) } + // altimate_change start — heal the datamate MCP entry before the session starts, + // mirroring cli/cmd/serve.ts: an entry persisted without its env block (e.g. + // missing ELECTRON_RUN_AS_NODE for an Electron command) would otherwise be + // re-spawned broken on every run invocation with no path to self-repair. + { + const { syncDatamateUrlFromVscodeMcp } = await import("../../altimate/datamate-transport") + await syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) + } + // altimate_change end await bootstrap(process.cwd(), async () => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 8d4dd58e45..47d995a889 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -27,6 +27,13 @@ import { Instance } from "@/project/instance" // altimate_change — onboarding telemetry: flush this thread's buffer in rpc.shutdown() import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" +// altimate_change start — heal the datamate MCP entry at boot. `altimate serve` runs +// this sync before listening (cli/cmd/serve.ts), but the TUI worker never did, so an +// entry persisted without its env block (e.g. missing ELECTRON_RUN_AS_NODE for an +// Electron command) was re-spawned broken on every TUI session start with no path to +// self-repair. +import { syncDatamateUrlFromVscodeMcp } from "@/altimate/datamate-transport" +// altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling // is enforced by the compiler rather than by a comment. @@ -34,6 +41,12 @@ const SHUTDOWN_BUDGET_MS = Telemetry.TUI_SHUTDOWN_BUDGET_MS Heap.start() +// altimate_change start — datamate entry heal, awaited before the first in-process +// request (session start connects MCP servers from the config this sync repairs). +// Errors are swallowed: a failed sync must never block the TUI. +const datamateSyncReady: Promise = syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) +// altimate_change end + const traceConsumer = new TraceConsumer() // loadConfig() must complete before the first event: getOrCreateTrace caches, per session, a Trace // whose snapshot dir comes from loadConfig's FileExporter — an event handled before it finishes caches @@ -65,6 +78,10 @@ let server: Awaited> | undefined export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { + // altimate_change start — no request is served until the datamate entry heal + // completes (already-resolved after the first request; effectively free thereafter). + await datamateSyncReady + // altimate_change end const headers = { ...input.headers } const auth = ServerAuth.header() if (auth && !headers["authorization"] && !headers["Authorization"]) { @@ -90,6 +107,10 @@ export const rpc = { return result }, async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { + // altimate_change start — external-server mode bypasses rpc.fetch, so gate listen + // on the datamate entry heal the same way (mirrors cli/cmd/serve.ts ordering). + await datamateSyncReady + // altimate_change end if (server) await server.stop(true) server = await Server.listen(input) return { url: server.url.toString() } diff --git a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts new file mode 100644 index 0000000000..5c49d190ce --- /dev/null +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -0,0 +1,133 @@ +import { describe, test, expect } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import { mkdir, writeFile, readFile } from "fs/promises" +import path from "path" +import { + readDatamateTransportFromIde, + syncDatamateUrlFromVscodeMcp, + DATAMATE_KEY, +} from "../../src/altimate/datamate-transport" + +// Regression tests for the stdio env carry-through. The IDE extension writes the +// datamate stdio entry with an env block — on desktop editors the entry's command +// is the editor's Electron binary and env carries ELECTRON_RUN_AS_NODE=1, without +// which the spawn boots the editor GUI and opens datamate-cli.js as a document +// instead of running it. readDatamateTransportFromIde used to drop env entirely, +// so `datamate_manager add` persisted a broken entry that re-popped the file on +// every session launch. + +async function seedIdeStdio(dir: string, entry: Record) { + await mkdir(path.join(dir, ".vscode"), { recursive: true }) + await writeFile( + path.join(dir, ".vscode", "mcp.json"), + JSON.stringify({ servers: { [DATAMATE_KEY]: entry } }, null, 2), + ) +} + +describe("readDatamateTransportFromIde stdio env carry-through", () => { + test("carries env minus ALTIMATE_EXTENSION_RPC, plus updatedAt", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { + ALTIMATE_EXTENSION_RPC: "/tmp/altimate-mcp-1.sock", + ELECTRON_RUN_AS_NODE: "1", + }, + updatedAt: "2026-08-06T00:00:00.000Z", + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t).toEqual({ + type: "local", + command: ["/path/to/electron", "/ext/dist/datamate-cli.js", "start-stdio"], + environment: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "2026-08-06T00:00:00.000Z", + }) + }) + + test("env with only ALTIMATE_EXTENSION_RPC → environment omitted entirely", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/usr/lib/code-server/lib/node", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ALTIMATE_EXTENSION_RPC: "/tmp/altimate-mcp-1.sock" }, + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t).toEqual({ + type: "local", + command: ["/usr/lib/code-server/lib/node", "/ext/dist/datamate-cli.js", "start-stdio"], + }) + }) + + test("entry without env keeps the bare local shape (back-compat)", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "datamate", + args: ["start-stdio"], + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t).toEqual({ type: "local", command: ["datamate", "start-stdio"] }) + }) + + test("non-string env values are ignored, string values kept", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["start-stdio"], + env: { + ELECTRON_RUN_AS_NODE: "1", + BOGUS_NUMBER: 42, + BOGUS_OBJECT: { nested: true }, + }, + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t?.type).toBe("local") + if (t?.type === "local") { + expect(t.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + } + }) +}) + +describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { + test("synced local entry strips ALTIMATE_EXTENSION_RPC but keeps ELECTRON_RUN_AS_NODE", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "altimate-code.json") + await writeFile( + configPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["stale"], enabled: true, updatedAt: "T1" } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { + ALTIMATE_EXTENSION_RPC: "/tmp/altimate-mcp-1.sock", + ELECTRON_RUN_AS_NODE: "1", + }, + updatedAt: "T2", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path) + expect(updated).toContain(DATAMATE_KEY) + + const after = JSON.parse(await readFile(configPath, "utf-8")) + const entry = after.mcp[DATAMATE_KEY] + expect(entry.type).toBe("local") + expect(entry.command).toEqual(["/path/to/electron", "/ext/dist/datamate-cli.js", "start-stdio"]) + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + expect(entry.updatedAt).toBe("T2") + expect(entry.enabled).toBe(true) // non-transport field preserved + }) +})