From cbf4f6554efc05aa37dbf39051f3ac23cc4c6a2a Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 06:51:20 +0800 Subject: [PATCH 01/12] fix: 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 + }) +}) From 80d4ad4cd2c7779ec8b36d9cff829cf3598c7dcc Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 13:36:57 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20hea?= =?UTF-8?q?l=20ordering,=20existing-entry=20refresh,=20project-root=20sync?= =?UTF-8?q?=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TUI worker: the datamate heal is now sequenced strictly before `InstanceRuntime.load`/`Config.get()` (trace init awaits it), so the config read can neither race the non-atomic write nor cache the pre-heal entry — the first session connects with the healed config. - `datamate_manager add`: the in-config-but-not-connected branch refreshes the persisted entry from the current IDE transport (preserving user-managed fields) and connects via `MCP.add`, instead of `MCP.connect` which re-reads the stale in-memory entry. - Boot heals (`run`, TUI worker) scan from the containing git project root via the new `resolveDatamateSyncRoot`, not raw cwd — a session launched from a subdirectory now finds the root IDE config and persisted entry. --- .../src/altimate/datamate-transport.ts | 20 ++++++++++++ .../opencode/src/altimate/tools/datamate.ts | 32 +++++++++++++++---- packages/opencode/src/cli/cmd/run.ts | 9 ++++-- packages/opencode/src/cli/tui/worker.ts | 20 +++++++++--- .../mcp-datamate-stdio-env.test.ts | 20 ++++++++++++ 5 files changed, 89 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 98543f7b19..ff2931326a 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -42,6 +42,26 @@ function extractSpawnEnvironment(raw: unknown): Record | undefin return Object.keys(env).length > 0 ? env : undefined } +/** + * Root directory the boot-time heal should scan from: the containing git + * project root when there is one, else the directory itself. Boot-time callers + * (TUI worker, `run`) fire the sync before an Instance exists, so they cannot + * use `Instance.worktree` — but MCP config is scoped to the project root, and + * a session launched from a subdirectory would otherwise scan the subtree and + * miss both the IDE config and the persisted entry it needs to repair. + */ +export async function resolveDatamateSyncRoot(directory: string): Promise { + try { + const matches = Filesystem.up({ targets: [".git"], start: directory }) + const dotgit = await matches.next().then((x) => x.value) + await matches.return() + if (dotgit) return path.dirname(dotgit) + } catch { + // fall through to the directory itself + } + return directory +} + /** * Parse a single mcp.json file and return the servers map, trying each of the * known top-level key names in order. diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 562072abdf..954431993b 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -8,6 +8,7 @@ import { listMcpInConfig, resolveConfigPath, findAllConfigPaths, + readMcpEntryFromDisk, } from "../../mcp/config" import { Instance } from "../../project/instance" import { Global } from "../../global" @@ -255,14 +256,33 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p output: `Datamate tools are already available via the '${DATAMATE_KEY}' MCP server (${toolCount} tools active).${staleNote}`, } } - // In config but not connected — reconnect via MCP.connect() so persistMcpEnabled - // is called and the enabled:true state survives the next session restart. - // Bug-fix: was previously MCP.add() which skips persistMcpEnabled, so a session - // that had the server disabled would not re-enable it on the next restart. - log.info("handleAdd: reconnecting existing datamate entry", { + // In config but not connected — refresh the persisted entry from the current + // IDE transport before connecting. MCP.connect() reads the in-memory Config + // singleton, so a stale entry (e.g. one persisted without its environment + // block) would be respawned broken no matter what the IDE entry says now. + // Same pattern as the reload-datamate endpoint: write the fresh entry to + // disk, then MCP.add() with the config directly. Writing enabled: true + // preserves the re-enable-on-restart behavior MCP.connect()'s + // persistMcpEnabled used to provide; other user-managed fields (timeout, + // oauth, …) are carried over from the existing entry. + log.info("handleAdd: refreshing and reconnecting existing datamate entry", { serverName: DATAMATE_KEY, + type: mcpConfig.type, }) - await MCP.connect(DATAMATE_KEY) + const existing = await readMcpEntryFromDisk(DATAMATE_KEY, configPath) + const TRANSPORT_FIELDS = new Set(["type", "command", "args", "environment", "url", "updatedAt", "enabled"]) + const preserved: Record = {} + for (const [k, v] of Object.entries(existing ?? {})) { + if (!TRANSPORT_FIELDS.has(k)) preserved[k] = v + } + const refreshed = { + ...preserved, + ...mcpConfig, + enabled: true, + ...(transport?.type === "local" && transport.updatedAt ? { updatedAt: transport.updatedAt } : {}), + } + await addMcpToConfig(DATAMATE_KEY, refreshed as Parameters[1], configPath) + await MCP.add(DATAMATE_KEY, mcpConfig) } else { // Not in config yet — write to disk then connect. The persisted entry // additionally carries the IDE entry's updatedAt (disk-only; the runtime diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 93b9e73cff..ae2d97d4e6 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -946,9 +946,14 @@ You are speaking to a non-technical business executive. Follow these rules stric // 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. + // Scoped to the project root, not cwd — a run from a subdirectory must still + // find the root IDE config and the persisted entry it needs to repair. { - const { syncDatamateUrlFromVscodeMcp } = await import("../../altimate/datamate-transport") - await syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) + const { syncDatamateUrlFromVscodeMcp, resolveDatamateSyncRoot } = await import( + "../../altimate/datamate-transport" + ) + const root = await resolveDatamateSyncRoot(process.cwd()).catch(() => process.cwd()) + await syncDatamateUrlFromVscodeMcp(root).catch(() => {}) } // altimate_change end await bootstrap(process.cwd(), async () => { diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 47d995a889..c00117a9a5 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -32,7 +32,7 @@ import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // 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" +import { syncDatamateUrlFromVscodeMcp, resolveDatamateSyncRoot } from "@/altimate/datamate-transport" // altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling @@ -41,10 +41,17 @@ 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). +// altimate_change start — datamate entry heal. Scoped to the project root (a session +// launched from a subdirectory must still find the root IDE config + persisted entry). +// Everything that reads the config is sequenced AFTER this promise — trace init below, +// the first in-process request, and Server.listen — because the heal writes +// altimate-code.json with a non-atomic write, and InstanceRuntime.load/Config.get() +// would otherwise race it (transiently truncated read) or cache the pre-heal entry, +// making the first session spawn the broken config anyway. // Errors are swallowed: a failed sync must never block the TUI. -const datamateSyncReady: Promise = syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) +const datamateSyncReady: Promise = resolveDatamateSyncRoot(process.cwd()) + .then((root) => syncDatamateUrlFromVscodeMcp(root)) + .catch(() => {}) // altimate_change end const traceConsumer = new TraceConsumer() @@ -54,6 +61,11 @@ const traceConsumer = new TraceConsumer() // Config.get() (a facade needing an Instance on the canonical ALS the bare worker lacks at init), so // load the project instance for the worker's cwd first; best-effort fallback otherwise. const traceReady: Promise = (async () => { + // altimate_change start — the datamate heal writes altimate-code.json; let it finish + // before InstanceRuntime.load/Config.get() read (and cache) the config, so the first + // session connects with the healed entry instead of a stale or half-written one. + await datamateSyncReady + // altimate_change end try { const ctx = await InstanceRuntime.load({ directory: process.cwd() }) await Instance.restore(ctx, () => traceConsumer.loadConfig()) 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 index 5c49d190ce..e704c2527c 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -5,6 +5,7 @@ import path from "path" import { readDatamateTransportFromIde, syncDatamateUrlFromVscodeMcp, + resolveDatamateSyncRoot, DATAMATE_KEY, } from "../../src/altimate/datamate-transport" @@ -96,6 +97,25 @@ describe("readDatamateTransportFromIde stdio env carry-through", () => { }) }) +describe("resolveDatamateSyncRoot", () => { + test("resolves the containing git project root from a subdirectory", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, ".git"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "deep"), { recursive: true }) + + const root = await resolveDatamateSyncRoot(path.join(tmp.path, "packages", "deep")) + expect(root).toBe(tmp.path) + }) + + test("falls back to the directory itself outside a git project", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, "plain"), { recursive: true }) + + const root = await resolveDatamateSyncRoot(path.join(tmp.path, "plain")) + expect(root).toBe(path.join(tmp.path, "plain")) + }) +}) + 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() From 1cb8fad7960879b630eea9e76b280de4852b8e1c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 13:48:12 +0800 Subject: [PATCH 03/12] refactor: share TRANSPORT_IDENTITY_FIELDS between sync and datamate add refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both paths encode the same idea — entry fields re-derived from the IDE transport versus user-managed fields carried forward. A single exported set keeps them from silently diverging when a new transport field is added; the add-refresh path layers `enabled` on top since it re-derives that too. --- .../src/altimate/datamate-transport.ts | 25 ++++++++++++------- .../opencode/src/altimate/tools/datamate.ts | 8 +++--- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index ff2931326a..0c40270fc6 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -62,6 +62,21 @@ export async function resolveDatamateSyncRoot(directory: string): Promise = new Set([ + "type", + "command", + "args", + "environment", + "url", + "updatedAt", +]) + /** * Parse a single mcp.json file and return the servers map, trying each of the * known top-level key names in order. @@ -255,17 +270,9 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise = {} for (const [k, v] of Object.entries(existingEntry)) { - if (!TRANSPORT_FIELDS.has(k)) preserved[k] = v + if (!TRANSPORT_IDENTITY_FIELDS.has(k)) preserved[k] = v } let newEntry: Record diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 954431993b..4077b25ec1 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -13,7 +13,7 @@ import { import { Instance } from "../../project/instance" import { Global } from "../../global" import { Log } from "@/altimate/util/log" -import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" +import { DATAMATE_KEY, readDatamateTransportFromIde, TRANSPORT_IDENTITY_FIELDS } from "../datamate-transport" const log = Log.create({ service: "datamate" }) @@ -270,10 +270,12 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p type: mcpConfig.type, }) const existing = await readMcpEntryFromDisk(DATAMATE_KEY, configPath) - const TRANSPORT_FIELDS = new Set(["type", "command", "args", "environment", "url", "updatedAt", "enabled"]) + // enabled joins the shared transport-identity set here because this path + // re-derives it too (always written as true below). + const replacedFields = new Set([...TRANSPORT_IDENTITY_FIELDS, "enabled"]) const preserved: Record = {} for (const [k, v] of Object.entries(existing ?? {})) { - if (!TRANSPORT_FIELDS.has(k)) preserved[k] = v + if (!replacedFields.has(k)) preserved[k] = v } const refreshed = { ...preserved, From 7bcc9b6405167eb8b91908bc5b9ccbfe6a753d0e Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 13:52:09 +0800 Subject: [PATCH 04/12] fix: connect refreshed datamate entry with its preserved settings; carry updatedAt for remote - The add-refresh path wrote the merged entry (preserved headers/oauth/timeout + fresh transport) to disk but connected the live client with the bare transport config, dropping authentication and connection settings for the session being connected. MCP.add now receives the same merged entry as the disk write, matching the reload-datamate endpoint. - The remote transport variant now carries updatedAt like the local one, so a remote datamate added via datamate_manager is not rewritten once by the next boot's sync purely for the missing change signal. --- .../opencode/src/altimate/datamate-transport.ts | 8 ++++++-- packages/opencode/src/altimate/tools/datamate.ts | 9 ++++++--- .../mcp-datamate-stdio-env.test.ts | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 0c40270fc6..8deb45b1ab 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -20,7 +20,7 @@ const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const export type DatamateTransport = - | { type: "remote"; url: string } + | { type: "remote"; url: string; updatedAt?: string } | { type: "local"; command: string[]; environment?: Record; updatedAt?: string } /** @@ -159,7 +159,11 @@ export async function readDatamateTransportFromIde( }) if (typeof entry["url"] === "string") { - return { type: "remote", url: entry["url"] } + // updatedAt carried for parity with the local branch: the boot-time sync + // uses it as its change signal regardless of transport type, and an entry + // persisted without it gets one redundant rewrite on the next boot. + const updatedAt = typeof entry["updatedAt"] === "string" ? entry["updatedAt"] : undefined + return { type: "remote", url: entry["url"], ...(updatedAt ? { updatedAt } : {}) } } // stdio entry — reuse the exact command + args + env the extension diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 4077b25ec1..9e90af77a9 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -281,10 +281,13 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p ...preserved, ...mcpConfig, enabled: true, - ...(transport?.type === "local" && transport.updatedAt ? { updatedAt: transport.updatedAt } : {}), + ...(transport?.updatedAt ? { updatedAt: transport.updatedAt } : {}), } await addMcpToConfig(DATAMATE_KEY, refreshed as Parameters[1], configPath) - await MCP.add(DATAMATE_KEY, mcpConfig) + // The live client must get the same merged entry as the disk write — the + // bare transport config would drop preserved auth/connection settings + // (headers, oauth, timeout) for the session being connected right now. + await MCP.add(DATAMATE_KEY, refreshed as Parameters[1]) } else { // Not in config yet — write to disk then connect. The persisted entry // additionally carries the IDE entry's updatedAt (disk-only; the runtime @@ -297,7 +300,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p const diskEntry = { ...mcpConfig, enabled: true, - ...(transport?.type === "local" && transport.updatedAt ? { updatedAt: transport.updatedAt } : {}), + ...(transport?.updatedAt ? { updatedAt: transport.updatedAt } : {}), } await addMcpToConfig(DATAMATE_KEY, diskEntry as Parameters[1], configPath) await MCP.add(DATAMATE_KEY, mcpConfig) 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 index e704c2527c..f5bf721897 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -64,6 +64,22 @@ describe("readDatamateTransportFromIde stdio env carry-through", () => { }) }) + test("remote entry carries updatedAt for sync parity, bare shape without it", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "http", + url: "http://localhost:7801/mcp", + updatedAt: "2026-08-06T00:00:00.000Z", + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t).toEqual({ + type: "remote", + url: "http://localhost:7801/mcp", + updatedAt: "2026-08-06T00:00:00.000Z", + }) + }) + test("entry without env keeps the bare local shape (back-compat)", async () => { await using tmp = await tmpdir() await seedIdeStdio(tmp.path, { From 37c3d2c8e523663da3b9467e5ac718e555732970 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 14:01:58 +0800 Subject: [PATCH 05/12] refactor: hoist shared updatedAt spread in handleAdd Both the refresh and new-entry branches persisted the transport's updatedAt with the same conditional spread; a single `updatedAtField` above the branch keeps them from drifting, and the disk-only rationale is documented once. --- packages/opencode/src/altimate/tools/datamate.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 9e90af77a9..95e1656257 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -224,7 +224,11 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p const configPath = await resolveConfigPath(isGlobal ? Global.Path.config : projectRoot(), isGlobal) if (transport !== null) { - // IDE/extension mode: check if DATAMATE_KEY is already wired up + // IDE/extension mode: check if DATAMATE_KEY is already wired up. + // updatedAt is disk-only (the runtime config schema has no such field); the + // mcp.json sync uses it to recognize the entry as current instead of + // rewriting it on the next boot. + const updatedAtField = transport.updatedAt ? { updatedAt: transport.updatedAt } : {} const existingNames = await listMcpInConfig(configPath) const staleEntries = existingNames.filter( (n) => n !== DATAMATE_KEY && n.startsWith("datamate-"), @@ -281,7 +285,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p ...preserved, ...mcpConfig, enabled: true, - ...(transport?.updatedAt ? { updatedAt: transport.updatedAt } : {}), + ...updatedAtField, } await addMcpToConfig(DATAMATE_KEY, refreshed as Parameters[1], configPath) // The live client must get the same merged entry as the disk write — the @@ -289,10 +293,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p // (headers, oauth, timeout) for the session being connected right now. await MCP.add(DATAMATE_KEY, refreshed as Parameters[1]) } else { - // 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. + // Not in config yet — write to disk then connect. log.info("handleAdd: adding new datamate entry", { serverName: DATAMATE_KEY, type: mcpConfig.type, @@ -300,7 +301,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p const diskEntry = { ...mcpConfig, enabled: true, - ...(transport?.updatedAt ? { updatedAt: transport.updatedAt } : {}), + ...updatedAtField, } await addMcpToConfig(DATAMATE_KEY, diskEntry as Parameters[1], configPath) await MCP.add(DATAMATE_KEY, mcpConfig) From 33b60d8b969ceb013f9bfd3d7b27daadc82064a0 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 8 Aug 2026 05:44:39 +0800 Subject: [PATCH 06/12] fix: heal the datamate entry in the global config too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit datamate_manager add supports scope "global", so a broken (env-less) datamate entry can live in the global altimate-code.json. It is spawned at session start like any merged config entry — reproducing the editor-tab pop — but the boot heal only rewrote the project config, so the entry never repaired (found by the bug reporter testing the fix: no environment block appeared). syncDatamateUrlFromVscodeMcp now heals every config file carrying a datamate entry via findAllConfigPaths (project, project subdirs, global), reporting the entry once. Sync tests pass an isolated global dir so test runs never touch the developer's real config. --- .../src/altimate/datamate-transport.ts | 140 ++++++++++-------- .../mcp-datamate-893.test.ts | 6 +- .../mcp-datamate-stdio-env.test.ts | 60 +++++++- 3 files changed, 140 insertions(+), 66 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 8deb45b1ab..d71383b324 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -1,7 +1,8 @@ import { readFile } from "fs/promises" import path from "path" import { parseTree, findNodeAtLocation, getNodeValue } from "jsonc-parser" -import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk } from "../mcp/config" +import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk, findAllConfigPaths } from "../mcp/config" +import { Global } from "../global" import { Filesystem } from "../util/filesystem" import { Glob } from "@opencode-ai/core/util/glob" import { Log } from "@/altimate/util/log" @@ -204,7 +205,11 @@ export async function readDatamateTransportFromIde( * Fire-and-forget friendly: errors are logged but never thrown. * Returns the list of MCP server names whose config was updated on disk. */ -export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise { +export async function syncDatamateUrlFromVscodeMcp( + cwd: string, + // Overridable for tests only — the real global config dir is a static xdg path. + globalConfigDir: string = Global.Path.config, +): Promise { const updated: string[] = [] try { log.info("syncDatamateUrlFromVscodeMcp: start", { cwd }) @@ -246,76 +251,87 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise => { const configText = await Filesystem.readText(configPath) const existingTree = parseTree(configText) const existingNode = existingTree ? findNodeAtLocation(existingTree, ["mcp", DATAMATE_KEY]) : undefined + if (!existingNode) return false + + // getNodeValue reconstructs the full entry (a manual children walk reading + // `prop.children[1].value` drops array/object fields — jsonc-parser only + // populates `Node.value` for primitives). + const existingEntry = + existingNode.type === "object" + ? (getNodeValue(existingNode) as Record) + : {} + const existingUpdatedAt = + typeof existingEntry["updatedAt"] === "string" ? existingEntry["updatedAt"] : undefined + + if (vscodeUpdatedAt === existingUpdatedAt) { + log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date", { + configPath, + updatedAt: vscodeUpdatedAt, + }) + return false + } - if (existingNode) { - // getNodeValue reconstructs the full entry (a manual children walk reading - // `prop.children[1].value` drops array/object fields — jsonc-parser only - // populates `Node.value` for primitives). - const existingEntry = - existingNode.type === "object" - ? (getNodeValue(existingNode) as Record) - : {} - const existingUpdatedAt = - typeof existingEntry["updatedAt"] === "string" ? existingEntry["updatedAt"] : undefined - - if (vscodeUpdatedAt === existingUpdatedAt) { - log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date", { - updatedAt: vscodeUpdatedAt, - }) - } else { - // Preserve fields the IDE doesn't manage (enabled, timeout, oauth, …) by - // carrying forward everything except the transport-identity fields, which - // we re-derive below. IDE config uses "stdio"/"http"/"streamable-http"/"sse"; - // altimate-code.json uses "local"/"remote". - const preserved: Record = {} - for (const [k, v] of Object.entries(existingEntry)) { - if (!TRANSPORT_IDENTITY_FIELDS.has(k)) preserved[k] = v - } - - let newEntry: Record - if ("command" in datamateVscode) { - const environment = extractSpawnEnvironment(datamateVscode["env"]) - const cmd = - typeof datamateVscode["command"] === "string" - ? (datamateVscode["command"] as string) - : DATAMATE_KEY - newEntry = { - ...preserved, - type: "local", - command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])], - ...(environment ? { environment } : {}), - updatedAt: vscodeUpdatedAt, - } - } else { - // http / streamable-http / sse → remote - newEntry = { - ...preserved, - type: "remote", - url: datamateVscode["url"] as string, - updatedAt: vscodeUpdatedAt, - } - } + // Preserve fields the IDE doesn't manage (enabled, timeout, oauth, …) by + // carrying forward everything except the transport-identity fields, which + // we re-derive below. IDE config uses "stdio"/"http"/"streamable-http"/"sse"; + // altimate-code.json uses "local"/"remote". + const preserved: Record = {} + for (const [k, v] of Object.entries(existingEntry)) { + if (!TRANSPORT_IDENTITY_FIELDS.has(k)) preserved[k] = v + } - await addMcpToConfig( - DATAMATE_KEY, - newEntry as Parameters[1], - configPath, - ) - log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", { - type: datamateVscode["type"], - updatedAt: vscodeUpdatedAt, - }) - updated.push(DATAMATE_KEY) + let newEntry: Record + if ("command" in datamateVscode) { + const environment = extractSpawnEnvironment(datamateVscode["env"]) + const cmd = + typeof datamateVscode["command"] === "string" + ? (datamateVscode["command"] as string) + : DATAMATE_KEY + newEntry = { + ...preserved, + type: "local", + command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])], + ...(environment ? { environment } : {}), + updatedAt: vscodeUpdatedAt, + } + } else { + // http / streamable-http / sse → remote + newEntry = { + ...preserved, + type: "remote", + url: datamateVscode["url"] as string, + updatedAt: vscodeUpdatedAt, } } + + await addMcpToConfig( + DATAMATE_KEY, + newEntry as Parameters[1], + configPath, + ) + log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", { + configPath, + type: datamateVscode["type"], + updatedAt: vscodeUpdatedAt, + }) + return true + } + + let datamateHealed = false + for (const configPath of await findAllConfigPaths(cwd, globalConfigDir)) { + if (await healEntryInFile(configPath)) datamateHealed = true } + if (datamateHealed) updated.push(DATAMATE_KEY) } // ── All other remote MCP entries: existing URL-comparison logic ────────── diff --git a/packages/opencode/test/release-validation/mcp-datamate-893.test.ts b/packages/opencode/test/release-validation/mcp-datamate-893.test.ts index 50d21b3e78..fd0b03ef63 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-893.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-893.test.ts @@ -350,7 +350,7 @@ describe("PR893: syncDatamateUrlFromVscodeMcp updatedAt-based change detection", }) await seedIdeMcp(tmp.path, { url: "http://NEW" }) // no updatedAt - const updated = await syncDatamateUrlFromVscodeMcp(tmp.path) + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, path.join(tmp.path, "isolated-global")) expect(updated).not.toContain(DATAMATE_KEY) const after = JSON.parse(await readFile(configPath, "utf-8")) @@ -368,7 +368,7 @@ describe("PR893: syncDatamateUrlFromVscodeMcp updatedAt-based change detection", }) await seedIdeMcp(tmp.path, { url: "http://NEW", updatedAt: "T1" }) - const updated = await syncDatamateUrlFromVscodeMcp(tmp.path) + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, path.join(tmp.path, "isolated-global")) expect(updated).not.toContain(DATAMATE_KEY) const after = JSON.parse(await readFile(configPath, "utf-8")) @@ -386,7 +386,7 @@ describe("PR893: syncDatamateUrlFromVscodeMcp updatedAt-based change detection", }) await seedIdeMcp(tmp.path, { url: "http://NEW", updatedAt: "T2" }) - const updated = await syncDatamateUrlFromVscodeMcp(tmp.path) + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, path.join(tmp.path, "isolated-global")) expect(updated).toContain(DATAMATE_KEY) const after = JSON.parse(await readFile(configPath, "utf-8")) 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 index f5bf721897..d6d95427bc 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -155,7 +155,7 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { updatedAt: "T2", }) - const updated = await syncDatamateUrlFromVscodeMcp(tmp.path) + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, path.join(tmp.path, "isolated-global")) expect(updated).toContain(DATAMATE_KEY) const after = JSON.parse(await readFile(configPath, "utf-8")) @@ -166,4 +166,62 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { expect(entry.updatedAt).toBe("T2") expect(entry.enabled).toBe(true) // non-transport field preserved }) + + test("heals a datamate entry living only in the GLOBAL config", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + const globalConfigPath = path.join(globalDir, "altimate-code.json") + await writeFile( + globalConfigPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T3", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const after = JSON.parse(await readFile(globalConfigPath, "utf-8")) + const entry = after.mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + expect(entry.updatedAt).toBe("T3") + expect(entry.enabled).toBe(true) + }) + + test("heals project and global entries in one pass", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + const brokenEntry = { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } + const projectConfigPath = path.join(tmp.path, "altimate-code.json") + const globalConfigPath = path.join(globalDir, "altimate-code.json") + await writeFile(projectConfigPath, JSON.stringify({ mcp: { [DATAMATE_KEY]: brokenEntry } }, null, 2)) + await writeFile(globalConfigPath, JSON.stringify({ mcp: { [DATAMATE_KEY]: brokenEntry } }, null, 2)) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T4", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toEqual([DATAMATE_KEY]) // reported once, not per file + + for (const p of [projectConfigPath, globalConfigPath]) { + const entry = JSON.parse(await readFile(p, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + expect(entry.updatedAt).toBe("T4") + } + }) }) From 6625177f644c815b73cf653ffdcb2ed6ef41845c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 8 Aug 2026 12:51:38 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20harden=20the=20datamate=20heal=20?= =?UTF-8?q?=E2=80=94=20full=20config-filename=20coverage,=20internal=20roo?= =?UTF-8?q?t=20resolution,=20per-file=20isolation,=20global-aware=20reload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CONFIG_FILENAMES now mirrors every filename the config loader merges (adds altimate-code.jsonc and legacy config.json), so entries in those files are healed/removed/listed like the rest instead of loading as live config that tooling cannot see. - syncDatamateUrlFromVscodeMcp resolves the git project root itself, so every caller (serve, reload endpoint, TUI worker, run) handles nested invocations; the worker/run callers drop their now-redundant resolution. - One malformed config file no longer aborts the multi-file heal — each file is healed independently with a logged skip on failure. - The reload-datamate endpoint reads the fresh entry from any config file the sync covers (project, subdirs, global) instead of only the project path, so a healed global-only entry actually reconnects. --- .../src/altimate/datamate-transport.ts | 16 +++- packages/opencode/src/cli/cmd/run.ts | 13 ++- packages/opencode/src/cli/tui/worker.ts | 22 +++-- packages/opencode/src/mcp/config.ts | 11 ++- packages/opencode/src/server/server.ts | 15 +++- .../mcp-datamate-stdio-env.test.ts | 89 +++++++++++++++++++ 6 files changed, 137 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index d71383b324..eb6786e591 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -212,6 +212,10 @@ export async function syncDatamateUrlFromVscodeMcp( ): Promise { const updated: string[] = [] try { + // Resolve the project root here rather than in each caller: an invocation + // from a nested subdirectory must still find the root .vscode/mcp.json and + // the root-level config files it needs to repair. + cwd = await resolveDatamateSyncRoot(cwd) log.info("syncDatamateUrlFromVscodeMcp: start", { cwd }) // Find the first mcp.json that contains a "datamate" entry. @@ -329,7 +333,17 @@ export async function syncDatamateUrlFromVscodeMcp( let datamateHealed = false for (const configPath of await findAllConfigPaths(cwd, globalConfigDir)) { - if (await healEntryInFile(configPath)) datamateHealed = true + // Per-file isolation: one malformed config (addMcpToConfig refuses to + // rewrite unparseable files by throwing) must not abort the heal for the + // remaining project/global files. + try { + if (await healEntryInFile(configPath)) datamateHealed = true + } catch (err) { + log.warn("syncDatamateUrlFromVscodeMcp: skipping unhealable config file", { + configPath, + error: err instanceof Error ? err.message : String(err), + }) + } } if (datamateHealed) updated.push(DATAMATE_KEY) } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index ae2d97d4e6..5c263512d3 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -945,15 +945,12 @@ You are speaking to a non-technical business executive. Follow these rules stric // 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. - // Scoped to the project root, not cwd — a run from a subdirectory must still - // find the root IDE config and the persisted entry it needs to repair. + // re-spawned broken on every run invocation with no path to self-repair. The + // sync resolves the project root itself, so a run from a subdirectory still + // finds the root IDE config and the persisted entry it needs to repair. { - const { syncDatamateUrlFromVscodeMcp, resolveDatamateSyncRoot } = await import( - "../../altimate/datamate-transport" - ) - const root = await resolveDatamateSyncRoot(process.cwd()).catch(() => process.cwd()) - await syncDatamateUrlFromVscodeMcp(root).catch(() => {}) + const { syncDatamateUrlFromVscodeMcp } = await import("../../altimate/datamate-transport") + await syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) } // altimate_change end await bootstrap(process.cwd(), async () => { diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index c00117a9a5..736eacc8db 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -32,7 +32,7 @@ import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // 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, resolveDatamateSyncRoot } from "@/altimate/datamate-transport" +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 @@ -41,17 +41,15 @@ const SHUTDOWN_BUDGET_MS = Telemetry.TUI_SHUTDOWN_BUDGET_MS Heap.start() -// altimate_change start — datamate entry heal. Scoped to the project root (a session -// launched from a subdirectory must still find the root IDE config + persisted entry). -// Everything that reads the config is sequenced AFTER this promise — trace init below, -// the first in-process request, and Server.listen — because the heal writes -// altimate-code.json with a non-atomic write, and InstanceRuntime.load/Config.get() -// would otherwise race it (transiently truncated read) or cache the pre-heal entry, -// making the first session spawn the broken config anyway. -// Errors are swallowed: a failed sync must never block the TUI. -const datamateSyncReady: Promise = resolveDatamateSyncRoot(process.cwd()) - .then((root) => syncDatamateUrlFromVscodeMcp(root)) - .catch(() => {}) +// altimate_change start — datamate entry heal (the sync resolves the project root +// itself, so a session launched from a subdirectory still finds the root IDE config +// + persisted entry). Everything that reads the config is sequenced AFTER this +// promise — trace init below, the first in-process request, and Server.listen — +// because the heal writes altimate-code.json with a non-atomic write, and +// InstanceRuntime.load/Config.get() would otherwise race it (transiently truncated +// read) or cache the pre-heal entry, making the first session spawn the broken +// config anyway. 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() diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index cccbc89f9d..10125fa7a9 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -3,10 +3,13 @@ import { modify, applyEdits, parse, parseTree, findNodeAtLocation, getNodeValue, import { Filesystem } from "../util/filesystem" import type { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" -// altimate_change start — primary config filename is altimate-code.json; opencode.json -// is fallback for users with pre-existing upstream installs. New writes land in -// altimate-code.json (first entry of the list). -const CONFIG_FILENAMES = ["altimate-code.json", "opencode.json", "opencode.jsonc"] +// altimate_change start — primary config filename is altimate-code.json; the rest are +// fallbacks for users with pre-existing installs. The list mirrors every filename the +// config loader merges (config/config.ts loadFile calls: altimate-code.json/.jsonc, +// opencode.json/.jsonc, legacy config.json) — an entry in any of them is live config, +// so lookups/removals/heals must see them all. New writes land in altimate-code.json +// (first entry of the list). +const CONFIG_FILENAMES = ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc", "config.json"] // altimate_change end export async function resolveConfigPath(baseDir: string, global = false) { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 5f548ddf8f..46f5d12c11 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -35,7 +35,7 @@ import { MCP } from "../mcp" // Using datamate-transport.ts instead of serve.ts avoids a dep on a cmd handler. import { syncDatamateUrlFromVscodeMcp } from "../altimate/datamate-transport" import { readMcpEntryFromDisk } from "../mcp/config" -import { resolveConfigPath } from "../mcp/config" +import { findAllConfigPaths } from "../mcp/config" import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt" // altimate_change end import { FileRoutes } from "./routes/file" @@ -688,12 +688,19 @@ export namespace Server { log.info("reload-datamate: config updated, reconnecting MCP servers", { updatedNames }) // Reconnect each updated server using the freshly-written disk entry. // Bypass Config.get() (stale singleton) by reading the file directly. - const configPath = await resolveConfigPath(directory) + // The healed entry may live in any config file the sync covers — + // project, project subdirs, or the global config (scope: "global" + // adds) — so scan them all instead of only the project path. + const configPaths = await findAllConfigPaths(directory, Global.Path.config) const currentStatus = await MCP.status() for (const name of updatedNames) { - const freshEntry = await readMcpEntryFromDisk(name, configPath) + let freshEntry: Awaited> + for (const configPath of configPaths) { + freshEntry = await readMcpEntryFromDisk(name, configPath) + if (freshEntry) break + } if (!freshEntry) { - log.warn("reload-datamate: fresh config entry not found on disk", { name, configPath }) + log.warn("reload-datamate: fresh config entry not found on disk", { name, configPaths }) continue } log.info("reload-datamate: reconnecting with fresh config", { 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 index d6d95427bc..348bcae775 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -198,6 +198,95 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { expect(entry.enabled).toBe(true) }) + test("invocation from a nested subdirectory heals root-level configs", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + await mkdir(path.join(tmp.path, ".git"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "deep"), { recursive: true }) + const projectConfigPath = path.join(tmp.path, "altimate-code.json") + await writeFile( + projectConfigPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T5", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(path.join(tmp.path, "packages", "deep"), globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const entry = JSON.parse(await readFile(projectConfigPath, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + }) + + test("a malformed config file does not abort healing the remaining files", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + // Project config is truncated garbage — addMcpToConfig refuses to rewrite it. + const projectConfigPath = path.join(tmp.path, "altimate-code.json") + await writeFile(projectConfigPath, '{"mcp": {"datamate": {"type": "local", "command": ["x"') + const globalConfigPath = path.join(globalDir, "altimate-code.json") + await writeFile( + globalConfigPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T6", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const entry = JSON.parse(await readFile(globalConfigPath, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + }) + + test("heals a global entry living in altimate-code.jsonc (loader-merged filename)", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + const globalJsoncPath = path.join(globalDir, "altimate-code.jsonc") + await writeFile( + globalJsoncPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T7", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const entry = JSON.parse(await readFile(globalJsoncPath, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + }) + test("heals project and global entries in one pass", async () => { await using tmp = await tmpdir() const globalDir = path.join(tmp.path, "global-config") From 42311f234992bd50d10442be065369f42f57dc00 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 8 Aug 2026 13:00:35 +0800 Subject: [PATCH 08/12] fix: scope legacy config.json to global config candidates only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config loader merges config.json only from the global config dir; the project loader reads only altimate-code.json/.jsonc and opencode.json/.jsonc. Listing config.json in the shared filename set made project-side discovery treat any unrelated project config.json as live config — and resolveConfigPath could return it as the write target for a fresh add, persisting an entry the loader would never load. Split the sets: GLOBAL_CONFIG_FILENAMES carries config.json, project candidates do not. Regression test asserts the global legacy file heals while a project-level config.json is left byte-identical. --- packages/opencode/src/mcp/config.ts | 14 ++++++--- .../mcp-datamate-stdio-env.test.ts | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index 10125fa7a9..195c4da69d 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -9,7 +9,13 @@ import type { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" // opencode.json/.jsonc, legacy config.json) — an entry in any of them is live config, // so lookups/removals/heals must see them all. New writes land in altimate-code.json // (first entry of the list). -const CONFIG_FILENAMES = ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc", "config.json"] +const CONFIG_FILENAMES = ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc"] +// The GLOBAL config dir additionally merges the legacy config.json +// (config/config.ts global load path). The project loader never reads +// config.json, so it must stay out of project-side candidates — otherwise an +// unrelated project file named config.json becomes a discovery hit and, worse, +// a write target for entries the loader would never load. +const GLOBAL_CONFIG_FILENAMES = [...CONFIG_FILENAMES, "config.json"] // altimate_change end export async function resolveConfigPath(baseDir: string, global = false) { @@ -23,8 +29,8 @@ export async function resolveConfigPath(baseDir: string, global = false) { ) } - // Then check root-level configs - candidates.push(...CONFIG_FILENAMES.map((f) => path.join(baseDir, f))) + // Then check root-level configs (the global dir also accepts legacy config.json) + candidates.push(...(global ? GLOBAL_CONFIG_FILENAMES : CONFIG_FILENAMES).map((f) => path.join(baseDir, f))) for (const candidate of candidates) { if (await Filesystem.exists(candidate)) { @@ -101,7 +107,7 @@ export async function listMcpInConfig(configPath: string): Promise { export async function findAllConfigPaths(projectDir: string, globalDir: string): Promise { const paths: string[] = [] for (const dir of [projectDir, globalDir]) { - for (const name of CONFIG_FILENAMES) { + for (const name of dir === globalDir ? GLOBAL_CONFIG_FILENAMES : CONFIG_FILENAMES) { const p = path.join(dir, name) if (await Filesystem.exists(p)) paths.push(p) } 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 index 348bcae775..21025ec951 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -287,6 +287,35 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) }) + test("legacy config.json is healed in the GLOBAL dir but left alone at project level", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + const brokenEntry = { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } + // Global legacy config.json IS merged by the config loader → must heal. + const globalLegacyPath = path.join(globalDir, "config.json") + await writeFile(globalLegacyPath, JSON.stringify({ mcp: { [DATAMATE_KEY]: brokenEntry } }, null, 2)) + // Project config.json is NOT read by the loader → must not be touched. + const projectConfigJsonPath = path.join(tmp.path, "config.json") + const unrelated = JSON.stringify({ mcp: { [DATAMATE_KEY]: brokenEntry } }, null, 2) + await writeFile(projectConfigJsonPath, unrelated) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T8", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const globalEntry = JSON.parse(await readFile(globalLegacyPath, "utf-8")).mcp[DATAMATE_KEY] + expect(globalEntry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + // Byte-identical: the project-level config.json was never rewritten. + expect(await readFile(projectConfigJsonPath, "utf-8")).toBe(unrelated) + }) + test("heals project and global entries in one pass", async () => { await using tmp = await tmpdir() const globalDir = path.join(tmp.path, "global-config") From 7f684289134dd98cf3420cba1f9b47e5c348f3ce Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 8 Aug 2026 13:17:00 +0800 Subject: [PATCH 09/12] test: update reload-endpoint source guard for the multi-path disk read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial guard asserted the single-path read line verbatim; the endpoint now scans every config file the heal covers. The guarded contract — stale-singleton bypass via readMcpEntryFromDisk + MCP.add — is unchanged and still asserted. --- .../test/upstream/adversarial/upi-config-mcp.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/upstream/adversarial/upi-config-mcp.test.ts b/packages/opencode/test/upstream/adversarial/upi-config-mcp.test.ts index c0b3abaa7d..016664dfea 100644 --- a/packages/opencode/test/upstream/adversarial/upi-config-mcp.test.ts +++ b/packages/opencode/test/upstream/adversarial/upi-config-mcp.test.ts @@ -197,7 +197,11 @@ describe("UPI-25 through UPI-27 MCP persistence, names, pagination, and resource expect(mcpSource).toContain("persistChain.then(() =>") expect(mcpSource).toContain("persistChain = run.catch(() => {})") expect(serverSource).toContain("Bypass Config.get() (stale singleton) by reading the file directly.") - expect(serverSource).toContain("const freshEntry = await readMcpEntryFromDisk(name, configPath)") + // The disk read scans every config file the heal covers (project, subdirs, + // global) — the healed entry may live in any of them; the stale-singleton + // bypass contract is unchanged (readMcpEntryFromDisk + MCP.add, no Config.get()). + expect(serverSource).toContain("const configPaths = await findAllConfigPaths(directory, Global.Path.config)") + expect(serverSource).toContain("freshEntry = await readMcpEntryFromDisk(name, configPath)") expect(serverSource).toContain("await MCP.add(name, freshEntry)") }) }) From 9b16734815e975c2a74e8fc5e811b2aa18bb4cfd Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Mon, 10 Aug 2026 21:09:24 +0800 Subject: [PATCH 10/12] fix: skip blanked {} datamate entries when selecting the mcp.json source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension blanks the datamate entry to {} (not delete) in non-active-IDE mcp.json files, and the sorted scan can reach the blanked file first (.cursor/ sorts before .vscode/). For the transport read that shadowed the real entry behind the bare-marker fallback; for the sync it silently skipped the heal entirely ({} has no updatedAt). Empty entries are tombstones, not transports — both selection loops now skip them so the active IDE's real entry wins. --- .../src/altimate/datamate-transport.ts | 12 ++++- .../mcp-datamate-stdio-env.test.ts | 54 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index eb6786e591..eceadcd9cf 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -152,7 +152,11 @@ export async function readDatamateTransportFromIde( const parsed = JSON.parse(text) as Record const serversMap = extractServersMap(parsed) const entry = serversMap[DATAMATE_KEY] - if (!entry) continue + // The extension blanks `datamate` to {} (not delete) in non-active-IDE + // mcp.json files, and the sorted scan can reach the blanked file first + // (`.cursor/` sorts before `.vscode/`). An empty entry is a tombstone, + // not a transport — skip it so the active IDE's real entry is found. + if (!entry || Object.keys(entry).length === 0) continue log.info("readDatamateTransportFromIde: found entry", { source: relPath, @@ -228,7 +232,11 @@ export async function syncDatamateUrlFromVscodeMcp( const text = await readFile(candidate, "utf-8") const parsed = JSON.parse(text) as Record const map = extractServersMap(parsed) - if (map[DATAMATE_KEY]) { + // Same tombstone rule as readDatamateTransportFromIde: a blanked {} + // entry (non-active-IDE file) must not be selected as the sync source — + // it has no updatedAt, so the heal would silently skip while the real + // entry sits in the next file. + if (map[DATAMATE_KEY] && Object.keys(map[DATAMATE_KEY]).length > 0) { mcpJsonPath = candidate serversMap = map break 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 index 21025ec951..7671893fab 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -132,6 +132,60 @@ describe("resolveDatamateSyncRoot", () => { }) }) +describe("blanked {} datamate entries (non-active-IDE tombstones)", () => { + test("readDatamateTransportFromIde skips a blanked entry that sorts first", async () => { + await using tmp = await tmpdir() + // .cursor sorts before .vscode; the extension blanks datamate to {} in + // non-active-IDE files. + await mkdir(path.join(tmp.path, ".cursor"), { recursive: true }) + await writeFile(path.join(tmp.path, ".cursor", "mcp.json"), JSON.stringify({ mcpServers: { [DATAMATE_KEY]: {} } })) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T9", + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t?.type).toBe("local") + if (t?.type === "local") { + expect(t.command[0]).toBe("/path/to/electron") + expect(t.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + } + }) + + test("sync source selection skips a blanked entry that sorts first", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "isolated-global") + await mkdir(path.join(tmp.path, ".cursor"), { recursive: true }) + await writeFile(path.join(tmp.path, ".cursor", "mcp.json"), JSON.stringify({ mcpServers: { [DATAMATE_KEY]: {} } })) + const configPath = path.join(tmp.path, "altimate-code.json") + await writeFile( + configPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T10", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const entry = JSON.parse(await readFile(configPath, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + expect(entry.updatedAt).toBe("T10") + }) +}) + 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() From 692417ac5fbea1c654191545b05df30f899f6427 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 26 Aug 2026 12:53:40 +0800 Subject: [PATCH 11/12] refactor: share the blank-tombstone predicate between both mcp.json scans The transport read and the sync source selection each open-coded the "missing or empty datamate entry" check. One isBlankDatamateEntry helper keeps the two scans agreeing on what a tombstone is. --- .../src/altimate/datamate-transport.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index eceadcd9cf..a4a26122fb 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -43,6 +43,17 @@ function extractSpawnEnvironment(raw: unknown): Record | undefin return Object.keys(env).length > 0 ? env : undefined } +/** + * A missing or empty datamate entry. The extension blanks `datamate` to {} + * (not delete) in non-active-IDE mcp.json files, so an empty object is a + * tombstone, not a transport — both mcp.json scans must skip it, or the + * active IDE's real entry is shadowed by whichever file sorts first + * (`.cursor/` sorts before `.vscode/`). + */ +function isBlankDatamateEntry(entry: unknown): boolean { + return !entry || typeof entry !== "object" || Object.keys(entry as object).length === 0 +} + /** * Root directory the boot-time heal should scan from: the containing git * project root when there is one, else the directory itself. Boot-time callers @@ -152,11 +163,7 @@ export async function readDatamateTransportFromIde( const parsed = JSON.parse(text) as Record const serversMap = extractServersMap(parsed) const entry = serversMap[DATAMATE_KEY] - // The extension blanks `datamate` to {} (not delete) in non-active-IDE - // mcp.json files, and the sorted scan can reach the blanked file first - // (`.cursor/` sorts before `.vscode/`). An empty entry is a tombstone, - // not a transport — skip it so the active IDE's real entry is found. - if (!entry || Object.keys(entry).length === 0) continue + if (isBlankDatamateEntry(entry)) continue log.info("readDatamateTransportFromIde: found entry", { source: relPath, @@ -232,11 +239,10 @@ export async function syncDatamateUrlFromVscodeMcp( const text = await readFile(candidate, "utf-8") const parsed = JSON.parse(text) as Record const map = extractServersMap(parsed) - // Same tombstone rule as readDatamateTransportFromIde: a blanked {} - // entry (non-active-IDE file) must not be selected as the sync source — - // it has no updatedAt, so the heal would silently skip while the real - // entry sits in the next file. - if (map[DATAMATE_KEY] && Object.keys(map[DATAMATE_KEY]).length > 0) { + // A tombstone must not become the sync source either: it has no + // updatedAt, so the heal would silently skip while the real entry sits + // in the next file. + if (!isBlankDatamateEntry(map[DATAMATE_KEY])) { mcpJsonPath = candidate serversMap = map break From e96f26b06b07158248c9000c340b2966cb1b3dd6 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 26 Aug 2026 20:31:34 +0800 Subject: [PATCH 12/12] fix: bind the datamate heal to extension-written IDE files and never auto-rewrite a global entry from a project file Review on this PR showed the boot-time heal plus the global-config heal had turned the pre-existing "scan any mcp.json in the tree" transport source into a trust-boundary crossing: a repo-local file could replace the global datamate entry's command/args/env automatically at every session start, and the carried env overrode the host process env at spawn. - Env carry is an allowlist (ELECTRON_RUN_AS_NODE only), not a denylist. - Transport sources are only the two locations the extension writes (**/.vscode/mcp.json, **/.cursor/mcp.json), parsed through a validating parseIdeTransport: local needs a non-empty command, remote a non-empty url; blank tombstones and incomplete entries are skipped instead of winning selection (an incomplete entry used to persist as a url-less remote entry). The old bare-marker fallback is gone. - Entries derived from an IDE file carry provenance (managedBy + sourceMcpJson). The boot heal rewrites project-scope entries as before but touches a GLOBAL entry only when its stamp matches the exact IDE file in hand; hand-added and legacy global entries are left alone, and an explicit datamate_manager add is what (re)stamps them. - The project-root walk stops at the home directory, and a .git at home is not a project; .git files (worktrees/submodules) resolve to the nearest root as in Project.fromDirectory. - Config heal candidates are every directory from the launch dir up to the root (plus the global dir under the provenance rule), matching the loader's upward walk so a nested package's own config is healed too. - mcp/config exposes findProjectConfigPaths/findGlobalConfigPaths. PR893 regression tests updated to the new contract (IDE-only locations, required transport.source, incomplete entries rejected, allowlist env); new tests cover each review point. --- .../src/altimate/datamate-transport.ts | 309 +++++++++--------- .../opencode/src/altimate/tools/datamate.ts | 9 +- packages/opencode/src/mcp/config.ts | 35 +- .../mcp-datamate-893-codex.test.ts | 36 +- .../mcp-datamate-893.test.ts | 17 +- .../mcp-datamate-stdio-env.test.ts | 200 +++++++++++- 6 files changed, 414 insertions(+), 192 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index a4a26122fb..a93ba2668a 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -1,7 +1,7 @@ import { readFile } from "fs/promises" import path from "path" import { parseTree, findNodeAtLocation, getNodeValue } from "jsonc-parser" -import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk, findAllConfigPaths } from "../mcp/config" +import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk, findProjectConfigPaths, findGlobalConfigPaths } from "../mcp/config" import { Global } from "../global" import { Filesystem } from "../util/filesystem" import { Glob } from "@opencode-ai/core/util/glob" @@ -21,37 +21,74 @@ const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const export type DatamateTransport = - | { type: "remote"; url: string; updatedAt?: string } - | { type: "local"; command: string[]; environment?: Record; updatedAt?: string } + | { type: "remote"; url: string; updatedAt?: string; source: string } + | { type: "local"; command: string[]; environment?: Record; updatedAt?: string; source: 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. + * Provenance stamp on entries altimate-code derived from an IDE mcp.json. + * `managedBy` marks the entry as ours; `sourceMcpJson` binds it to the exact + * file it came from. The boot-time heal only rewrites a GLOBAL entry that + * carries a matching stamp — a hand-added or legacy global entry is never + * silently replaced from a project-local file. */ +export const DATAMATE_PROVENANCE = "altimate-ide" + +/** + * The only mcp.json locations the extension writes (`.${ide}/mcp.json`, ide ∈ + * vscode|cursor). Anything else in a checkout is not an extension-authored + * entry and must not become a transport source. + */ +const IDE_MCP_JSON_PATTERNS = ["**/.vscode/mcp.json", "**/.cursor/mcp.json"] + +/** + * Env keys carried from an IDE entry into the spawn. ELECTRON_RUN_AS_NODE is + * the one the fix exists for: 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 — instead of running it. + * Nothing else is taken: the carried env is spread over altimate-code's own + * process env at spawn, so a denylist would let a repo-local file override + * NODE_OPTIONS/LD_PRELOAD/PATH for the child. + */ +const SPAWN_ENV_ALLOWLIST: ReadonlySet = new Set(["ELECTRON_RUN_AS_NODE"]) + 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 (!SPAWN_ENV_ALLOWLIST.has(key)) continue if (typeof value === "string") env[key] = value } return Object.keys(env).length > 0 ? env : undefined } /** - * A missing or empty datamate entry. The extension blanks `datamate` to {} - * (not delete) in non-active-IDE mcp.json files, so an empty object is a - * tombstone, not a transport — both mcp.json scans must skip it, or the - * active IDE's real entry is shadowed by whichever file sorts first - * (`.cursor/` sorts before `.vscode/`). + * Validate an IDE mcp.json `datamate` entry into a transport, or null when it + * is not one: missing, a blanked {} tombstone (the extension blanks the entry + * in non-active-IDE files), or incomplete (no usable `url` for remote, no + * usable `command` for stdio). Incomplete entries must not win source + * selection — an entry like `{type:"stdio", updatedAt:…}` would otherwise be + * persisted as `{type:"remote"}` with no url and break config loading. */ -function isBlankDatamateEntry(entry: unknown): boolean { - return !entry || typeof entry !== "object" || Object.keys(entry as object).length === 0 +export function parseIdeTransport(entry: unknown, source: string): DatamateTransport | null { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null + const e = entry as Record + if (Object.keys(e).length === 0) return null + const updatedAt = typeof e["updatedAt"] === "string" && e["updatedAt"] ? { updatedAt: e["updatedAt"] } : {} + if (typeof e["url"] === "string" && e["url"].length > 0) { + return { type: "remote", url: e["url"], ...updatedAt, source } + } + if (typeof e["command"] === "string" && e["command"].length > 0) { + const args = Array.isArray(e["args"]) ? e["args"].filter((a): a is string => typeof a === "string") : [] + const environment = extractSpawnEnvironment(e["env"]) + return { + type: "local", + command: [e["command"], ...args], + ...(environment ? { environment } : {}), + ...updatedAt, + source, + } + } + return null } /** @@ -64,10 +101,20 @@ function isBlankDatamateEntry(entry: unknown): boolean { */ export async function resolveDatamateSyncRoot(directory: string): Promise { try { - const matches = Filesystem.up({ targets: [".git"], start: directory }) + // Bounded at the home directory: an unbounded walk reaches `/`, and a home + // under dotfiles management would otherwise become the "project" — its + // whole tree scanned for a datamate entry from any unrelated project. + // `stop` is inclusive, so a `.git` AT home still matches and is rejected. + // `.git` may be a file (worktrees, submodules); the nearest one wins, + // matching how Project.fromDirectory derives the sandbox. + const home = Global.Path.home + const matches = Filesystem.up({ targets: [".git"], start: directory, stop: home }) const dotgit = await matches.next().then((x) => x.value) await matches.return() - if (dotgit) return path.dirname(dotgit) + if (dotgit) { + const root = path.dirname(dotgit) + if (root !== home) return root + } } catch { // fall through to the directory itself } @@ -87,6 +134,8 @@ export const TRANSPORT_IDENTITY_FIELDS: ReadonlySet = new Set([ "environment", "url", "updatedAt", + "managedBy", + "sourceMcpJson", ]) /** @@ -111,11 +160,10 @@ function extractServersMap( */ async function findAllMcpJsonFiles(projectRootDir: string): Promise { try { - const paths = await Glob.scan("**/mcp.json", { - cwd: projectRootDir, - absolute: true, - dot: true, - }) + const paths: string[] = [] + for (const pattern of IDE_MCP_JSON_PATTERNS) { + paths.push(...(await Glob.scan(pattern, { cwd: projectRootDir, absolute: true, dot: true }))) + } // Exclude build/dependency/output trees. command + args from a discovered // mcp.json are passed to StdioClientTransport, so keep the scan to source the // user actually authors and out of vendored/generated directories. The new core @@ -154,54 +202,18 @@ async function findAllMcpJsonFiles(projectRootDir: string): Promise { export async function readDatamateTransportFromIde( projectRootDir: string, ): Promise { - const mcpJsonPaths = await findAllMcpJsonFiles(projectRootDir) - - for (const mcpJsonPath of mcpJsonPaths) { + for (const mcpJsonPath of await findAllMcpJsonFiles(projectRootDir)) { const relPath = path.relative(projectRootDir, mcpJsonPath) try { - const text = await readFile(mcpJsonPath, "utf-8") - const parsed = JSON.parse(text) as Record - const serversMap = extractServersMap(parsed) - const entry = serversMap[DATAMATE_KEY] - if (isBlankDatamateEntry(entry)) continue - - log.info("readDatamateTransportFromIde: found entry", { - source: relPath, - type: entry["type"] ?? "(no type)", - }) - - if (typeof entry["url"] === "string") { - // updatedAt carried for parity with the local branch: the boot-time sync - // uses it as its change signal regardless of transport type, and an entry - // persisted without it gets one redundant rewrite on the next boot. - const updatedAt = typeof entry["updatedAt"] === "string" ? entry["updatedAt"] : undefined - return { type: "remote", url: entry["url"], ...(updatedAt ? { updatedAt } : {}) } - } - - // 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) { - 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 - return { type: "local", command: [DATAMATE_KEY, "start-stdio"] } + const parsed = JSON.parse(await readFile(mcpJsonPath, "utf-8")) as Record + const transport = parseIdeTransport(extractServersMap(parsed)[DATAMATE_KEY], mcpJsonPath) + if (!transport) continue + log.info("readDatamateTransportFromIde: found entry", { source: relPath, type: transport.type }) + return transport } catch { log.warn("readDatamateTransportFromIde: failed to parse", { source: relPath }) } } - log.info("readDatamateTransportFromIde: no IDE entry found, falling back to cloud config") return null } @@ -217,33 +229,29 @@ export async function readDatamateTransportFromIde( * Returns the list of MCP server names whose config was updated on disk. */ export async function syncDatamateUrlFromVscodeMcp( - cwd: string, + launchDir: string, // Overridable for tests only — the real global config dir is a static xdg path. globalConfigDir: string = Global.Path.config, ): Promise { const updated: string[] = [] try { - // Resolve the project root here rather than in each caller: an invocation - // from a nested subdirectory must still find the root .vscode/mcp.json and - // the root-level config files it needs to repair. - cwd = await resolveDatamateSyncRoot(cwd) - log.info("syncDatamateUrlFromVscodeMcp: start", { cwd }) - - // Find the first mcp.json that contains a "datamate" entry. - const mcpJsonPaths = await findAllMcpJsonFiles(cwd) - let mcpJsonPath: string | undefined + // IDE discovery is scoped to the project root; the config heal walks from + // the launch directory up to that root, mirroring the loader (a nested + // package's own opencode.json is loaded and overrides the root entry, so + // it must be healed too — Codex review on this PR). + const root = await resolveDatamateSyncRoot(launchDir) + const cwd = root + log.info("syncDatamateUrlFromVscodeMcp: start", { launchDir, root }) + + // First VALID datamate transport among the extension-written mcp.json files. + let transport: DatamateTransport | undefined let serversMap: Record> = {} - - for (const candidate of mcpJsonPaths) { + for (const candidate of await findAllMcpJsonFiles(root)) { try { - const text = await readFile(candidate, "utf-8") - const parsed = JSON.parse(text) as Record - const map = extractServersMap(parsed) - // A tombstone must not become the sync source either: it has no - // updatedAt, so the heal would silently skip while the real entry sits - // in the next file. - if (!isBlankDatamateEntry(map[DATAMATE_KEY])) { - mcpJsonPath = candidate + const map = extractServersMap(JSON.parse(await readFile(candidate, "utf-8")) as Record) + const parsed = parseIdeTransport(map[DATAMATE_KEY], candidate) + if (parsed) { + transport = parsed serversMap = map break } @@ -252,50 +260,47 @@ export async function syncDatamateUrlFromVscodeMcp( } } - if (!mcpJsonPath) { - log.info("syncDatamateUrlFromVscodeMcp: no mcp.json with datamate entry found, skipping sync") + if (!transport) { + log.info("syncDatamateUrlFromVscodeMcp: no mcp.json with a valid datamate entry found, skipping sync") return updated } - - log.info("syncDatamateUrlFromVscodeMcp: using config", { - source: path.relative(cwd, mcpJsonPath), - }) + log.info("syncDatamateUrlFromVscodeMcp: using config", { source: path.relative(root, transport.source) }) // ── "datamate" entry: sync by updatedAt (works for stdio + HTTP) ──────── - const datamateVscode = serversMap[DATAMATE_KEY] - const vscodeUpdatedAt = - datamateVscode && typeof datamateVscode["updatedAt"] === "string" - ? (datamateVscode["updatedAt"] as string) - : undefined - - if (datamateVscode && vscodeUpdatedAt) { - // The entry may live in the project config OR the global one - // (`datamate_manager add` supports scope: "global") — a stale global entry - // is spawned at session start just the same, so heal every config file - // that carries a datamate entry, not only the project's. - const healEntryInFile = async (configPath: string): Promise => { + const vscodeUpdatedAt = transport.updatedAt + if (vscodeUpdatedAt) { + const ideTransport = transport + const healEntryInFile = async (configPath: string, scope: "project" | "global"): Promise => { const configText = await Filesystem.readText(configPath) const existingTree = parseTree(configText) - const existingNode = existingTree - ? findNodeAtLocation(existingTree, ["mcp", DATAMATE_KEY]) - : undefined + const existingNode = existingTree ? findNodeAtLocation(existingTree, ["mcp", DATAMATE_KEY]) : undefined if (!existingNode) return false // getNodeValue reconstructs the full entry (a manual children walk reading // `prop.children[1].value` drops array/object fields — jsonc-parser only // populates `Node.value` for primitives). const existingEntry = - existingNode.type === "object" - ? (getNodeValue(existingNode) as Record) - : {} + existingNode.type === "object" ? (getNodeValue(existingNode) as Record) : {} + + // A GLOBAL entry outlives the project, so it is rewritten only when it + // carries our provenance stamp bound to THIS mcp.json. Hand-added or + // legacy global entries stay untouched; `datamate_manager add` is the + // explicit path that (re)stamps them. + if (scope === "global") { + const managed = + existingEntry["managedBy"] === DATAMATE_PROVENANCE && existingEntry["sourceMcpJson"] === ideTransport.source + if (!managed) { + log.info("syncDatamateUrlFromVscodeMcp: global datamate entry not managed from this IDE file, leaving it", { + configPath, + }) + return false + } + } + const existingUpdatedAt = typeof existingEntry["updatedAt"] === "string" ? existingEntry["updatedAt"] : undefined - if (vscodeUpdatedAt === existingUpdatedAt) { - log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date", { - configPath, - updatedAt: vscodeUpdatedAt, - }) + log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date", { configPath, updatedAt: vscodeUpdatedAt }) return false } @@ -307,51 +312,51 @@ export async function syncDatamateUrlFromVscodeMcp( for (const [k, v] of Object.entries(existingEntry)) { if (!TRANSPORT_IDENTITY_FIELDS.has(k)) preserved[k] = v } - - let newEntry: Record - if ("command" in datamateVscode) { - const environment = extractSpawnEnvironment(datamateVscode["env"]) - const cmd = - typeof datamateVscode["command"] === "string" - ? (datamateVscode["command"] as string) - : DATAMATE_KEY - newEntry = { - ...preserved, - type: "local", - command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])], - ...(environment ? { environment } : {}), - updatedAt: vscodeUpdatedAt, - } - } else { - // http / streamable-http / sse → remote - newEntry = { - ...preserved, - type: "remote", - url: datamateVscode["url"] as string, - updatedAt: vscodeUpdatedAt, - } + const newEntry: Record = { + ...preserved, + ...(ideTransport.type === "local" + ? { + type: "local", + command: ideTransport.command, + ...(ideTransport.environment ? { environment: ideTransport.environment } : {}), + } + : { type: "remote", url: ideTransport.url }), + updatedAt: vscodeUpdatedAt, + managedBy: DATAMATE_PROVENANCE, + sourceMcpJson: ideTransport.source, } - await addMcpToConfig( - DATAMATE_KEY, - newEntry as Parameters[1], - configPath, - ) - log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", { - configPath, - type: datamateVscode["type"], - updatedAt: vscodeUpdatedAt, - }) + await addMcpToConfig(DATAMATE_KEY, newEntry as Parameters[1], configPath) + log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", { configPath, type: ideTransport.type, updatedAt: vscodeUpdatedAt }) return true } + // Project-scope candidates: every directory from the launch dir up to the + // root (inclusive), each with its .altimate-code/.opencode subdirs. + const candidates: Array<{ path: string; scope: "project" | "global" }> = [] + const seen = new Set() + let dir = path.resolve(launchDir) + const rootResolved = path.resolve(root) + while (true) { + for (const p of await findProjectConfigPaths(dir)) { + if (!seen.has(p)) { seen.add(p); candidates.push({ path: p, scope: "project" }) } + } + if (dir === rootResolved || !dir.startsWith(rootResolved)) break + const parent = path.dirname(dir) + if (parent === dir) break + dir = parent + } + for (const p of await findGlobalConfigPaths(globalConfigDir)) { + if (!seen.has(p)) { seen.add(p); candidates.push({ path: p, scope: "global" }) } + } + let datamateHealed = false - for (const configPath of await findAllConfigPaths(cwd, globalConfigDir)) { + for (const { path: configPath, scope } of candidates) { // Per-file isolation: one malformed config (addMcpToConfig refuses to // rewrite unparseable files by throwing) must not abort the heal for the - // remaining project/global files. + // remaining files. try { - if (await healEntryInFile(configPath)) datamateHealed = true + if (await healEntryInFile(configPath, scope)) datamateHealed = true } catch (err) { log.warn("syncDatamateUrlFromVscodeMcp: skipping unhealable config file", { configPath, diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 95e1656257..1565227109 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -13,7 +13,7 @@ import { import { Instance } from "../../project/instance" import { Global } from "../../global" import { Log } from "@/altimate/util/log" -import { DATAMATE_KEY, readDatamateTransportFromIde, TRANSPORT_IDENTITY_FIELDS } from "../datamate-transport" +import { DATAMATE_KEY, DATAMATE_PROVENANCE, readDatamateTransportFromIde, TRANSPORT_IDENTITY_FIELDS } from "../datamate-transport" const log = Log.create({ service: "datamate" }) @@ -229,6 +229,11 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p // mcp.json sync uses it to recognize the entry as current instead of // rewriting it on the next boot. const updatedAtField = transport.updatedAt ? { updatedAt: transport.updatedAt } : {} + // Provenance (disk-only): marks the entry as derived from this exact IDE + // file. The boot-time heal rewrites a GLOBAL entry only when this stamp + // matches, so an explicit `add` is what authorizes future auto-repair of + // a global-scope entry. + const provenanceFields = { managedBy: DATAMATE_PROVENANCE, sourceMcpJson: transport.source } const existingNames = await listMcpInConfig(configPath) const staleEntries = existingNames.filter( (n) => n !== DATAMATE_KEY && n.startsWith("datamate-"), @@ -286,6 +291,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p ...mcpConfig, enabled: true, ...updatedAtField, + ...provenanceFields, } await addMcpToConfig(DATAMATE_KEY, refreshed as Parameters[1], configPath) // The live client must get the same merged entry as the disk write — the @@ -302,6 +308,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p ...mcpConfig, enabled: true, ...updatedAtField, + ...provenanceFields, } await addMcpToConfig(DATAMATE_KEY, diskEntry as Parameters[1], configPath) await MCP.add(DATAMATE_KEY, mcpConfig) diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index 195c4da69d..0a39f32738 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -104,26 +104,35 @@ export async function listMcpInConfig(configPath: string): Promise { } /** Find all config files that exist (project + global) */ -export async function findAllConfigPaths(projectDir: string, globalDir: string): Promise { +export async function findProjectConfigPaths(projectDir: string): Promise { const paths: string[] = [] - for (const dir of [projectDir, globalDir]) { - for (const name of dir === globalDir ? GLOBAL_CONFIG_FILENAMES : CONFIG_FILENAMES) { - const p = path.join(dir, name) + for (const name of CONFIG_FILENAMES) { + const p = path.join(projectDir, name) + if (await Filesystem.exists(p)) paths.push(p) + } + // Also check .altimate-code and .opencode subdirectories + for (const subdir of [".altimate-code", ".opencode"]) { + for (const name of CONFIG_FILENAMES) { + const p = path.join(projectDir, subdir, name) if (await Filesystem.exists(p)) paths.push(p) } - // Also check .altimate-code and .opencode subdirectories for project - if (dir === projectDir) { - for (const subdir of [".altimate-code", ".opencode"]) { - for (const name of CONFIG_FILENAMES) { - const p = path.join(dir, subdir, name) - if (await Filesystem.exists(p)) paths.push(p) - } - } - } } return paths } +export async function findGlobalConfigPaths(globalDir: string): Promise { + const paths: string[] = [] + for (const name of GLOBAL_CONFIG_FILENAMES) { + const p = path.join(globalDir, name) + if (await Filesystem.exists(p)) paths.push(p) + } + return paths +} + +export async function findAllConfigPaths(projectDir: string, globalDir: string): Promise { + return [...(await findProjectConfigPaths(projectDir)), ...(await findGlobalConfigPaths(globalDir))] +} + /** * Read a single MCP entry directly from a config file, bypassing the Config * singleton so callers can get the freshly-written config without busting the diff --git a/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts b/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts index dfa66a6f3a..c4af7f36d3 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts @@ -114,6 +114,7 @@ describe("PR #893 datamate IDE transport selection", () => { await expect(readDatamateTransportFromIde(project.path)).resolves.toEqual({ type: "remote", url: "https://datamate.example.com/sse", + source: path.join(project.path, ".vscode", "mcp.json"), }) }) @@ -131,32 +132,37 @@ describe("PR #893 datamate IDE transport selection", () => { await expect(readDatamateTransportFromIde(project.path)).resolves.toEqual({ type: "local", command: ["bunx", "@altimate/datamate", "start-stdio", "--workspace", project.path], + source: path.join(project.path, ".cursor", "mcp.json"), }) }) - test("falls back to safe local datamate marker when IDE entry has no usable transport fields", async () => { + test("rejects an IDE entry with no usable transport fields (no marker fallback)", async () => { + // Contract change (review on the stdio-env fix): an incomplete entry — no + // usable `url` or `command` — is not a transport. It used to fall back to a + // bare `datamate start-stdio` marker; now it is skipped so it can neither + // shadow a valid entry in a later file nor be persisted as a malformed + // `remote` entry without a url. await using project = await tmpdir() await writeJson(path.join(project.path, ".vscode/mcp.json"), { servers: { datamate: { type: "stdio", args: ["ignored-without-command"] } }, }) - await expect(readDatamateTransportFromIde(project.path)).resolves.toEqual({ - type: "local", - command: ["datamate", "start-stdio"], - }) + await expect(readDatamateTransportFromIde(project.path)).resolves.toBeNull() }) test("skips malformed mcp.json and uses the next valid datamate entry", async () => { await using project = await tmpdir() - await mkdir(path.join(project.path, "a-bad"), { recursive: true }) - await writeFile(path.join(project.path, "a-bad/mcp.json"), "{ not json") - await writeJson(path.join(project.path, "z-good/mcp.json"), { + // Only the extension-written locations (.vscode/ and .cursor/) are scanned. + await mkdir(path.join(project.path, "a-bad", ".vscode"), { recursive: true }) + await writeFile(path.join(project.path, "a-bad/.vscode/mcp.json"), "{ not json") + await writeJson(path.join(project.path, "z-good/.vscode/mcp.json"), { servers: { datamate: { url: "https://good.example.com/mcp" } }, }) await expect(readDatamateTransportFromIde(project.path)).resolves.toEqual({ type: "remote", url: "https://good.example.com/mcp", + source: path.join(project.path, "z-good", ".vscode", "mcp.json"), }) }) @@ -172,6 +178,7 @@ describe("PR #893 datamate IDE transport selection", () => { await expect(readDatamateTransportFromIde(project.path)).resolves.toEqual({ type: "local", command: ["datamate", "start-stdio"], + source: path.join(project.path, ".vscode", "mcp.json"), }) }) }) @@ -206,21 +213,30 @@ describe("PR #893 datamate sync to altimate-code config", () => { }, }) - const updated = await syncDatamateUrlFromVscodeMcp(project.path) + // Isolated global dir: the sync also heals the global config, and tests must + // never touch the developer's real one. + const updated = await syncDatamateUrlFromVscodeMcp(project.path, path.join(project.path, "isolated-global")) const entry = await readMcpEntryFromDisk("datamate", configPath) const raw = await readFile(configPath, "utf-8") expect(updated).toEqual(["datamate"]) + // Contract change (security review on the stdio-env fix): the carried env is + // an ALLOWLIST (ELECTRON_RUN_AS_NODE only), not a denylist — arbitrary keys + // like KEEP_ME are dropped, since the carried env is spread over the host + // process env at spawn. The synced entry is also stamped with provenance + // bound to the IDE file it came from. expect(entry).toEqual({ type: "local", command: ["datamate", "start-stdio", "--port", "0"], - environment: { KEEP_ME: "yes" }, enabled: false, timeout: 12345, updatedAt: "2026-06-17T10:00:00.000Z", + managedBy: "altimate-ide", + sourceMcpJson: path.join(project.path, ".vscode", "mcp.json"), } as any) expect(raw).not.toContain("extension-rpc-secret") expect(raw).not.toContain("ALTIMATE_EXTENSION_RPC") + expect(raw).not.toContain("KEEP_ME") }) test("does not rewrite datamate when updatedAt already matches IDE config", async () => { diff --git a/packages/opencode/test/release-validation/mcp-datamate-893.test.ts b/packages/opencode/test/release-validation/mcp-datamate-893.test.ts index fd0b03ef63..0b0e428544 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-893.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-893.test.ts @@ -268,21 +268,22 @@ describe("PR893: findAllConfigPaths project subdir coverage", () => { describe("PR893: readDatamateTransportFromIde sorted-first selection + classification", () => { test("first sorted datamate-bearing mcp.json wins (a/ before b/)", async () => { await using tmp = await tmpdir() - // a/mcp.json — stdio datamate; b/mcp.json — http datamate. - await mkdir(path.join(tmp.path, "a"), { recursive: true }) - await mkdir(path.join(tmp.path, "b"), { recursive: true }) + // a/.vscode/mcp.json — stdio datamate; b/.vscode/mcp.json — http datamate. + // (Only extension-written locations, .vscode/ and .cursor/, are scanned.) + await mkdir(path.join(tmp.path, "a", ".vscode"), { recursive: true }) + await mkdir(path.join(tmp.path, "b", ".vscode"), { recursive: true }) await writeFile( - path.join(tmp.path, "a", "mcp.json"), + path.join(tmp.path, "a", ".vscode", "mcp.json"), JSON.stringify({ servers: { [DATAMATE_KEY]: { type: "stdio", command: "datamate", args: ["x"] } } }), ) await writeFile( - path.join(tmp.path, "b", "mcp.json"), + path.join(tmp.path, "b", ".vscode", "mcp.json"), JSON.stringify({ servers: { [DATAMATE_KEY]: { url: "http://from-b" } } }), ) const t = await readDatamateTransportFromIde(tmp.path) // a/ sorts before b/ → stdio entry from a/ wins → local transport. - expect(t).toEqual({ type: "local", command: ["datamate", "x"] }) + expect(t).toEqual({ type: "local", command: ["datamate", "x"], source: path.join(tmp.path, "a", ".vscode", "mcp.json") }) }) test("stdio entry → local(command+args); http entry → remote(url)", async () => { @@ -293,7 +294,7 @@ describe("PR893: readDatamateTransportFromIde sorted-first selection + classific JSON.stringify({ mcpServers: { [DATAMATE_KEY]: { url: "https://remote-only" } } }), ) const t = await readDatamateTransportFromIde(tmp.path) - expect(t).toEqual({ type: "remote", url: "https://remote-only" }) + expect(t).toEqual({ type: "remote", url: "https://remote-only", source: path.join(tmp.path, ".cursor", "mcp.json") }) }) test("returns null when no mcp.json contains a datamate entry", async () => { @@ -319,7 +320,7 @@ describe("PR893: readDatamateTransportFromIde sorted-first selection + classific const t = await readDatamateTransportFromIde(tmp.path) // The implementation checks `typeof entry.url === "string"` first, so a URL // present always classifies as remote regardless of command/type. - expect(t).toEqual({ type: "remote", url: "http://wins" }) + expect(t).toEqual({ type: "remote", url: "http://wins", source: path.join(tmp.path, ".vscode", "mcp.json") }) }) }) 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 index 7671893fab..31b50a27fb 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -7,6 +7,7 @@ import { syncDatamateUrlFromVscodeMcp, resolveDatamateSyncRoot, DATAMATE_KEY, + DATAMATE_PROVENANCE, } from "../../src/altimate/datamate-transport" // Regression tests for the stdio env carry-through. The IDE extension writes the @@ -17,6 +18,17 @@ import { // so `datamate_manager add` persisted a broken entry that re-popped the file on // every session launch. +/** Env-less broken entry as a fixed `datamate_manager add` would have stamped it. */ +function stamped(root: string) { + return { + type: "local", + command: ["/path/to/electron", "cli.js"], + enabled: true, + managedBy: DATAMATE_PROVENANCE, + sourceMcpJson: path.join(root, ".vscode", "mcp.json"), + } +} + async function seedIdeStdio(dir: string, entry: Record) { await mkdir(path.join(dir, ".vscode"), { recursive: true }) await writeFile( @@ -45,6 +57,7 @@ describe("readDatamateTransportFromIde stdio env carry-through", () => { command: ["/path/to/electron", "/ext/dist/datamate-cli.js", "start-stdio"], environment: { ELECTRON_RUN_AS_NODE: "1" }, updatedAt: "2026-08-06T00:00:00.000Z", + source: path.join(tmp.path, ".vscode", "mcp.json"), }) }) @@ -61,6 +74,7 @@ describe("readDatamateTransportFromIde stdio env carry-through", () => { expect(t).toEqual({ type: "local", command: ["/usr/lib/code-server/lib/node", "/ext/dist/datamate-cli.js", "start-stdio"], + source: path.join(tmp.path, ".vscode", "mcp.json"), }) }) @@ -77,6 +91,7 @@ describe("readDatamateTransportFromIde stdio env carry-through", () => { type: "remote", url: "http://localhost:7801/mcp", updatedAt: "2026-08-06T00:00:00.000Z", + source: path.join(tmp.path, ".vscode", "mcp.json"), }) }) @@ -89,7 +104,7 @@ describe("readDatamateTransportFromIde stdio env carry-through", () => { }) const t = await readDatamateTransportFromIde(tmp.path) - expect(t).toEqual({ type: "local", command: ["datamate", "start-stdio"] }) + expect(t).toEqual({ type: "local", command: ["datamate", "start-stdio"], source: path.join(tmp.path, ".vscode", "mcp.json"), }) }) test("non-string env values are ignored, string values kept", async () => { @@ -164,7 +179,7 @@ describe("blanked {} datamate entries (non-active-IDE tombstones)", () => { await writeFile( configPath, JSON.stringify( - { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + { mcp: { [DATAMATE_KEY]: stamped(tmp.path) } }, null, 2, ), @@ -229,7 +244,7 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { await writeFile( globalConfigPath, JSON.stringify( - { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + { mcp: { [DATAMATE_KEY]: stamped(tmp.path) } }, null, 2, ), @@ -262,7 +277,7 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { await writeFile( projectConfigPath, JSON.stringify( - { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + { mcp: { [DATAMATE_KEY]: stamped(tmp.path) } }, null, 2, ), @@ -293,7 +308,7 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { await writeFile( globalConfigPath, JSON.stringify( - { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + { mcp: { [DATAMATE_KEY]: stamped(tmp.path) } }, null, 2, ), @@ -321,7 +336,7 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { await writeFile( globalJsoncPath, JSON.stringify( - { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + { mcp: { [DATAMATE_KEY]: stamped(tmp.path) } }, null, 2, ), @@ -345,7 +360,7 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { await using tmp = await tmpdir() const globalDir = path.join(tmp.path, "global-config") await mkdir(globalDir, { recursive: true }) - const brokenEntry = { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } + const brokenEntry = stamped(tmp.path) // Global legacy config.json IS merged by the config loader → must heal. const globalLegacyPath = path.join(globalDir, "config.json") await writeFile(globalLegacyPath, JSON.stringify({ mcp: { [DATAMATE_KEY]: brokenEntry } }, null, 2)) @@ -374,7 +389,7 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { await using tmp = await tmpdir() const globalDir = path.join(tmp.path, "global-config") await mkdir(globalDir, { recursive: true }) - const brokenEntry = { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } + const brokenEntry = stamped(tmp.path) const projectConfigPath = path.join(tmp.path, "altimate-code.json") const globalConfigPath = path.join(globalDir, "altimate-code.json") await writeFile(projectConfigPath, JSON.stringify({ mcp: { [DATAMATE_KEY]: brokenEntry } }, null, 2)) @@ -397,3 +412,172 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { } }) }) + +describe("review hardening: allowlist, validation, provenance, bounded root, nested configs", () => { + test("only ELECTRON_RUN_AS_NODE is carried from the IDE env (allowlist, not denylist)", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["cli.js", "start-stdio"], + env: { + ELECTRON_RUN_AS_NODE: "1", + NODE_OPTIONS: "--require /tmp/evil.js", + LD_PRELOAD: "/tmp/evil.so", + PATH: "/tmp/evil-bin", + ALTIMATE_EXTENSION_RPC: "/tmp/x.sock", + }, + }) + const t = await readDatamateTransportFromIde(tmp.path) + expect(t?.type).toBe("local") + if (t?.type === "local") expect(t.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + }) + + test("an incomplete IDE entry cannot win source selection nor be persisted as a url-less remote", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "isolated-global") + // .cursor sorts first and carries a non-empty but transport-less entry. + await mkdir(path.join(tmp.path, ".cursor"), { recursive: true }) + await writeFile( + path.join(tmp.path, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: { [DATAMATE_KEY]: { type: "stdio", updatedAt: "T-bogus" } } }), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T11", + }) + const configPath = path.join(tmp.path, "altimate-code.json") + await writeFile(configPath, JSON.stringify({ mcp: { [DATAMATE_KEY]: stamped(tmp.path) } }, null, 2)) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t?.source).toBe(path.join(tmp.path, ".vscode", "mcp.json")) + + await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + const entry = JSON.parse(await readFile(configPath, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.type).toBe("local") + expect(entry.updatedAt).toBe("T11") + expect("url" in entry).toBe(false) + }) + + test("a lone incomplete IDE entry writes nothing at all", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "isolated-global") + await seedIdeStdio(tmp.path, { type: "stdio", updatedAt: "T-bogus" }) + const configPath = path.join(tmp.path, "altimate-code.json") + const before = JSON.stringify({ mcp: { [DATAMATE_KEY]: stamped(tmp.path) } }, null, 2) + await writeFile(configPath, before) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toEqual([]) + expect(await readFile(configPath, "utf-8")).toBe(before) + }) + + test("a hand-added GLOBAL entry (no provenance) survives a project-local heal byte-identical", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + const globalPath = path.join(globalDir, "altimate-code.json") + const handAdded = JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "remote", url: "https://mcp.example.com/sse", headers: { Authorization: "Bearer x" } } } }, + null, + 2, + ) + await writeFile(globalPath, handAdded) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T12", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toEqual([]) + expect(await readFile(globalPath, "utf-8")).toBe(handAdded) + }) + + test("a GLOBAL entry managed from a DIFFERENT project's mcp.json is left alone", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + const globalPath = path.join(globalDir, "altimate-code.json") + const other = JSON.stringify( + { mcp: { [DATAMATE_KEY]: { ...stamped(tmp.path), sourceMcpJson: "/somewhere/else/.vscode/mcp.json" } } }, + null, + 2, + ) + await writeFile(globalPath, other) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T13", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toEqual([]) + expect(await readFile(globalPath, "utf-8")).toBe(other) + }) + + test("a nested package's own config (loaded by the config walk) is healed too", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "isolated-global") + await mkdir(path.join(tmp.path, ".git"), { recursive: true }) + const pkg = path.join(tmp.path, "packages", "app") + await mkdir(pkg, { recursive: true }) + const nestedConfig = path.join(pkg, "opencode.json") + await writeFile(nestedConfig, JSON.stringify({ mcp: { [DATAMATE_KEY]: stamped(tmp.path) } }, null, 2)) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T14", + }) + + // Launched from the nested package: the root mcp.json is the IDE source, + // and the nested config on the launch→root walk is a heal target. + const updated = await syncDatamateUrlFromVscodeMcp(pkg, globalDir) + expect(updated).toContain(DATAMATE_KEY) + const entry = JSON.parse(await readFile(nestedConfig, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + }) + + test("an mcp.json outside the extension-written locations is never a transport source", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, "docs", "examples"), { recursive: true }) + await writeFile( + path.join(tmp.path, "docs", "examples", "mcp.json"), + JSON.stringify({ servers: { [DATAMATE_KEY]: { command: "/evil", args: [], env: { ELECTRON_RUN_AS_NODE: "1" }, updatedAt: "T" } } }), + ) + expect(await readDatamateTransportFromIde(tmp.path)).toBeNull() + }) + + test("resolveDatamateSyncRoot: a home directory that is itself a git repo is not a project", async () => { + await using tmp = await tmpdir() + const prev = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + try { + await mkdir(path.join(tmp.path, ".git"), { recursive: true }) + const deep = path.join(tmp.path, "code", "no-git-here") + await mkdir(deep, { recursive: true }) + expect(await resolveDatamateSyncRoot(deep)).toBe(deep) + } finally { + if (prev === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = prev + } + }) + + test("resolveDatamateSyncRoot: a .git FILE (worktree/submodule) marks the nearest project root", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, ".git"), { recursive: true }) + const wt = path.join(tmp.path, "modules", "sub") + await mkdir(path.join(wt, "src"), { recursive: true }) + await writeFile(path.join(wt, ".git"), "gitdir: ../../.git/modules/sub\n") + expect(await resolveDatamateSyncRoot(path.join(wt, "src"))).toBe(wt) + }) +})