diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 8a5e967233..eb6786e591 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" @@ -20,8 +21,62 @@ const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const export type DatamateTransport = - | { type: "remote"; url: string } - | { type: "local"; command: string[] } + | { type: "remote"; url: string; updatedAt?: 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 +} + +/** + * 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 +} + +/** + * Entry fields re-derived from the IDE transport on every sync/refresh — as + * opposed to user-managed fields (enabled, timeout, oauth, …), which are + * carried forward from the existing entry. Shared with `datamate_manager add`'s + * refresh path so the two never disagree on what counts as transport identity. + */ +export const TRANSPORT_IDENTITY_FIELDS: ReadonlySet = new Set([ + "type", + "command", + "args", + "environment", + "url", + "updatedAt", +]) /** * Parse a single mcp.json file and return the servers map, trying each of the @@ -105,14 +160,28 @@ 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 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 @@ -136,9 +205,17 @@ 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 { + // 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. @@ -178,85 +255,97 @@ 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 - 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 TRANSPORT_FIELDS = new Set([ - "type", - "command", - "args", - "environment", - "url", - "updatedAt", - ]) - const preserved: Record = {} - for (const [k, v] of Object.entries(existingEntry)) { - if (!TRANSPORT_FIELDS.has(k)) preserved[k] = v - } + // 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 - let newEntry: Record - if ("command" in datamateVscode) { - const env = datamateVscode["env"] as Record | undefined - const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {} - const cmd = - typeof datamateVscode["command"] === "string" - ? (datamateVscode["command"] as string) - : DATAMATE_KEY - newEntry = { - ...preserved, - type: "local", - command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])], - ...(Object.keys(restEnv).length > 0 ? { environment: restEnv } : {}), - updatedAt: vscodeUpdatedAt, - } - } else { - // http / streamable-http / sse → remote - newEntry = { - ...preserved, - type: "remote", - url: datamateVscode["url"] as string, - updatedAt: vscodeUpdatedAt, - } - } + if (vscodeUpdatedAt === existingUpdatedAt) { + log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date", { + configPath, + updatedAt: vscodeUpdatedAt, + }) + return false + } - await addMcpToConfig( - DATAMATE_KEY, - newEntry as Parameters[1], - configPath, - ) - log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", { - type: datamateVscode["type"], - updatedAt: vscodeUpdatedAt, - }) - updated.push(DATAMATE_KEY) + // 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, + } + } + + 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)) { + // 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) } // ── All other remote MCP entries: existing URL-comparison logic ────────── diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 7e1bb6944d..95e1656257 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -8,11 +8,12 @@ import { listMcpInConfig, resolveConfigPath, findAllConfigPaths, + readMcpEntryFromDisk, } from "../../mcp/config" 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" }) @@ -206,18 +207,28 @@ 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" 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-"), @@ -249,21 +260,50 @@ 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) + // 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 (!replacedFields.has(k)) preserved[k] = v + } + const refreshed = { + ...preserved, + ...mcpConfig, + enabled: true, + ...updatedAtField, + } + await addMcpToConfig(DATAMATE_KEY, refreshed as Parameters[1], configPath) + // 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 + // Not in config yet — write to disk then connect. 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, + ...updatedAtField, + } + 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..5c263512d3 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -942,6 +942,17 @@ 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. 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 } = 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..736eacc8db 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,17 @@ const SHUTDOWN_BUDGET_MS = Telemetry.TUI_SHUTDOWN_BUDGET_MS Heap.start() +// 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() // 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 @@ -41,6 +59,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()) @@ -65,6 +88,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 +117,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/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index cccbc89f9d..195c4da69d 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -3,10 +3,19 @@ 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"] +// 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) { @@ -20,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)) { @@ -98,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/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-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 new file mode 100644 index 0000000000..21025ec951 --- /dev/null +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -0,0 +1,345 @@ +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, + resolveDatamateSyncRoot, + 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("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, { + 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("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() + 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, path.join(tmp.path, "isolated-global")) + 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 + }) + + 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("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("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") + 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") + } + }) +}) 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)") }) })