diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 8a5e967233..a93ba2668a 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, findProjectConfigPaths, findGlobalConfigPaths } 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,122 @@ 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; source: string } + | { type: "local"; command: string[]; environment?: Record; updatedAt?: string; source: string } + +/** + * 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 (!SPAWN_ENV_ALLOWLIST.has(key)) continue + if (typeof value === "string") env[key] = value + } + return Object.keys(env).length > 0 ? env : undefined +} + +/** + * 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. + */ +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 +} + +/** + * 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 { + // 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) { + const root = path.dirname(dotgit) + if (root !== home) return root + } + } 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", + "managedBy", + "sourceMcpJson", +]) /** * Parse a single mcp.json file and return the servers map, trying each of the @@ -45,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 @@ -88,40 +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 (!entry) continue - - log.info("readDatamateTransportFromIde: found entry", { - source: relPath, - type: entry["type"] ?? "(no type)", - }) - - if (typeof entry["url"] === "string") { - return { type: "remote", url: entry["url"] } - } - - // stdio entry — reuse the exact command + args the extension registered - 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] } - } - - // 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 } @@ -136,23 +228,30 @@ 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( + 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 { - log.info("syncDatamateUrlFromVscodeMcp: start", { cwd }) + // 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 }) - // Find the first mcp.json that contains a "datamate" entry. - const mcpJsonPaths = await findAllMcpJsonFiles(cwd) - let mcpJsonPath: string | undefined + // 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) - if (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 } @@ -161,102 +260,111 @@ 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) { - // 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 - } + const existingNode = existingTree ? findNodeAtLocation(existingTree, ["mcp", DATAMATE_KEY]) : undefined + if (!existingNode) return false - 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, - } - } + // 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) : {} - await addMcpToConfig( - DATAMATE_KEY, - newEntry as Parameters[1], + // 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, - ) - log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", { - type: datamateVscode["type"], - updatedAt: vscodeUpdatedAt, }) - updated.push(DATAMATE_KEY) + 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 }) + return false + } + + // 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 + } + 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: 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 { 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 files. + try { + if (await healEntryInFile(configPath, scope)) 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..1565227109 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, DATAMATE_PROVENANCE, readDatamateTransportFromIde, TRANSPORT_IDENTITY_FIELDS } from "../datamate-transport" const log = Log.create({ service: "datamate" }) @@ -206,18 +207,33 @@ 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 } : {} + // 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-"), @@ -249,21 +265,52 @@ 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, + ...provenanceFields, + } + 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, + ...provenanceFields, + } + 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..0a39f32738 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)) { @@ -95,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 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(dir, name) + 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/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-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 50d21b3e78..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") }) }) }) @@ -350,7 +351,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 +369,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 +387,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..31b50a27fb --- /dev/null +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -0,0 +1,583 @@ +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, + DATAMATE_PROVENANCE, +} 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. + +/** 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( + 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", + source: path.join(tmp.path, ".vscode", "mcp.json"), + }) + }) + + 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"], + source: path.join(tmp.path, ".vscode", "mcp.json"), + }) + }) + + 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", + source: path.join(tmp.path, ".vscode", "mcp.json"), + }) + }) + + 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"], source: path.join(tmp.path, ".vscode", "mcp.json"), }) + }) + + 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("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]: stamped(tmp.path) } }, + 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() + 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]: stamped(tmp.path) } }, + 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]: stamped(tmp.path) } }, + 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]: stamped(tmp.path) } }, + 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]: stamped(tmp.path) } }, + 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 = 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)) + // 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 = 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)) + 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") + } + }) +}) + +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) + }) +}) 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)") }) })