From c9ae8aaacffab7395a57021660a75b9d54e73f6c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 02:42:12 +0800 Subject: [PATCH 01/13] feat(workspace): attach the bound workspace's engine as a derived MCP overlay With the pilot flag on in a bound project, the `datamate` MCP entry is derived at config load as the workspace's pinned local engine: never written to disk, overriding IDE, hosted and stale entries. MCP bootstrap starts it; each turn boundary re-reads the binding, replaces it on re-link, retries a failed handshake once, announces once per verdict. Writers refuse the key. Terminal only. --- packages/opencode/src/altimate/api/client.ts | 39 +- .../opencode/src/altimate/tools/datamate.ts | 42 +- .../src/altimate/workspace/engine-overlay.ts | 416 ++++++++++++++++ .../src/altimate/workspace/engine-probes.ts | 131 +++++ .../src/altimate/workspace/engine-seams.ts | 64 +++ .../src/altimate/workspace/engine-types.ts | 225 +++++++++ packages/opencode/src/cli/cmd/run.ts | 5 + packages/opencode/src/cli/cmd/serve.ts | 6 + packages/opencode/src/config/config.ts | 11 + packages/opencode/src/mcp/index.ts | 5 + packages/opencode/src/server/routes/mcp.ts | 13 + packages/opencode/src/server/server.ts | 12 + packages/opencode/src/session/prompt.ts | 11 + .../altimate/workspace/engine-overlay.test.ts | 453 ++++++++++++++++++ .../altimate/workspace/engine-types.test.ts | 147 ++++++ packages/opencode/test/mcp/lifecycle.test.ts | 28 ++ 16 files changed, 1591 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/engine-overlay.ts create mode 100644 packages/opencode/src/altimate/workspace/engine-probes.ts create mode 100644 packages/opencode/src/altimate/workspace/engine-seams.ts create mode 100644 packages/opencode/src/altimate/workspace/engine-types.ts create mode 100644 packages/opencode/test/altimate/workspace/engine-overlay.test.ts create mode 100644 packages/opencode/test/altimate/workspace/engine-types.test.ts diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 85531e36b3..9d4caaae54 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -37,6 +37,10 @@ const DatamateSummary = z.object({ const IntegrationSummary = z.object({ id: z.coerce.string(), name: z.string().optional(), + // altimate_change start — catalog `type` (tool | mcp | code | api | extension); + // extension-type integrations have no meaning on the CLI surface. + type: z.string().optional(), + // altimate_change end description: z.string().nullable().optional(), tools: z .array( @@ -227,19 +231,30 @@ export namespace AltimateApi { async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) { const url = `${creds.altimateUrl}${endpoint}` - const res = await fetch(url, { - method, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${creds.altimateApiKey}`, - "x-tenant": creds.altimateInstanceName, - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }) - if (!res.ok) { - throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`) + // altimate_change start — upstream_fix: bound every API request. Without a + // signal a stalled server holds the caller indefinitely. The abort stays + // armed until the BODY is read: `fetch` resolves on headers. + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15_000) + try { + const res = await fetch(url, { + signal: controller.signal, + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${creds.altimateApiKey}`, + "x-tenant": creds.altimateInstanceName, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + if (!res.ok) { + throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`) + } + return await res.json() + } finally { + clearTimeout(timeout) } - return res.json() + // altimate_change end } export async function listDatamates() { diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 7e1bb6944d..945a3c7aea 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -13,6 +13,8 @@ import { Instance } from "../../project/instance" import { Global } from "../../global" import { Log } from "@/altimate/util/log" import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" +// altimate_change - workspace mode owns the datamate key +import { managedWorkspace } from "../workspace/engine-overlay" const log = Log.create({ service: "datamate" }) @@ -138,22 +140,35 @@ async function handleList() { async function handleListIntegrations() { try { - const integrations = await AltimateApi.listIntegrations() + const catalog = await AltimateApi.listIntegrations() + // altimate_change start — extension-type integrations are RPC into a live VS + // Code host and cannot work from the CLI. Hide them from this surface (the + // workspace UI still offers them) and say how many were hidden. + const integrations = catalog.filter((i) => i.type !== "extension") + const hidden = catalog.length - integrations.length + const omitted = + hidden > 0 + ? `${hidden} extension-type integration${hidden === 1 ? " was" : "s were"} omitted — they require a live VS Code bridge and are not available from the CLI.` + : "" if (integrations.length === 0) { return { - title: "Integrations: none found", - metadata: { count: 0 }, - output: "No integrations available.", + title: hidden > 0 ? `Integrations: none available on the CLI (${hidden} hidden)` : "Integrations: none found", + metadata: { count: 0, hidden }, + output: omitted ? `No integrations available. ${omitted}` : "No integrations available.", } } + // altimate_change end const lines = ["ID | Name | Tools", "---|------|------"] for (const i of integrations) { const tools = i.tools?.map((t) => t.key).join(", ") ?? "none" lines.push(`${i.id} | ${i.name} | ${tools}`) } + // altimate_change start + if (omitted) lines.push("", `(${omitted})`) + // altimate_change end return { title: `Integrations: ${integrations.length} available`, - metadata: { count: integrations.length }, + metadata: { count: integrations.length, hidden }, output: lines.join("\n"), } } catch (e) { @@ -201,6 +216,23 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p ? DATAMATE_KEY : (args.name ?? `datamate-${slugify(datamate.name)}`) + // altimate_change start — in workspace mode the shared `datamate` key is the + // bound workspace's own engine, derived at config load. Adding a datamate + // under that key would replace it; refuse and say why. Standalone + // `datamate-` entries are a different key and stay the user's. + const managed = serverName === DATAMATE_KEY ? managedWorkspace() : null + if (managed) { + return { + title: `Datamate add: '${DATAMATE_KEY}' is managed by workspace "${managed.name}"`, + metadata: { serverName: DATAMATE_KEY, managedBy: managed.id, datamateId: args.datamate_id }, + output: + `This project is linked to workspace "${managed.name}", whose integrations are served by the ` + + `workspace's own engine under the '${DATAMATE_KEY}' MCP server. Adding datamate '${datamate.name}' ` + + `there is not applied. Unlink the project, or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`, + } + } + // altimate_change end + const creds = transport ? undefined : await AltimateApi.getCredentials() const mcpConfig = transport?.type === "remote" diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts new file mode 100644 index 0000000000..967202c93a --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -0,0 +1,416 @@ +// altimate_change - new file +// +// The workspace engine overlay. +// +// A project bound to a workspace gets that workspace's integration tools from +// the local engine (`datamate start-stdio --datamate `), served under the +// `datamate` MCP key. This module derives that entry at config-load time and +// never writes it anywhere: +// +// config load → overlay(): bound + engine on PATH clearing the floor +// → `mcp.datamate` is the pinned local spawn, whatever any +// file, IDE or discovery pass put there; otherwise the key +// is removed so nothing else answers for the workspace. +// MCP bootstrap starts it like any configured stdio server and awaits it +// before the first tool list — first-turn readiness for free. +// turn boundary → beforeTurn(): re-read the binding; on a re-link reload +// config and replace the engine; on a failed handshake +// retry once per process; settle this session's outcome; +// tell the user once per verdict. +// +// What this deliberately is not: a reconciler over other writers of the key. +// In workspace mode the key is owned here — the in-process writers refuse it +// (see `managedWorkspace`), and anything another process changes is observed +// at the next turn boundary. The tools a turn holds are the ones resolved at +// that turn's start. +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { MCP } from "@/mcp" +import { Config } from "@/config/config" +import { currentDirectory, isEnabled, isHeadless, isServe, log, syncInternals } from "./engine-seams" +import { declaredBounded, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" +import { + ENGINE_BINARY, + INSTALL_COMMAND, + REPAIRABLE, + clearsFloor, + describeMissing, + describeRefusal, + engineEntry, + engineToolKeys, + type Declared, + type LocalMcpConfig, + type McpStatus, + type Outcome, + type Toast, +} from "./engine-types" + +export * from "./engine-types" +export { isEnabled, isHeadless, isServe, syncInternals } from "./engine-seams" + +/** Sessions remembered per process. It is a memo; an evicted session just re-settles. */ +export const MAX_TRACKED_SESSIONS = 256 +/** A failed probe is repeated at most this often, so a missing engine does not + * cost a process spawn on every turn while still being noticed once installed. */ +export const FAILED_PROBE_TTL_MS = 30_000 +/** A failed allowlist lookup is retried at most this often. */ +const DECLARED_RETRY_MS = 60_000 + +// ── the engine on PATH ────────────────────────────────────────────────────── + +type Probe = { kind: "ok"; version: string } | { kind: "missing" } | { kind: "too-old"; found: string | null } + +let probeMemo: { result: Probe; at: number } | null = null + +function now(): number { + return syncInternals.now ? syncInternals.now() : Date.now() +} + +async function probeEngine(): Promise { + const at = now() + if (probeMemo && (probeMemo.result.kind === "ok" || at - probeMemo.at < FAILED_PROBE_TTL_MS)) { + return probeMemo.result + } + const bin = which(ENGINE_BINARY) + let result: Probe + if (!bin) { + result = { kind: "missing" } + } else { + const version = await versionOf(bin) + result = clearsFloor(version) ? { kind: "ok", version: version! } : { kind: "too-old", found: version } + } + probeMemo = { result, at } + return result +} + +/** Forget the last probe, so the next turn boundary looks for the engine + * again immediately. The install offer calls this after an install. */ +export function invalidateProbe(): void { + probeMemo = null +} + +// ── the overlay ───────────────────────────────────────────────────────────── + +type Overlay = { + directory: string + workspace: { id: string; name: string } + /** The derived entry, or null when the engine is unusable. */ + entry: LocalMcpConfig | null + refusal: Extract | null +} + +let current: Overlay | null = null +/** What MCP is believed to be running under the key: the overlay as it stood + * when MCP bootstrapped (config load precedes MCP init, which reads the cached + * config), then whatever the turn hook last applied. `undefined` until the + * first turn boundary. Kept apart from `current` because any consumer can + * invalidate and reload config between turns, re-running the overlay without + * touching MCP. */ +let applied: Overlay | null | undefined = undefined + +function sameEntry(a: LocalMcpConfig | null, b: LocalMcpConfig | null): boolean { + return !!a && !!b && a.command.join("\0") === b.command.join("\0") +} + +/** Derive the `datamate` entry for a bound directory into `config.mcp`. + * + * Called from the config loader after external MCP discovery, so it has the + * last word over every other source of the key. Mutates `config.mcp` only when + * the directory is bound with the pilot on. Never throws. */ +export async function overlay(directory: string, config: { mcp?: Record }): Promise { + try { + if (!isEnabled() || isServe()) { + current = null + return + } + const binding = await resolveBinding(directory) + if (!binding) { + current = null + return + } + const workspace = { id: String(binding.datamateId), name: binding.datamateName } + const probe = await probeEngine() + if (probe.kind === "ok") { + const entry = engineEntry(workspace.id) + config.mcp ??= {} + config.mcp[DATAMATE_KEY] = entry + current = { directory, workspace, entry, refusal: null } + log.info("workspace engine overlay applied", { workspaceId: workspace.id, version: probe.version }) + return + } + // No hosted fallback in workspace mode: the hosted endpoint serves a + // different tool set, and an IDE's unpinned engine serves whichever + // teammate is active there. Either would answer for the workspace with + // tools it did not declare. + if (config.mcp && DATAMATE_KEY in config.mcp) delete config.mcp[DATAMATE_KEY] + current = { + directory, + workspace, + entry: null, + refusal: probe.kind === "missing" ? { kind: "engine-missing" } : { kind: "engine-too-old", found: probe.found }, + } + log.info("workspace engine overlay refused", { workspaceId: workspace.id, reason: probe.kind }) + } catch (err) { + log.warn("workspace engine overlay failed; leaving the MCP config as loaded", { err: String(err) }) + current = null + } +} + +/** The workspace that owns the `datamate` key in this process, or null. + * + * Synchronous, for the in-process writers of that key (the reload endpoint, + * the HTTP add route, `datamate_manager add`): in workspace mode they refuse + * the key and say why, instead of replacing the engine underneath a turn. */ +export function managedWorkspace(): { id: string; name: string } | null { + return current?.workspace ?? null +} + +// ── per-session outcome ───────────────────────────────────────────────────── + +type SessionRecord = { outcome: Outcome; announced?: string } +const sessions = new Map() +/** Workspaces whose failed handshake was already retried in this process. */ +const retried = new Set() +const declaredCache = new Map() + +function record(sessionID: string, outcome: Outcome): SessionRecord { + const previous = sessions.get(sessionID) + sessions.delete(sessionID) + const next: SessionRecord = { outcome, announced: previous?.announced } + sessions.set(sessionID, next) + while (sessions.size > MAX_TRACKED_SESSIONS) { + const oldest = sessions.keys().next().value + if (oldest === undefined) break + sessions.delete(oldest) + } + return next +} + +/** The outcome a session settled at its last turn boundary. A pure read; + * `undefined` before the first `beforeTurn` for that session. */ +export function settledOutcome(sessionID: string): Outcome | undefined { + return sessions.get(sessionID)?.outcome +} + +function mcp() { + return ( + syncInternals.mcp ?? { + status: () => MCP.status() as Promise, + add: (name: string, cfg: LocalMcpConfig) => MCP.add(name, cfg), + remove: (name: string) => MCP.remove(name), + tools: () => MCP.tools() as Promise>, + } + ) +} + +function config() { + return ( + syncInternals.config ?? { + invalidate: () => Config.invalidate(), + get: async () => { + await Config.get() + }, + } + ) +} + +async function declaredFor(workspaceId: string): Promise { + const cached = declaredCache.get(workspaceId) + if (cached && (cached.value || now() - cached.at < DECLARED_RETRY_MS)) return cached.value + const value = await declaredBounded(workspaceId) + declaredCache.set(workspaceId, { value, at: now() }) + return value +} + +/** Reconcile, settle and announce for one session. Runs at the start of every + * user turn, before the tool list is resolved. Never throws. */ +export async function beforeTurn(sessionID: string): Promise { + try { + await reconcile(sessionID) + } catch (err) { + log.warn("workspace engine turn hook failed", { sessionID, err: String(err) }) + } +} + +async function reconcile(sessionID: string): Promise { + if (!isEnabled() || isServe()) { + record(sessionID, { kind: "disabled" }) + return + } + const directory = currentDirectory() + if (!directory) { + record(sessionID, { kind: "unbound" }) + return + } + // The overlay runs inside config load; make sure it has run at least once. + await config().get() + // First turn boundary: MCP bootstrapped from the config as loaded, i.e. from + // the overlay as it stands now. + if (applied === undefined) applied = current + + const binding = await resolveBinding(directory) + if (!binding) { + // Unlinked (or never linked): the key is not ours to fill. + if (current || applied) { + await config().invalidate() + await config().get() + } + if (applied?.entry) await mcp().remove(DATAMATE_KEY) + applied = null + record(sessionID, { kind: "unbound" }) + return + } + const workspaceId = String(binding.datamateId) + + // Reload the overlay when the binding moved, or when a refused engine may + // have appeared since (the probe memo bounds how often that is asked). + let reload = !current || current.workspace.id !== workspaceId + if (!reload && current && !current.entry) { + const probe = await probeEngine() + reload = probe.kind === "ok" + } + if (reload) { + await config().invalidate() + await config().get() + } + + const overlayNow = current + if (!overlayNow) { + if (applied?.entry) await mcp().remove(DATAMATE_KEY) + applied = null + record(sessionID, { kind: "unbound" }) + return + } + const workspace = overlayNow.workspace + + // Bring MCP in line with the overlay: start or replace the engine when the + // derived entry changed, drop it when there is none any more. + if (overlayNow.entry) { + if (!sameEntry(applied?.entry ?? null, overlayNow.entry)) await mcp().add(DATAMATE_KEY, overlayNow.entry) + } else if (applied?.entry) { + await mcp().remove(DATAMATE_KEY) + } + applied = overlayNow + + if (!overlayNow.entry) { + const refusal = overlayNow.refusal ?? { kind: "engine-missing" as const } + if (refusal.kind === "engine-missing") { + const declared = await declaredFor(workspace.id) + const count = declared?.keys.length + const outcome: Outcome = + count === undefined ? { kind: "engine-missing" } : { kind: "engine-missing", declared: count } + record(sessionID, outcome) + const what = + count === undefined + ? `Workspace "${workspace.name}" has integration tools that run on the local engine, which is not installed.` + : `Workspace "${workspace.name}" declares ${count} integration tool${count === 1 ? "" : "s"}. They run on the local engine, which is not installed.` + await announceRefusal(sessionID, outcome, { + title: `Workspace "${workspace.name}" needs the local engine`, + message: `${what} Install it with: ${INSTALL_COMMAND}`, + variant: "warning", + }) + return + } + record(sessionID, refusal) + await announceRefusal(sessionID, refusal, { + title: `Workspace "${workspace.name}": engine not usable`, + message: describeRefusal(refusal.found, workspace.name), + variant: "warning", + }) + return + } + + // The engine is configured. The first status call boots MCP, which awaits + // the engine's handshake; the allowlist lookup overlaps with it. + const [statusMap, declared] = await Promise.all([mcp().status(), declaredFor(workspace.id)]) + let status = statusMap[DATAMATE_KEY] + if (status?.status !== "connected" && !retried.has(workspace.id)) { + retried.add(workspace.id) + log.info("workspace engine not connected; retrying once", { workspaceId: workspace.id, status: status?.status }) + await mcp().add(DATAMATE_KEY, overlayNow.entry) + status = (await mcp().status())[DATAMATE_KEY] + } + if (status?.status !== "connected") { + const outcome: Outcome = { + kind: "connect-failed", + error: status?.error ?? `engine status: ${status?.status ?? "unknown"}`, + } + record(sessionID, outcome) + await announceRefusal(sessionID, outcome, { + title: `Workspace "${workspace.name}": engine failed to start`, + message: `${outcome.error}. Start a new session to try again.`, + variant: "error", + }) + return + } + + const present = engineToolKeys(await mcp().tools()) + const missing = declared ? declared.keys.filter((k) => !present.has(k)) : undefined + const outcome: Outcome = { + kind: "attached", + available: present.size, + ...(declared ? { declared: declared.keys.length, missing } : {}), + } + const rec = record(sessionID, outcome) + // Keyed on the workspace too: a re-link with an identical inventory is still + // a new verdict the user should hear. + const signature = `attached:${workspace.id}:${outcome.available}:${outcome.declared ?? "?"}:${(missing ?? []).join(",")}` + if (rec.announced === signature) return + rec.announced = signature + log.info("workspace engine attached", { + workspaceId: workspace.id, + available: outcome.available, + declared: outcome.declared, + missing, + }) + if (isHeadless()) return + await notify({ + title: `Workspace "${workspace.name}"`, + message: declared + ? `${outcome.available} of ${declared.keys.length} declared integration tools available.${describeMissing(missing ?? [])}` + : `${outcome.available} integration tools available.`, + variant: missing && missing.length > 0 ? "warning" : "info", + }) +} + +/** Tell the session about a refusal, once per unchanged verdict. + * + * The substitution point for the install offer: when `installWouldHelp(outcome)` + * a dialog replaces the toast here; it never adds a second message. Headless + * `run` prints one stderr line instead. */ +export async function announceRefusal(sessionID: string, outcome: Outcome, toast: Toast): Promise { + const rec = sessions.get(sessionID) ?? record(sessionID, outcome) + const detail = "error" in outcome ? outcome.error : "found" in outcome ? String(outcome.found) : "" + const signature = `${outcome.kind}:${detail}:${toast.title}` + if (rec.announced === signature) return + rec.announced = signature + if (isHeadless()) { + printLine(`${toast.title}: ${toast.message}`) + return + } + await notify(toast) +} + +/** Is a re-probe worth asking for on the next turn? Exposed for the install + * offer, which schedules nothing itself: it installs, invalidates the probe, + * and the next turn boundary attaches. */ +export function isRepairable(outcome: Outcome | undefined): boolean { + return !!outcome && REPAIRABLE[outcome.kind] +} + +/** Test-only: forget everything this process learned. */ +export function resetForTests(): void { + current = null + applied = undefined + probeMemo = null + sessions.clear() + retried.clear() + declaredCache.clear() +} + +/** Test-only views. */ +export function overlayForTests(): Overlay | null { + return current +} +export function trackedSessionsForTests(): number { + return sessions.size +} diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts new file mode 100644 index 0000000000..d3c5ab7651 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -0,0 +1,131 @@ +// altimate_change - new file +// +// Everything that asks the outside world a question: the binary, its +// version, the workspace allowlist, and the user-facing surfaces. +import launch from "cross-spawn" +import { which as whichBinary } from "@opencode-ai/core/util/which" +import { AltimateApi } from "@/altimate/api/client" +import { AppRuntime } from "@/effect/app-runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { TuiEvent } from "@/server/tui-event" +import { readLocalBinding, type CachedBinding } from "./state" +import { log, syncInternals } from "./engine-seams" +import type { Declared, Toast } from "./engine-types" + +/** How long the allowlist lookup may hold a turn. Once per workspace per process. */ +export const DECLARED_TIMEOUT_MS = 4_000 + +export async function resolveBinding(directory: string): Promise { + if (syncInternals.resolveBinding) return syncInternals.resolveBinding(directory) + try { + return await readLocalBinding(directory) + } catch (err) { + log.warn("could not resolve the workspace binding", { err: String(err) }) + return null + } +} + +export function which(cmd: string): string | null { + return syncInternals.which ? syncInternals.which(cmd) : whichBinary(cmd) +} + +/** `datamate --version`, stdout only. The engine prints its real package + * version here; its MCP `serverInfo` was a hard-coded placeholder on the very + * engines the floor excludes, so the handshake cannot be asked instead. + * + * cross-spawn, not execFile: an npm-installed engine on Windows resolves to a + * `.cmd` shim that Node cannot execute without a shell. */ +export function versionOf(bin: string): Promise { + if (syncInternals.versionOf) return syncInternals.versionOf(bin) + return new Promise((resolve) => { + let settled = false + const done = (value: string | null) => { + if (settled) return + settled = true + resolve(value) + } + try { + const child = launch(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }) + let out = "" + child.stdout?.on("data", (chunk) => { + out += String(chunk) + }) + child.on("error", () => done(null)) + child.on("close", (code) => { + if (code !== 0) return done(null) + const line = out.trim().split(/\r?\n/)[0] ?? "" + done(line || null) + }) + } catch { + done(null) + } + }) +} + +/** The workspace allowlist, split by whether the CLI can serve it. */ +export async function declared(workspaceId: string): Promise { + if (syncInternals.declared) return syncInternals.declared(workspaceId) + try { + if (!(await AltimateApi.isConfigured())) return null + const [workspace, catalog] = await Promise.all([ + AltimateApi.getDatamate(workspaceId), + AltimateApi.listIntegrations(), + ]) + const extensionIds = new Set(catalog.filter((i) => i.type === "extension").map((i) => i.id)) + const keys: string[] = [] + const extensionKeys: string[] = [] + for (const integration of workspace.integrations ?? []) { + const target = extensionIds.has(integration.id) ? extensionKeys : keys + for (const tool of integration.tools ?? []) target.push(tool.key) + } + return { keys, extensionKeys } + } catch (err) { + log.warn("could not read the declared workspace integrations", { workspaceId, err: String(err) }) + return null + } +} + +/** The allowlist, bounded. Reporting only; the losing timer is cancelled so a + * lookup that succeeded in time is not later reported as timed out. */ +export async function declaredBounded(workspaceId: string): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + declared(workspaceId), + new Promise((resolve) => { + timer = setTimeout(() => { + log.warn("workspace allowlist lookup timed out; continuing without the declared-vs-delivered report", { + workspaceId, + timeoutMs: DECLARED_TIMEOUT_MS, + }) + resolve(null) + }, DECLARED_TIMEOUT_MS) + timer.unref?.() + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +export async function notify(toast: Toast): Promise { + if (syncInternals.notify) return syncInternals.notify(toast) + try { + await AppRuntime.runPromise( + EventV2Bridge.Service.use((events) => events.publish(TuiEvent.ToastShow, { ...toast, duration: 10000 })), + ) + } catch (err) { + log.warn("could not show the workspace engine toast", { err: String(err) }) + } +} + +/** stderr, deliberately: `run --format json` documents stdout as raw JSON + * events, and this is a status notice, not run output. */ +export function printLine(line: string): void { + if (syncInternals.printLine) return syncInternals.printLine(line) + try { + process.stderr.write(line + "\n") + } catch { + // A closed stream must not take down the turn. + } +} diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts new file mode 100644 index 0000000000..b481c1a8e6 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -0,0 +1,64 @@ +// altimate_change - new file +// +// Ambient access and the single test seam. `syncInternals` stays ONE flat +// object on purpose: it is the override surface every consumer reaches for. +import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" +import { Instance } from "@/project/instance" +import { Log } from "@/altimate/util/log" +import type { CachedBinding } from "./state" +import type { Declared, LocalMcpConfig, McpStatus, Toast } from "./engine-types" + +export const log = Log.create({ service: "workspace-engine" }) + +/** Test seams. Production leaves every field unset. */ +export const syncInternals: { + resolveBinding?: (directory: string) => Promise + which?: (cmd: string) => string | null + versionOf?: (bin: string) => Promise + declared?: (workspaceId: string) => Promise + notify?: (toast: Toast) => Promise + printLine?: (line: string) => void + instanceDirectory?: () => string | null + headless?: () => boolean + serve?: () => boolean + now?: () => number + mcp?: { + status: () => Promise + add: (name: string, cfg: LocalMcpConfig) => Promise + remove: (name: string) => Promise + tools: () => Promise> + } + config?: { + invalidate: () => Promise + /** Loads config, which runs the overlay as a side effect. */ + get: () => Promise + } +} = {} + +export function isEnabled(): boolean { + return CoreFlag.ALTIMATE_WORKSPACE +} + +/** Headless `run`: no TUI can render a toast, so refusals print one stderr + * line. An env var because it must be readable from every module realm. */ +export function isHeadless(): boolean { + if (syncInternals.headless) return syncInternals.headless() + return process.env["ALTIMATE_CODE_HEADLESS"] === "1" +} + +/** `altimate serve` — the extension's host process. Workspace mode is + * terminal-only: the extension runs its own engine and bridge under the same + * key, and overriding it there would remove its extension-type tools. */ +export function isServe(): boolean { + if (syncInternals.serve) return syncInternals.serve() + return process.env["ALTIMATE_CODE_SERVE"] === "1" +} + +export function currentDirectory(): string | null { + if (syncInternals.instanceDirectory) return syncInternals.instanceDirectory() + try { + return Instance.directory + } catch { + return null + } +} diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts new file mode 100644 index 0000000000..57849013fb --- /dev/null +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -0,0 +1,225 @@ +// altimate_change - new file +// +// Vocabulary for the workspace engine overlay: the outcome union, the +// derived MCP entry, and the pure predicates over them. Nothing here performs +// I/O or reads ambient state. +import { DATAMATE_KEY } from "@/altimate/datamate-transport" + +/** Oldest engine this client works against. + * + * 0.7.0 is the first engine that LOCKS the `--datamate` pin, so a settings + * change in the IDE cannot swap the workspace out from under a running engine. + * Everything below it can drift. */ +export const MIN_ENGINE_VERSION = "0.7.0" +export const ENGINE_PACKAGE = "@altimateai/datamate" +export const ENGINE_BINARY = "datamate" +export const INSTALL_COMMAND = `npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}` + +/** Engine tools arrive under the MCP server key as `_`. */ +export const TOOL_PREFIX = `${DATAMATE_KEY}_` + +/** What a session knows about its workspace engine, settled at the turn + * boundary. `undefined` (never settled) is distinct from every kind here. */ +export type Outcome = + | { kind: "disabled" } + | { kind: "unbound" } + | { kind: "attached"; available: number; declared?: number; missing?: string[] } + | { kind: "engine-missing"; declared?: number } + /** `found` is null when the binary ran but printed nothing usable — broken + * rather than old; the message says so. */ + | { kind: "engine-too-old"; found: string | null } + | { kind: "connect-failed"; error: string } + +/** The MCP entry this module derives. Never written to disk. */ +export type LocalMcpConfig = { + type: "local" + command: string[] + enabled: true +} + +export type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } + +export type McpStatus = Record + +/** Declared allowlist for a workspace, split by whether the CLI can serve it. + * Extension-type integrations are RPC into a live VS Code host and have no + * meaning on the CLI surface, so they are excluded from the reported gap. */ +export type Declared = { keys: string[]; extensionKeys: string[] } + +/** A configured MCP entry in either shape it can reach us: opencode's own + * `command: string[]` argv, or the `{ command, args }` split an IDE writes. */ +export type EntryLike = { + type?: string + url?: string + command?: string[] | string + args?: string[] +} + +export const PIN_FLAG = "--datamate" + +export function engineEntry(workspaceId: string): LocalMcpConfig { + return { type: "local", command: [ENGINE_BINARY, "start-stdio", PIN_FLAG, workspaceId], enabled: true } +} + +/** SemVer precedence compare. Returns <0, 0, >0. + * + * Build metadata is ignored. A NON-numeric or non-three-part core compares as + * older than any readable one, so unreadable `--version` output can never + * clear the floor (`parseInt` would read "7rc" as 7). Pre-releases rank below + * their release (SemVer §11.3): the floor names behaviour that shipped in a + * specific release, and a pre-release of it predates that. */ +export function compareVersions(a: string, b: string): number { + const parseCore = (raw: string): number[] | null => { + const parts = raw.split(".") + if (parts.length !== 3) return null + if (!parts.every((part) => /^\d+$/.test(part))) return null + return parts.map((part) => Number(part)) + } + const split = (v: string) => { + const bare = v.trim().replace(/^v/, "") + const plus = bare.indexOf("+") + const noBuild = plus >= 0 ? bare.slice(0, plus) : bare + const dash = noBuild.indexOf("-") + return { + core: parseCore(dash >= 0 ? noBuild.slice(0, dash) : noBuild), + pre: dash >= 0 ? noBuild.slice(dash + 1) : "", + } + } + const pa = split(a) + const pb = split(b) + if (!pa.core || !pb.core) return !pa.core && !pb.core ? 0 : pa.core ? 1 : -1 + for (let i = 0; i < 3; i++) { + if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i] + } + if (!pa.pre && !pb.pre) return 0 + if (!pa.pre) return 1 + if (!pb.pre) return -1 + const ia = pa.pre.split(".") + const ib = pb.pre.split(".") + for (let i = 0; i < Math.max(ia.length, ib.length); i++) { + const x = ia[i] + const y = ib[i] + if (x === undefined) return -1 + if (y === undefined) return 1 + const nx = /^\d+$/.test(x) + const ny = /^\d+$/.test(y) + if (nx && ny) { + const d = Number(x) - Number(y) + if (d !== 0) return d + } else if (nx !== ny) { + return nx ? -1 : 1 + } else if (x !== y) { + return x < y ? -1 : 1 + } + } + return 0 +} + +/** Is this engine version usable? An unreadable version is below the floor: + * an engine that cannot say what it is cannot be shown to lock its pin. */ +export function clearsFloor(version: string | null): boolean { + return !!version && compareVersions(version, MIN_ENGINE_VERSION) >= 0 +} + +/** Strip the server prefix from the engine tools present in the catalog. */ +export function engineToolKeys(tools: Record): Set { + const out = new Set() + for (const key of Object.keys(tools)) { + if (key.startsWith(TOOL_PREFIX)) out.add(key.slice(TOOL_PREFIX.length)) + } + return out +} + +/** The entry's full argv, flattening both config shapes. */ +export function commandArgv(entry: EntryLike | null): string[] { + if (!entry) return [] + const head = typeof entry.command === "string" ? [entry.command] : (entry.command ?? []) + return [...head, ...(entry.args ?? [])] +} + +/** Which workspace does this entry pin its engine to, if any? + * + * `--datamate ` is the whole of an engine's workspace identity. An entry + * without it serves whichever teammate its owner has active, which changes at + * runtime from a UI this client does not control — the extension writes + * exactly such an entry. Scanned from the end (repeated flag → last wins); + * both `--datamate 5` and `--datamate=5` are valid. Fails open on a miss. */ +export function pinnedWorkspace(entry: EntryLike | null): string | null { + const argv = commandArgv(entry) + for (let i = argv.length - 1; i >= 0; i--) { + const arg = argv[i] + if (typeof arg !== "string") continue + if (arg === PIN_FLAG) return typeof argv[i + 1] === "string" ? argv[i + 1] : null + if (arg.startsWith(`${PIN_FLAG}=`)) return arg.slice(PIN_FLAG.length + 1) || null + } + return null +} + +/** Why an engine was refused, in the user's terms. "Too old" and "could not be + * run" are one code path and very different problems; conflating them sent + * more than one debugging session hunting a version mismatch that did not + * exist. */ +export function describeRefusal(found: string | null, workspaceName: string): string { + if (!found) { + return ( + `The ${ENGINE_BINARY} on PATH did not report a usable version, so it cannot serve workspace ` + + `"${workspaceName}". It is more likely broken than out of date — try \`${ENGINE_BINARY} --version\` ` + + `directly. Reinstall with: ${INSTALL_COMMAND}` + ) + } + return ( + `Found ${ENGINE_BINARY} ${found}; workspace "${workspaceName}" needs ${MIN_ENGINE_VERSION} or newer. ` + + `Update with: ${INSTALL_COMMAND}` + ) +} + +export function describeMissing(missing: string[]): string { + if (missing.length === 0) return "" + const shown = missing.slice(0, 5).join(", ") + const more = missing.length > 5 ? ` (+${missing.length - 5} more)` : "" + return ` Declared but not available: ${shown}${more}.` +} + +/** What each outcome MEANS, as tables over the whole union: a new variant + * fails to compile until every table names it, and the safe answer is false. */ +export const SERVING: Record = { + attached: true, + disabled: false, + unbound: false, + "engine-missing": false, + "engine-too-old": false, + "connect-failed": false, +} + +/** Would installing the engine fix this outcome? Only genuine unobtainability + * qualifies; a failed connection is not an absence. */ +export const INSTALL_HELPS: Record = { + "engine-missing": true, + "engine-too-old": true, + attached: false, + disabled: false, + unbound: false, + "connect-failed": false, +} + +/** Outcomes a later turn may re-probe for, because the world can have changed + * in a way the user was told how to fix. */ +export const REPAIRABLE: Record = { + "engine-missing": true, + "engine-too-old": true, + "connect-failed": true, + attached: false, + disabled: false, + unbound: false, +} + +/** Is the engine serving this session attributable to its bound workspace? + * `undefined` means not settled and must stay distinguishable from a refusal: + * the precedence consumer fails open on it. */ +export function attributableEngine(outcome: Outcome | undefined): boolean { + return !!outcome && SERVING[outcome.kind] +} + +export function installWouldHelp(outcome: Outcome | undefined): boolean { + return !!outcome && INSTALL_HELPS[outcome.kind] +} diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 50638bcd14..3f6bf66bd7 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -375,6 +375,11 @@ export const RunCommand = cmd({ // altimate_change end }, handler: async (args) => { + // altimate_change start — mark the headless surface. Nothing here can render a + // toast, so workspace engine refusals degrade to one stderr line. An env var + // because it must be readable from every module realm. + process.env["ALTIMATE_CODE_HEADLESS"] = "1" + // altimate_change end // altimate_change start — `run` is the only entrypoint without an answer // channel for the question tool: no TUI is mounted and the in-process // Server.Default() shim below does not bind a port, so a connected IDE diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 52ac4646c1..b817cded2e 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -19,6 +19,12 @@ export const ServeCommand = effectCmd({ // need for an ambient project InstanceContext at startup. instance: false, handler: Effect.fn("Cli.serve")(function* (args) { + // altimate_change start — mark the extension-host surface. Workspace mode is + // terminal-only: under `serve` the extension runs its own engine and bridge + // under the `datamate` key, and the overlay must leave it alone. An env var + // because it must be readable from every module realm. + process.env["ALTIMATE_CODE_SERVE"] = "1" + // altimate_change end const { Server } = yield* Effect.promise(() => import("../../server/server")) if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28b5cb0ade..8355463e4b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -740,6 +740,17 @@ export const layer = Layer.effect( } // altimate_change end + // altimate_change start — workspace engine overlay. When the pilot is on and + // this directory is bound to a workspace, the `datamate` MCP entry is the + // workspace's pinned local engine — derived here, after discovery, so it has + // the last word over IDE-written, hosted and stale entries, and never written + // to any file. See altimate/workspace/engine-overlay.ts. + if (Flag.ALTIMATE_WORKSPACE) { + const { overlay } = yield* Effect.promise(() => import("../altimate/workspace/engine-overlay")) + yield* Effect.promise(() => overlay(ctx.directory, result as { mcp?: Record })) + } + // altimate_change end + return { config: result, directories, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 27f2e85a14..27720e0123 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -951,6 +951,11 @@ export const layer = Layer.effect( yield* closeClient(s, name) delete s.clients[name] delete s.status[name] + // "Removed" means the runtime forgets it. `s.config` is what `getMcpConfig` + // prefers over the file, so a retained entry has `status()` synthesising + // "disabled" for the rest of the process and `connect` re-spawning the + // removed configuration instead of what the file now says. + delete s.config[name] yield* events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore) }) // altimate_change end diff --git a/packages/opencode/src/server/routes/mcp.ts b/packages/opencode/src/server/routes/mcp.ts index 303e68086f..bf31584ee5 100644 --- a/packages/opencode/src/server/routes/mcp.ts +++ b/packages/opencode/src/server/routes/mcp.ts @@ -2,6 +2,10 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { MCP } from "../../mcp" +// altimate_change start — workspace mode owns the datamate key +import { DATAMATE_KEY } from "../../altimate/datamate-transport" +import { managedWorkspace } from "../../altimate/workspace/engine-overlay" +// altimate_change end // altimate_change start — Config.Mcp + MCP.Status migrated to Effect Schema in v1.17.9; convert to zod for HTTP schemas import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" import { zod } from "@/util/effect-zod" @@ -59,6 +63,15 @@ export const McpRoutes = lazy(() => ), async (c) => { const { name, config } = c.req.valid("json") + // altimate_change start — workspace mode owns the `datamate` key + const managed = name === DATAMATE_KEY ? managedWorkspace() : null + if (managed) { + return c.json( + { error: `MCP server "${name}" is managed by workspace "${managed.name}" in this project` }, + 400, + ) + } + // altimate_change end const result = await MCP.add(name, config) return c.json(result.status) }, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6f6af78a1a..44c2794826 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -34,6 +34,8 @@ import { MCP } from "../mcp" // Import sync + fresh-read helpers directly from the shared transport module. // Using datamate-transport.ts instead of serve.ts avoids a dep on a cmd handler. import { syncDatamateUrlFromVscodeMcp } from "../altimate/datamate-transport" +// altimate_change - workspace mode owns the datamate key +import { managedWorkspace } from "../altimate/workspace/engine-overlay" import { readMcpEntryFromDisk } from "../mcp/config" import { resolveConfigPath } from "../mcp/config" import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt" @@ -685,6 +687,16 @@ export namespace Server { .post("/altimate/mcp/reload-datamate", async (c) => { try { const directory = Instance.directory + // In workspace mode the `datamate` key is the bound workspace's own engine, + // derived at config load; an IDE reload must not replace it under a turn. + const managed = managedWorkspace() + if (managed) { + log.info("reload-datamate: refused, key is managed by a workspace", { workspace: managed.id }) + return c.json( + { ok: false, error: `The datamate MCP server is managed by workspace "${managed.name}" in this project.` }, + 409, + ) + } log.info("reload-datamate: syncing IDE MCP config", { directory }) // Sync IDE MCP config → altimate-code.json; returns updated server names. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 268babfc66..e8894b01fd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -25,6 +25,8 @@ import { MemoryPrompt } from "../memory/prompt" import { UNIFIED_INJECTION_BUDGET } from "../memory/types" // altimate_change - workspace memory read path import * as WorkspaceMemory from "../altimate/workspace/memory-sync" +// altimate_change - workspace engine turn boundary +import * as WorkspaceEngine from "../altimate/workspace/engine-overlay" import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" @@ -1007,6 +1009,15 @@ export namespace SessionPrompt { const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false + // altimate_change start — workspace engine turn boundary. Reconciles the bound + // workspace's engine (re-link, one retry on a failed handshake), settles this + // session's outcome and announces it once per verdict. Runs before + // `resolveTools` so the engine's tools are in this turn's tool list; the cold + // engine boot happens inside MCP's own bootstrap, bounded by its per-server + // timeout. Every user turn starts at step 1 (see the note below on `step`). + if (step === 1) await WorkspaceEngine.beforeTurn(sessionID) + // altimate_change end + // altimate_change start (AI-7519) — trace resolveTools per step. // Included in the parent `bootstrap` span on step===1; on later steps // this measures the per-turn tool-listing overhead (MCP.tools connect diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts new file mode 100644 index 0000000000..71ea2551d2 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -0,0 +1,453 @@ +// altimate_change - new file +// +// The workspace engine overlay, end to end through its seams: what the config +// loader gets, what a turn boundary does, what the session is told. No +// instance is booted, no process spawned, no MCP state touched. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { + FAILED_PROBE_TTL_MS, + INSTALL_COMMAND, + MAX_TRACKED_SESSIONS, + beforeTurn, + invalidateProbe, + managedWorkspace, + overlay, + overlayForTests, + pinnedWorkspace, + resetForTests, + settledOutcome, + syncInternals, + trackedSessionsForTests, + type Declared, + type LocalMcpConfig, + type Toast, +} from "../../../src/altimate/workspace/engine-overlay" +import type { CachedBinding } from "../../../src/altimate/workspace/state" + +const DIR = "/tmp/analytics" +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE + +const bound = (id: number, name = "analytics"): CachedBinding => + ({ datamateId: id, datamateName: name, repoRemote: null, projectPath: DIR, linkedAt: 0 }) as CachedBinding + +type Harness = { + config: { mcp?: Record } + binding: CachedBinding | null + which: string | null + version: string | null + status: string + statusError?: string + onAdd?: () => void + tools: Record + added: LocalMcpConfig[] + removes: number + gets: number + invalidates: number + probes: number + toasts: Toast[] + lines: string[] + clock: number +} + +function install(opts: { + flag?: boolean + serve?: boolean + headless?: boolean + binding?: CachedBinding | null + which?: string | null + version?: string | null + declared?: Declared | null + status?: string + statusError?: string + onAdd?: () => void + tools?: Record + mcp?: Record + noMcpKey?: boolean +}): Harness { + const h: Harness = { + config: opts.noMcpKey ? {} : { mcp: opts.mcp ?? {} }, + binding: opts.binding === undefined ? bound(42) : opts.binding, + which: opts.which === undefined ? "/usr/local/bin/datamate" : opts.which, + version: opts.version === undefined ? "0.7.0" : opts.version, + status: opts.status ?? "connected", + statusError: opts.statusError, + onAdd: opts.onAdd, + tools: opts.tools ?? { datamate_dbt_build_model: {}, datamate_dbt_compile_model: {} }, + added: [], + removes: 0, + gets: 0, + invalidates: 0, + probes: 0, + toasts: [], + lines: [], + clock: 1_000_000, + } + process.env.ALTIMATE_WORKSPACE = opts.flag === false ? "" : "1" + syncInternals.serve = () => opts.serve === true + syncInternals.headless = () => opts.headless === true + syncInternals.instanceDirectory = () => DIR + syncInternals.resolveBinding = async () => h.binding + syncInternals.which = () => h.which + syncInternals.versionOf = async () => { + h.probes += 1 + return h.version + } + syncInternals.declared = async () => + opts.declared === undefined + ? { keys: ["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"], extensionKeys: [] } + : opts.declared + syncInternals.notify = async (toast) => { + h.toasts.push(toast) + } + syncInternals.printLine = (line) => { + h.lines.push(line) + } + syncInternals.now = () => h.clock + syncInternals.mcp = { + status: async () => ({ datamate: { status: h.status, ...(h.statusError ? { error: h.statusError } : {}) } }), + add: async (_name, cfg) => { + h.added.push(cfg) + h.onAdd?.() + }, + remove: async () => { + h.removes += 1 + }, + tools: async () => h.tools, + } + // Models the real Config cache: `get` loads once and is then served from + // cache until `invalidate`; a load rebuilds the config from its sources (so + // nothing the overlay injected earlier survives a reload) and runs the overlay. + const initialMcp = opts.mcp ? structuredClone(opts.mcp) : undefined + let loaded = false + syncInternals.config = { + invalidate: async () => { + h.invalidates += 1 + loaded = false + }, + get: async () => { + h.gets += 1 + if (loaded) return + h.config = opts.noMcpKey ? {} : { mcp: structuredClone(initialMcp ?? {}) } + await overlay(DIR, h.config) + loaded = true + }, + } + return h +} + +beforeEach(() => resetForTests()) +afterEach(() => { + resetForTests() + for (const key of Object.keys(syncInternals)) delete (syncInternals as Record)[key] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG +}) + +const IDE_ENTRY = { type: "local", command: ["datamate", "start-stdio"] } +const HOSTED_ENTRY = { type: "remote", url: "https://mcpserver.example.invalid/sse" } + +describe("overlay — what the config loader gets", () => { + test("flag off leaves config.mcp untouched and nothing is managed", async () => { + const h = install({ flag: false, mcp: { datamate: IDE_ENTRY, github: { type: "remote", url: "x" } } }) + await overlay(DIR, h.config) + expect(h.config.mcp).toEqual({ datamate: IDE_ENTRY, github: { type: "remote", url: "x" } }) + expect(managedWorkspace()).toBeNull() + expect(h.probes).toBe(0) + }) + + test("under serve the overlay is inert even when bound", async () => { + const h = install({ serve: true, mcp: { datamate: IDE_ENTRY } }) + await overlay(DIR, h.config) + expect(h.config.mcp).toEqual({ datamate: IDE_ENTRY }) + expect(managedWorkspace()).toBeNull() + }) + + test("an unbound directory leaves config.mcp untouched, including a hosted entry", async () => { + const h = install({ binding: null, mcp: { datamate: HOSTED_ENTRY } }) + await overlay(DIR, h.config) + expect(h.config.mcp).toEqual({ datamate: HOSTED_ENTRY }) + expect(managedWorkspace()).toBeNull() + expect(h.probes).toBe(0) + }) + + test("an unbound directory with no mcp block gets none", async () => { + const h = install({ binding: null, noMcpKey: true }) + await overlay(DIR, h.config) + expect("mcp" in h.config).toBe(false) + }) + + test("a bound directory with a usable engine injects the pinned entry over an IDE entry", async () => { + const h = install({ mcp: { datamate: IDE_ENTRY, github: { type: "remote", url: "x" } } }) + await overlay(DIR, h.config) + const entry = h.config.mcp!.datamate as LocalMcpConfig + expect(entry).toEqual({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }) + expect(pinnedWorkspace(entry)).toBe("42") + expect(h.config.mcp!.github).toEqual({ type: "remote", url: "x" }) + expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) + }) + + test("a bound directory with a usable engine replaces a hosted entry", async () => { + const h = install({ mcp: { datamate: HOSTED_ENTRY } }) + await overlay(DIR, h.config) + expect((h.config.mcp!.datamate as LocalMcpConfig).command).toContain("--datamate") + }) + + test("a bound directory with a usable engine creates the mcp block when there was none", async () => { + const h = install({ noMcpKey: true }) + await overlay(DIR, h.config) + expect(pinnedWorkspace(h.config.mcp!.datamate as LocalMcpConfig)).toBe("42") + }) + + test("a bound directory without an engine removes the key rather than falling back", async () => { + const h = install({ which: null, mcp: { datamate: HOSTED_ENTRY, github: { type: "remote", url: "x" } } }) + await overlay(DIR, h.config) + expect(h.config.mcp).toEqual({ github: { type: "remote", url: "x" } }) + expect(overlayForTests()?.refusal).toEqual({ kind: "engine-missing" }) + expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) + }) + + test("an engine below the floor is refused with its version; one that prints nothing is refused as broken", async () => { + const old = install({ version: "0.6.3", mcp: { datamate: IDE_ENTRY } }) + await overlay(DIR, old.config) + expect(old.config.mcp).toEqual({}) + expect(overlayForTests()?.refusal).toEqual({ kind: "engine-too-old", found: "0.6.3" }) + + resetForTests() + const broken = install({ version: null }) + await overlay(DIR, broken.config) + expect(overlayForTests()?.refusal).toEqual({ kind: "engine-too-old", found: null }) + }) + + test("a pre-release of the floor version does not clear it", async () => { + const h = install({ version: "0.7.0-beta.1" }) + await overlay(DIR, h.config) + expect(overlayForTests()?.entry).toBeNull() + }) + + test("a usable engine is probed once per process; a failed probe is asked again only after the TTL", async () => { + const h = install({}) + await overlay(DIR, h.config) + await overlay(DIR, h.config) + expect(h.probes).toBe(1) + + resetForTests() + const missing = install({ version: "0.6.3" }) + await overlay(DIR, missing.config) + await overlay(DIR, missing.config) + expect(missing.probes).toBe(1) + missing.clock += FAILED_PROBE_TTL_MS + await overlay(DIR, missing.config) + expect(missing.probes).toBe(2) + }) + + test("a binding read that throws leaves the config as loaded", async () => { + const h = install({ mcp: { datamate: IDE_ENTRY } }) + syncInternals.resolveBinding = async () => { + throw new Error("cache unreadable") + } + await overlay(DIR, h.config) + expect(h.config.mcp).toEqual({ datamate: IDE_ENTRY }) + expect(managedWorkspace()).toBeNull() + }) +}) + +describe("beforeTurn — what a turn boundary does", () => { + test("flag off settles disabled and touches nothing", async () => { + const h = install({ flag: false }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "disabled" }) + expect(h.gets).toBe(0) + expect(h.added).toEqual([]) + }) + + test("settledOutcome is undefined before the first turn boundary", async () => { + install({}) + expect(settledOutcome("never")).toBeUndefined() + }) + + test("unbound settles unbound without touching MCP", async () => { + const h = install({ binding: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) + expect(h.added).toEqual([]) + expect(h.removes).toBe(0) + expect(h.toasts).toEqual([]) + }) + + test("a connected engine settles attached with the inventory and announces it once", async () => { + const h = install({}) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ + kind: "attached", + available: 2, + declared: 3, + missing: ["dbt_execute_sql"], + }) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].message).toContain("2 of 3 declared integration tools available") + expect(h.toasts[0].message).toContain("dbt_execute_sql") + // The engine was started by MCP bootstrap from the injected entry, not by the hook. + expect(h.added).toEqual([]) + await beforeTurn("s1") + expect(h.toasts).toHaveLength(1) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("attached without an allowlist reports only what is available", async () => { + const h = install({ declared: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2 }) + expect(h.toasts[0].message).toBe("2 integration tools available.") + }) + + test("the inventory is announced per session, not per process", async () => { + const h = install({}) + await beforeTurn("s1") + await beforeTurn("s2") + expect(h.toasts).toHaveLength(2) + }) + + test("a missing engine settles engine-missing with the declared count and announces the install command once", async () => { + const h = install({ which: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "engine-missing", declared: 3 }) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].message).toContain("declares 3 integration tools") + expect(h.toasts[0].message).toContain(INSTALL_COMMAND) + expect(h.added).toEqual([]) + await beforeTurn("s1") + expect(h.toasts).toHaveLength(1) + }) + + test("a missing engine with no reachable allowlist still names the install command", async () => { + const h = install({ which: null, declared: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "engine-missing" }) + expect(h.toasts[0].message).toContain(INSTALL_COMMAND) + }) + + test("an engine below the floor settles engine-too-old and says which version was found", async () => { + const h = install({ version: "0.6.3" }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "engine-too-old", found: "0.6.3" }) + expect(h.toasts[0].message).toContain("Found datamate 0.6.3") + }) + + test("headless run prints exactly one stderr line for a refusal and no toast", async () => { + const h = install({ which: null, headless: true }) + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.lines).toHaveLength(1) + expect(h.lines[0]).toContain(INSTALL_COMMAND) + expect(h.toasts).toEqual([]) + }) + + test("headless run stays silent on the happy path", async () => { + const h = install({ headless: true }) + await beforeTurn("s1") + expect(h.lines).toEqual([]) + expect(h.toasts).toEqual([]) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("a failed handshake is retried once, then settles connect-failed and is announced once", async () => { + const h = install({ status: "failed", statusError: "Connection closed" }) + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(settledOutcome("s1")).toEqual({ kind: "connect-failed", error: "Connection closed" }) + expect(h.toasts).toHaveLength(1) + expect(h.toasts[0].message).toContain("Connection closed") + await beforeTurn("s1") + await beforeTurn("s2") + expect(h.added).toHaveLength(1) + expect(h.toasts).toHaveLength(2) + }) + + test("a retry that succeeds settles attached", async () => { + const h = install({ status: "failed" }) + h.onAdd = () => { + h.status = "connected" + } + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("a re-link reloads the overlay and replaces the engine for the new workspace", async () => { + const h = install({}) + await beforeTurn("s1") + expect(managedWorkspace()?.id).toBe("42") + h.binding = bound(7, "growth") + await beforeTurn("s1") + expect(h.invalidates).toBe(1) + expect(h.added).toHaveLength(1) + expect(pinnedWorkspace(h.added[0])).toBe("7") + expect(pinnedWorkspace(h.config.mcp!.datamate as LocalMcpConfig)).toBe("7") + expect(managedWorkspace()).toEqual({ id: "7", name: "growth" }) + expect(settledOutcome("s1")?.kind).toBe("attached") + expect(h.toasts.at(-1)?.title).toContain("growth") + }) + + test("an unlink mid-session removes the engine and settles unbound", async () => { + const h = install({}) + await beforeTurn("s1") + h.binding = null + await beforeTurn("s1") + expect(h.removes).toBe(1) + expect(h.config.mcp!.datamate).toBeUndefined() + expect(managedWorkspace()).toBeNull() + expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) + }) + + test("an engine installed after a refusal is picked up once the probe is asked again", async () => { + const h = install({ which: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("engine-missing") + h.which = "/usr/local/bin/datamate" + // Within the TTL the failed probe is not repeated... + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("engine-missing") + expect(h.added).toEqual([]) + // ...the install offer invalidates it explicitly; a later turn re-probes on its own. + invalidateProbe() + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(pinnedWorkspace(h.added[0])).toBe("42") + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("a failed probe is repeated on its own after the TTL", async () => { + const h = install({ version: "0.6.3" }) + await beforeTurn("s1") + h.version = "0.7.0" + h.clock += FAILED_PROBE_TTL_MS + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(settledOutcome("s1")?.kind).toBe("attached") + }) + + test("the session memo is bounded", async () => { + install({}) + for (let i = 0; i < MAX_TRACKED_SESSIONS + 10; i++) await beforeTurn(`s${i}`) + expect(trackedSessionsForTests()).toBe(MAX_TRACKED_SESSIONS) + expect(settledOutcome("s0")).toBeUndefined() + expect(settledOutcome(`s${MAX_TRACKED_SESSIONS + 9}`)?.kind).toBe("attached") + }) + + test("the turn hook never throws", async () => { + install({}) + syncInternals.config = { + invalidate: async () => {}, + get: async () => { + throw new Error("config exploded") + }, + } + await expect(beforeTurn("s1")).resolves.toBeUndefined() + }) + + test("the key stays managed while the engine is missing, so writers still refuse", async () => { + install({ which: null }) + await beforeTurn("s1") + expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-types.test.ts b/packages/opencode/test/altimate/workspace/engine-types.test.ts new file mode 100644 index 0000000000..06f43732f2 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-types.test.ts @@ -0,0 +1,147 @@ +// altimate_change - new file +// +// The pure vocabulary of the workspace engine overlay: version floor, pin +// parser, tool keys, and the meaning tables over the outcome union. +import { describe, expect, test } from "bun:test" +import { + INSTALL_COMMAND, + INSTALL_HELPS, + MIN_ENGINE_VERSION, + REPAIRABLE, + SERVING, + attributableEngine, + clearsFloor, + compareVersions, + describeMissing, + describeRefusal, + engineEntry, + engineToolKeys, + installWouldHelp, + pinnedWorkspace, + type Outcome, +} from "../../../src/altimate/workspace/engine-types" + +describe("compareVersions", () => { + test("orders by major, minor, patch", () => { + expect(compareVersions("0.7.0", "0.7.0")).toBe(0) + expect(compareVersions("0.7.1", "0.7.0")).toBeGreaterThan(0) + expect(compareVersions("0.8.0", "0.7.9")).toBeGreaterThan(0) + expect(compareVersions("1.0.0", "0.9.9")).toBeGreaterThan(0) + expect(compareVersions("0.6.3", "0.7.0")).toBeLessThan(0) + }) + test("a release outranks every pre-release of it", () => { + expect(compareVersions("0.7.0-beta.1", "0.7.0")).toBeLessThan(0) + expect(compareVersions("0.7.0", "0.7.0-rc.2")).toBeGreaterThan(0) + expect(compareVersions("0.7.0-beta.2", "0.7.0-beta.10")).toBeLessThan(0) + expect(compareVersions("0.7.0-alpha", "0.7.0-beta")).toBeLessThan(0) + expect(compareVersions("0.7.0-1", "0.7.0-alpha")).toBeLessThan(0) + }) + test("ignores build metadata and a leading v", () => { + expect(compareVersions("v0.7.0+build.5", "0.7.0")).toBe(0) + }) + test("an unreadable core ranks below any readable one", () => { + expect(compareVersions("0.7rc.0", "0.7.0")).toBeLessThan(0) + expect(compareVersions("1", "0.7.0")).toBeLessThan(0) + expect(compareVersions("garbage", "0.0.1")).toBeLessThan(0) + expect(compareVersions("garbage", "nonsense")).toBe(0) + }) +}) + +describe("clearsFloor", () => { + test("only a readable version at or above the floor clears it", () => { + expect(clearsFloor(null)).toBe(false) + expect(clearsFloor("")).toBe(false) + expect(clearsFloor(MIN_ENGINE_VERSION)).toBe(true) + expect(clearsFloor("0.7.1")).toBe(true) + expect(clearsFloor("1.0.0")).toBe(true) + expect(clearsFloor("0.6.9")).toBe(false) + expect(clearsFloor(`${MIN_ENGINE_VERSION}-beta.1`)).toBe(false) + expect(clearsFloor("0.7rc.0")).toBe(false) + }) +}) + +describe("pinnedWorkspace", () => { + test("reads the pin from opencode's argv shape", () => { + expect(pinnedWorkspace({ command: ["datamate", "start-stdio", "--datamate", "5"] })).toBe("5") + }) + test("reads the pin from an IDE's command + args shape, either spelling", () => { + expect(pinnedWorkspace({ command: "datamate", args: ["start-stdio", "--datamate=7"] })).toBe("7") + expect(pinnedWorkspace({ command: "datamate", args: ["start-stdio", "--datamate", "8"] })).toBe("8") + }) + test("a repeated pin resolves last-wins", () => { + expect(pinnedWorkspace({ command: ["datamate", "--datamate", "1", "start-stdio", "--datamate=2"] })).toBe("2") + }) + test("fails open on every miss", () => { + expect(pinnedWorkspace(null)).toBeNull() + expect(pinnedWorkspace({})).toBeNull() + expect(pinnedWorkspace({ url: "https://example.invalid/sse" })).toBeNull() + expect(pinnedWorkspace({ command: ["datamate", "start-stdio"] })).toBeNull() + expect(pinnedWorkspace({ command: ["datamate", "start-stdio", "--datamate"] })).toBeNull() + expect(pinnedWorkspace({ command: ["datamate", "--datamate="] })).toBeNull() + expect(pinnedWorkspace({ command: ["datamate", "--datamate", 5 as unknown as string] })).toBeNull() + }) + test("the derived entry is pinned to the workspace it was derived for", () => { + const entry = engineEntry("42") + expect(entry).toEqual({ type: "local", command: ["datamate", "start-stdio", "--datamate", "42"], enabled: true }) + expect(pinnedWorkspace(entry)).toBe("42") + }) +}) + +describe("engineToolKeys", () => { + test("strips the datamate prefix and ignores everything else", () => { + const keys = engineToolKeys({ + datamate_dbt_build_model: {}, + datamate_snowflake_execute_database_query: {}, + "datamate-prod_other_tool": {}, + sql_execute: {}, + }) + expect([...keys].sort()).toEqual(["dbt_build_model", "snowflake_execute_database_query"]) + }) +}) + +describe("outcome tables", () => { + const kinds: Outcome["kind"][] = [ + "disabled", + "unbound", + "attached", + "engine-missing", + "engine-too-old", + "connect-failed", + ] + test("every table names every variant", () => { + for (const table of [SERVING, INSTALL_HELPS, REPAIRABLE]) { + expect(Object.keys(table).sort()).toEqual([...kinds].sort()) + } + }) + test("only an attached engine is attributable", () => { + expect(kinds.filter((k) => SERVING[k])).toEqual(["attached"]) + expect(attributableEngine(undefined)).toBe(false) + expect(attributableEngine({ kind: "attached", available: 1 })).toBe(true) + expect(attributableEngine({ kind: "connect-failed", error: "x" })).toBe(false) + }) + test("only genuine unobtainability is fixed by an install", () => { + expect(kinds.filter((k) => INSTALL_HELPS[k]).sort()).toEqual(["engine-missing", "engine-too-old"]) + expect(installWouldHelp(undefined)).toBe(false) + expect(installWouldHelp({ kind: "engine-missing" })).toBe(true) + expect(installWouldHelp({ kind: "connect-failed", error: "x" })).toBe(false) + }) + test("refusals the user can act on are repairable; verdicts about the project are not", () => { + expect(kinds.filter((k) => REPAIRABLE[k]).sort()).toEqual(["connect-failed", "engine-missing", "engine-too-old"]) + }) +}) + +describe("messages", () => { + test("a broken engine is described as broken, an old one as old", () => { + expect(describeRefusal(null, "analytics")).toContain("more likely broken than out of date") + expect(describeRefusal(null, "analytics")).toContain(INSTALL_COMMAND) + expect(describeRefusal("0.6.3", "analytics")).toContain(`needs ${MIN_ENGINE_VERSION} or newer`) + expect(describeRefusal("0.6.3", "analytics")).toContain("Found datamate 0.6.3") + }) + test("the missing list is truncated after five", () => { + expect(describeMissing([])).toBe("") + expect(describeMissing(["a", "b"])).toBe(" Declared but not available: a, b.") + expect(describeMissing(["a", "b", "c", "d", "e", "f", "g"])).toBe( + " Declared but not available: a, b, c, d, e (+2 more).", + ) + }) +}) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 9d71a0db25..3503eda1b8 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -526,6 +526,34 @@ it.instance( }, ) +// altimate_change start — remove forgets the runtime config +it.instance( + "remove forgets the key entirely, so it no longer reports as disabled", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "rm-server" + getOrCreateClientState("rm-server") + + yield* mcp.add("rm-server", { + type: "local", + command: ["echo", "test"], + }) + expect((yield* mcp.status())["rm-server"]?.status).toBe("connected") + + yield* mcp.remove("rm-server") + + // A retained runtime config would have status() synthesise "disabled" + // for the rest of the process, and connect() re-spawn the removed entry. + expect((yield* mcp.status())["rm-server"]).toBeUndefined() + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((k) => k.startsWith("rm-server"))).toBe(false) + }), + ), + { config: { mcp: {} } }, +) +// altimate_change end + it.instance( "connect() after disconnect() re-establishes the server", () => From 3022613726ac07911ea4199e95cac4cba73e9693 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 03:09:52 +0800 Subject: [PATCH 02/13] fix(workspace): count only declared tools in the inventory line The engine adds knowledge and memory tools beyond the allowlist when the workspace enables them, so "available of declared" read "11 of 7". The line now counts the declared tools that are present; `available` on the outcome still counts everything served. --- .../opencode/src/altimate/workspace/engine-overlay.ts | 9 ++++++++- .../test/altimate/workspace/engine-overlay.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 967202c93a..c7d44dd2da 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -124,6 +124,9 @@ export async function overlay(directory: string, config: { mcp?: Record { const present = engineToolKeys(await mcp().tools()) const missing = declared ? declared.keys.filter((k) => !present.has(k)) : undefined + // `available` is everything the engine serves under the key. The engine adds + // tools beyond the allowlist (knowledge, memory) when the workspace enables + // them, so the "N of M declared" line counts only the declared ones present. + const served = declared ? declared.keys.length - (missing?.length ?? 0) : present.size const outcome: Outcome = { kind: "attached", available: present.size, @@ -366,7 +373,7 @@ async function reconcile(sessionID: string): Promise { await notify({ title: `Workspace "${workspace.name}"`, message: declared - ? `${outcome.available} of ${declared.keys.length} declared integration tools available.${describeMissing(missing ?? [])}` + ? `${served} of ${declared.keys.length} declared integration tools available.${describeMissing(missing ?? [])}` : `${outcome.available} integration tools available.`, variant: missing && missing.length > 0 ? "warning" : "info", }) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 71ea2551d2..8e6cec91f6 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -293,6 +293,16 @@ describe("beforeTurn — what a turn boundary does", () => { expect(settledOutcome("s1")?.kind).toBe("attached") }) + test("the inventory counts declared tools that are present, not every tool the engine serves", async () => { + const h = install({ + tools: { datamate_dbt_build_model: {}, datamate_dbt_compile_model: {}, datamate_altimate_knowledge_search: {} }, + declared: { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] }, + }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [] }) + expect(h.toasts[0].message).toBe("2 of 2 declared integration tools available.") + }) + test("attached without an allowlist reports only what is available", async () => { const h = install({ declared: null }) await beforeTurn("s1") From 73c5180ece9a5b330d3c8589152b1c12bafff12b Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 03:19:23 +0800 Subject: [PATCH 03/13] fix(workspace): keep overlay state per directory and the retry budget per session Config and MCP state are per project instance and one server process can host several directories, so a module-wide overlay let project B's engine be started inside A's MCP state. The overlay is now keyed by directory and the writers ask for the current instance's. The one re-add after a failed handshake is per session, so "start a new session to try again" is true. --- .../src/altimate/workspace/engine-overlay.ts | 104 +++++++++++------- .../altimate/workspace/engine-overlay.test.ts | 33 +++++- 2 files changed, 96 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index c7d44dd2da..82a01e3880 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -98,14 +98,31 @@ type Overlay = { refusal: Extract | null } -let current: Overlay | null = null -/** What MCP is believed to be running under the key: the overlay as it stood - * when MCP bootstrapped (config load precedes MCP init, which reads the cached - * config), then whatever the turn hook last applied. `undefined` until the - * first turn boundary. Kept apart from `current` because any consumer can - * invalidate and reload config between turns, re-running the overlay without - * touching MCP. */ -let applied: Overlay | null | undefined = undefined +/** Per-directory state. Config and MCP state are per project instance, and one + * server process can host several directories, so the overlay is keyed the same + * way — a module-wide value would let project B's overlay start B's engine + * inside A's MCP state. */ +type DirectoryState = { + /** The overlay as of the last config load for this directory. */ + current: Overlay | null + /** What MCP is believed to be running under the key: the overlay as it stood + * when MCP bootstrapped (config load precedes MCP init, which reads the cached + * config), then whatever the turn hook last applied. `undefined` until the + * first turn boundary. Kept apart from `current` because any consumer can + * invalidate and reload config between turns, re-running the overlay without + * touching MCP. */ + applied: Overlay | null | undefined +} +const directories = new Map() + +function stateFor(directory: string): DirectoryState { + let state = directories.get(directory) + if (!state) { + state = { current: null, applied: undefined } + directories.set(directory, state) + } + return state +} function sameEntry(a: LocalMcpConfig | null, b: LocalMcpConfig | null): boolean { return !!a && !!b && a.command.join("\0") === b.command.join("\0") @@ -117,9 +134,10 @@ function sameEntry(a: LocalMcpConfig | null, b: LocalMcpConfig | null): boolean * last word over every other source of the key. Mutates `config.mcp` only when * the directory is bound with the pilot on. Never throws. */ export async function overlay(directory: string, config: { mcp?: Record }): Promise { + const state = stateFor(directory) try { if (!isEnabled() || isServe()) { - current = null + state.current = null return } const binding = await resolveBinding(directory) @@ -127,7 +145,7 @@ export async function overlay(directory: string, config: { mcp?: Record() -/** Workspaces whose failed handshake was already retried in this process. */ -const retried = new Set() const declaredCache = new Map() function record(sessionID: string, outcome: Outcome): SessionRecord { const previous = sessions.get(sessionID) sessions.delete(sessionID) - const next: SessionRecord = { outcome, announced: previous?.announced } + const next: SessionRecord = { outcome, announced: previous?.announced, retried: previous?.retried } sessions.set(sessionID, next) while (sessions.size > MAX_TRACKED_SESSIONS) { const oldest = sessions.keys().next().value @@ -244,21 +265,22 @@ async function reconcile(sessionID: string): Promise { record(sessionID, { kind: "unbound" }) return } + const state = stateFor(directory) // The overlay runs inside config load; make sure it has run at least once. await config().get() // First turn boundary: MCP bootstrapped from the config as loaded, i.e. from // the overlay as it stands now. - if (applied === undefined) applied = current + if (state.applied === undefined) state.applied = state.current const binding = await resolveBinding(directory) if (!binding) { // Unlinked (or never linked): the key is not ours to fill. - if (current || applied) { + if (state.current || state.applied) { await config().invalidate() await config().get() } - if (applied?.entry) await mcp().remove(DATAMATE_KEY) - applied = null + if (state.applied?.entry) await mcp().remove(DATAMATE_KEY) + state.applied = null record(sessionID, { kind: "unbound" }) return } @@ -266,8 +288,8 @@ async function reconcile(sessionID: string): Promise { // Reload the overlay when the binding moved, or when a refused engine may // have appeared since (the probe memo bounds how often that is asked). - let reload = !current || current.workspace.id !== workspaceId - if (!reload && current && !current.entry) { + let reload = !state.current || state.current.workspace.id !== workspaceId + if (!reload && state.current && !state.current.entry) { const probe = await probeEngine() reload = probe.kind === "ok" } @@ -276,10 +298,10 @@ async function reconcile(sessionID: string): Promise { await config().get() } - const overlayNow = current + const overlayNow = state.current if (!overlayNow) { - if (applied?.entry) await mcp().remove(DATAMATE_KEY) - applied = null + if (state.applied?.entry) await mcp().remove(DATAMATE_KEY) + state.applied = null record(sessionID, { kind: "unbound" }) return } @@ -288,11 +310,11 @@ async function reconcile(sessionID: string): Promise { // Bring MCP in line with the overlay: start or replace the engine when the // derived entry changed, drop it when there is none any more. if (overlayNow.entry) { - if (!sameEntry(applied?.entry ?? null, overlayNow.entry)) await mcp().add(DATAMATE_KEY, overlayNow.entry) - } else if (applied?.entry) { + if (!sameEntry(state.applied?.entry ?? null, overlayNow.entry)) await mcp().add(DATAMATE_KEY, overlayNow.entry) + } else if (state.applied?.entry) { await mcp().remove(DATAMATE_KEY) } - applied = overlayNow + state.applied = overlayNow if (!overlayNow.entry) { const refusal = overlayNow.refusal ?? { kind: "engine-missing" as const } @@ -326,9 +348,14 @@ async function reconcile(sessionID: string): Promise { // the engine's handshake; the allowlist lookup overlaps with it. const [statusMap, declared] = await Promise.all([mcp().status(), declaredFor(workspace.id)]) let status = statusMap[DATAMATE_KEY] - if (status?.status !== "connected" && !retried.has(workspace.id)) { - retried.add(workspace.id) - log.info("workspace engine not connected; retrying once", { workspaceId: workspace.id, status: status?.status }) + const session = sessions.get(sessionID) + if (status?.status !== "connected" && !session?.retried) { + ;(session ?? record(sessionID, { kind: "connect-failed", error: "retrying" })).retried = true + log.info("workspace engine not connected; retrying once for this session", { + workspaceId: workspace.id, + sessionID, + status: status?.status, + }) await mcp().add(DATAMATE_KEY, overlayNow.entry) status = (await mcp().status())[DATAMATE_KEY] } @@ -406,17 +433,16 @@ export function isRepairable(outcome: Outcome | undefined): boolean { /** Test-only: forget everything this process learned. */ export function resetForTests(): void { - current = null - applied = undefined + directories.clear() probeMemo = null sessions.clear() - retried.clear() declaredCache.clear() } /** Test-only views. */ -export function overlayForTests(): Overlay | null { - return current +export function overlayForTests(directory?: string): Overlay | null { + const dir = directory ?? currentDirectory() + return dir ? (directories.get(dir)?.current ?? null) : null } export function trackedSessionsForTests(): number { return sessions.size diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 8e6cec91f6..ebd026030c 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -360,17 +360,46 @@ describe("beforeTurn — what a turn boundary does", () => { expect(settledOutcome("s1")?.kind).toBe("attached") }) - test("a failed handshake is retried once, then settles connect-failed and is announced once", async () => { + test("a failed handshake is retried once per session, then settles connect-failed and is announced once", async () => { const h = install({ status: "failed", statusError: "Connection closed" }) await beforeTurn("s1") expect(h.added).toHaveLength(1) expect(settledOutcome("s1")).toEqual({ kind: "connect-failed", error: "Connection closed" }) expect(h.toasts).toHaveLength(1) expect(h.toasts[0].message).toContain("Connection closed") + expect(h.toasts[0].message).toContain("Start a new session") await beforeTurn("s1") - await beforeTurn("s2") expect(h.added).toHaveLength(1) + // The toast says a new session will try again, so a new session does. + await beforeTurn("s2") + expect(h.added).toHaveLength(2) expect(h.toasts).toHaveLength(2) + await beforeTurn("s2") + expect(h.added).toHaveLength(2) + }) + + test("overlay state is kept per directory, so one process can host two bound projects", async () => { + const DIR_B = "/tmp/growth" + const h = install({}) + const bindings: Record = { [DIR]: bound(42), [DIR_B]: bound(7, "growth") } + syncInternals.resolveBinding = async (directory) => bindings[directory] ?? null + const configA: { mcp?: Record } = { mcp: {} } + const configB: { mcp?: Record } = { mcp: {} } + await overlay(DIR, configA) + await overlay(DIR_B, configB) + expect(pinnedWorkspace(configA.mcp!.datamate as LocalMcpConfig)).toBe("42") + expect(pinnedWorkspace(configB.mcp!.datamate as LocalMcpConfig)).toBe("7") + expect(overlayForTests(DIR)?.workspace.id).toBe("42") + expect(overlayForTests(DIR_B)?.workspace.id).toBe("7") + // The writers ask for the current instance's directory. + syncInternals.instanceDirectory = () => DIR_B + expect(managedWorkspace()).toEqual({ id: "7", name: "growth" }) + syncInternals.instanceDirectory = () => DIR + expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) + // A's turn boundary sees A's overlay: nothing to reapply, no engine started for B. + await beforeTurn("s1") + expect(h.added).toEqual([]) + expect(settledOutcome("s1")?.kind).toBe("attached") }) test("a retry that succeeds settles attached", async () => { From 42f1f8d10537f5fbff30f0b99462c98e36ff5d2c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 03:28:03 +0800 Subject: [PATCH 04/13] fix(workspace): guard the v2 MCP add endpoint; hand the key back on unlink The shipped TUI/SDK path adds MCP servers through the HttpApi handler, not the legacy route, so the workspace-mode refusal of the `datamate` key now lives there too. On an unlink the reloaded config may restore the user's own hosted or IDE-written entry; MCP enumerates live clients only, so that entry is started after the workspace engine is removed. --- .../src/altimate/workspace/engine-overlay.ts | 34 +++++++++++++------ .../src/altimate/workspace/engine-seams.ts | 9 ++--- .../src/altimate/workspace/engine-types.ts | 7 ++++ .../routes/instance/httpapi/handlers/mcp.ts | 20 +++++++++++ .../altimate/workspace/engine-overlay.test.ts | 34 +++++++++++++++++-- 5 files changed, 88 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 82a01e3880..e0689fea06 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -37,8 +37,10 @@ import { describeRefusal, engineEntry, engineToolKeys, + isMcpEntry, type Declared, type LocalMcpConfig, + type McpEntry, type McpStatus, type Outcome, type Toast, @@ -182,8 +184,7 @@ export async function overlay(directory: string, config: { mcp?: Record MCP.status() as Promise, - add: (name: string, cfg: LocalMcpConfig) => MCP.add(name, cfg), + add: (name: string, cfg: LocalMcpConfig | McpEntry) => MCP.add(name, cfg as Parameters[1]), remove: (name: string) => MCP.remove(name), tools: () => MCP.tools() as Promise>, } @@ -230,13 +231,24 @@ function config() { return ( syncInternals.config ?? { invalidate: () => Config.invalidate(), - get: async () => { - await Config.get() - }, + get: async () => (await Config.get()) as { mcp?: Record }, } ) } +/** Hand the key back to whatever the reloaded config says now that the overlay + * no longer fills it: the user's own hosted or IDE-written entry, if any. MCP + * enumerates live clients only, so a restored config entry must be started or + * the project's standalone datamate tools stay gone for the rest of the process. */ +async function releaseKey(loaded: { mcp?: Record } | undefined): Promise { + await mcp().remove(DATAMATE_KEY) + const restored = loaded?.mcp?.[DATAMATE_KEY] + if (isMcpEntry(restored) && restored.enabled !== false) { + log.info("workspace engine released the datamate key; starting the configured entry", { type: restored.type }) + await mcp().add(DATAMATE_KEY, restored) + } +} + async function declaredFor(workspaceId: string): Promise { const cached = declaredCache.get(workspaceId) if (cached && (cached.value || now() - cached.at < DECLARED_RETRY_MS)) return cached.value @@ -275,11 +287,12 @@ async function reconcile(sessionID: string): Promise { const binding = await resolveBinding(directory) if (!binding) { // Unlinked (or never linked): the key is not ours to fill. + let loaded: { mcp?: Record } | undefined if (state.current || state.applied) { await config().invalidate() - await config().get() + loaded = await config().get() } - if (state.applied?.entry) await mcp().remove(DATAMATE_KEY) + if (state.applied?.entry) await releaseKey(loaded) state.applied = null record(sessionID, { kind: "unbound" }) return @@ -293,14 +306,15 @@ async function reconcile(sessionID: string): Promise { const probe = await probeEngine() reload = probe.kind === "ok" } + let loaded: { mcp?: Record } | undefined if (reload) { await config().invalidate() - await config().get() + loaded = await config().get() } const overlayNow = state.current if (!overlayNow) { - if (state.applied?.entry) await mcp().remove(DATAMATE_KEY) + if (state.applied?.entry) await releaseKey(loaded) state.applied = null record(sessionID, { kind: "unbound" }) return diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index b481c1a8e6..6202f9b825 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -6,7 +6,7 @@ import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" import type { CachedBinding } from "./state" -import type { Declared, LocalMcpConfig, McpStatus, Toast } from "./engine-types" +import type { Declared, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./engine-types" export const log = Log.create({ service: "workspace-engine" }) @@ -24,14 +24,15 @@ export const syncInternals: { now?: () => number mcp?: { status: () => Promise - add: (name: string, cfg: LocalMcpConfig) => Promise + add: (name: string, cfg: LocalMcpConfig | McpEntry) => Promise remove: (name: string) => Promise tools: () => Promise> } config?: { invalidate: () => Promise - /** Loads config, which runs the overlay as a side effect. */ - get: () => Promise + /** Loads config, which runs the overlay as a side effect, and returns the + * loaded `mcp` map. */ + get: () => Promise<{ mcp?: Record }> } } = {} diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 57849013fb..728c7abe37 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -37,6 +37,13 @@ export type LocalMcpConfig = { enabled: true } +/** A configured MCP entry as the loader hands it to MCP (any transport). */ +export type McpEntry = { type: string } & Record + +export function isMcpEntry(value: unknown): value is McpEntry { + return typeof value === "object" && value !== null && typeof (value as { type?: unknown }).type === "string" +} + export type Toast = { title: string; message: string; variant: "info" | "success" | "warning" | "error" } export type McpStatus = Record diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index cdf0cc1e70..1d883a5bd5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -4,6 +4,11 @@ import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { McpServerNotFoundError } from "../errors" import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp" +// altimate_change start — workspace mode owns the datamate key +import { InstanceState } from "@/effect/instance-state" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { managedWorkspace } from "@/altimate/workspace/engine-overlay" +// altimate_change end export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) => Effect.gen(function* () { @@ -14,6 +19,21 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler }) const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) { + // altimate_change start — in workspace mode the `datamate` key is the bound + // workspace's own engine, derived at config load; adding over it would replace + // the engine underneath a turn. Refuse and say why. + if (ctx.payload.name === DATAMATE_KEY) { + const managed = managedWorkspace(yield* InstanceState.directory) + if (managed) { + // BadRequest carries no body on this endpoint; the reason is logged. + yield* Effect.logWarning("mcp add refused: key is managed by a workspace", { + name: DATAMATE_KEY, + workspace: managed.id, + }) + return yield* new HttpApiError.BadRequest({}) + } + } + // altimate_change end const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status return yield* Schema.decodeUnknownEffect(StatusMap)( "status" in result ? { [ctx.payload.name]: result } : result, diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index ebd026030c..1818284dfe 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -20,6 +20,7 @@ import { trackedSessionsForTests, type Declared, type LocalMcpConfig, + type McpEntry, type Toast, } from "../../../src/altimate/workspace/engine-overlay" import type { CachedBinding } from "../../../src/altimate/workspace/state" @@ -39,7 +40,7 @@ type Harness = { statusError?: string onAdd?: () => void tools: Record - added: LocalMcpConfig[] + added: Array removes: number gets: number invalidates: number @@ -126,10 +127,11 @@ function install(opts: { }, get: async () => { h.gets += 1 - if (loaded) return + if (loaded) return h.config h.config = opts.noMcpKey ? {} : { mcp: structuredClone(initialMcp ?? {}) } await overlay(DIR, h.config) loaded = true + return h.config }, } return h @@ -396,6 +398,9 @@ describe("beforeTurn — what a turn boundary does", () => { expect(managedWorkspace()).toEqual({ id: "7", name: "growth" }) syncInternals.instanceDirectory = () => DIR expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) + // An Effect-side caller passes the instance directory explicitly. + expect(managedWorkspace(DIR_B)).toEqual({ id: "7", name: "growth" }) + expect(managedWorkspace("/tmp/elsewhere")).toBeNull() // A's turn boundary sees A's overlay: nothing to reapply, no engine started for B. await beforeTurn("s1") expect(h.added).toEqual([]) @@ -433,11 +438,36 @@ describe("beforeTurn — what a turn boundary does", () => { h.binding = null await beforeTurn("s1") expect(h.removes).toBe(1) + expect(h.added).toEqual([]) expect(h.config.mcp!.datamate).toBeUndefined() expect(managedWorkspace()).toBeNull() expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) }) + test("an unlink hands the key back to the entry the reloaded config restores", async () => { + // The overlay had shadowed the user's own hosted entry; once unbound, config + // reloads with that entry and MCP must be told to start it, because MCP only + // enumerates live clients. + const h = install({ mcp: { datamate: HOSTED_ENTRY } }) + await beforeTurn("s1") + expect(pinnedWorkspace(h.config.mcp!.datamate as LocalMcpConfig)).toBe("42") + h.binding = null + await beforeTurn("s1") + expect(h.removes).toBe(1) + expect(h.added).toEqual([HOSTED_ENTRY]) + expect(h.config.mcp!.datamate).toEqual(HOSTED_ENTRY) + expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) + }) + + test("an unlink does not start an entry the user had disabled", async () => { + const h = install({ mcp: { datamate: { ...HOSTED_ENTRY, enabled: false } } }) + await beforeTurn("s1") + h.binding = null + await beforeTurn("s1") + expect(h.removes).toBe(1) + expect(h.added).toEqual([]) + }) + test("an engine installed after a refusal is picked up once the probe is asked again", async () => { const h = install({ which: null }) await beforeTurn("s1") From 96194a99054ae94f14cf4b98d3655255d58b0239 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 03:38:16 +0800 Subject: [PATCH 05/13] fix(workspace): run turn hooks for one directory one at a time Sessions in a directory share the datamate key, and a sub-agent is enough to make two turns concurrent. One hook's binding read, reload and engine replacement now cannot interleave with another's, so a re-link observed by one session's boundary cannot land between another's read and its apply. --- .../src/altimate/workspace/engine-overlay.ts | 26 ++++++++++----- .../altimate/workspace/engine-overlay.test.ts | 33 +++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index e0689fea06..1c96b05bc7 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -107,6 +107,12 @@ type Overlay = { type DirectoryState = { /** The overlay as of the last config load for this directory. */ current: Overlay | null + /** Turn hooks for one directory run one at a time. Sessions in a directory + * share the key (a sub-agent's session is enough to make two concurrent), + * and a hook's binding read, reload and engine replacement must not + * interleave with another's — otherwise one session's boundary could + * replace the engine between another's read and its apply. */ + chain: Promise /** What MCP is believed to be running under the key: the overlay as it stood * when MCP bootstrapped (config load precedes MCP init, which reads the cached * config), then whatever the turn hook last applied. `undefined` until the @@ -120,7 +126,7 @@ const directories = new Map() function stateFor(directory: string): DirectoryState { let state = directories.get(directory) if (!state) { - state = { current: null, applied: undefined } + state = { current: null, applied: undefined, chain: Promise.resolve() } directories.set(directory, state) } return state @@ -260,14 +266,6 @@ async function declaredFor(workspaceId: string): Promise { /** Reconcile, settle and announce for one session. Runs at the start of every * user turn, before the tool list is resolved. Never throws. */ export async function beforeTurn(sessionID: string): Promise { - try { - await reconcile(sessionID) - } catch (err) { - log.warn("workspace engine turn hook failed", { sessionID, err: String(err) }) - } -} - -async function reconcile(sessionID: string): Promise { if (!isEnabled() || isServe()) { record(sessionID, { kind: "disabled" }) return @@ -278,6 +276,16 @@ async function reconcile(sessionID: string): Promise { return } const state = stateFor(directory) + const run = state.chain.then(() => reconcile(sessionID, directory, state)) + state.chain = run.catch(() => undefined) + try { + await run + } catch (err) { + log.warn("workspace engine turn hook failed", { sessionID, err: String(err) }) + } +} + +async function reconcile(sessionID: string, directory: string, state: DirectoryState): Promise { // The overlay runs inside config load; make sure it has run at least once. await config().get() // First turn boundary: MCP bootstrapped from the config as loaded, i.e. from diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 1818284dfe..aefb0b8b96 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -503,6 +503,39 @@ describe("beforeTurn — what a turn boundary does", () => { expect(settledOutcome(`s${MAX_TRACKED_SESSIONS + 9}`)?.kind).toBe("attached") }) + test("turn hooks for one directory run one at a time, so a re-link cannot interleave with another session's hook", async () => { + const h = install({}) + // Session A's binding read blocks until released; a re-link lands and + // session B's hook starts while A is inside its hook. + let calls = 0 + let releaseA: () => void = () => {} + const gateA = new Promise((resolve) => { + releaseA = resolve + }) + syncInternals.resolveBinding = async () => { + calls += 1 + // The read observes the binding as it was when asked; only the delivery + // of the answer is held back. + const snapshot = h.binding + if (calls === 2) await gateA + return snapshot + } + const a = beforeTurn("A") + await new Promise((r) => setTimeout(r, 5)) + h.binding = bound(7, "growth") + const b = beforeTurn("B") + await new Promise((r) => setTimeout(r, 5)) + // B is queued behind A: nothing has been applied for workspace 7 yet. + expect(h.added).toEqual([]) + releaseA() + await Promise.all([a, b]) + // A settled for the workspace it read; B's boundary then moved the engine. + expect(h.toasts.map((t) => t.title)).toEqual(['Workspace "analytics"', 'Workspace "growth"']) + expect(h.added).toHaveLength(1) + expect(pinnedWorkspace(h.added[0] as LocalMcpConfig)).toBe("7") + expect(managedWorkspace()?.id).toBe("7") + }) + test("the turn hook never throws", async () => { install({}) syncInternals.config = { From 7169ea7a0df6f1bf03109625c94115dad7adff8b Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 03:45:52 +0800 Subject: [PATCH 06/13] fix(workspace): hand the key back after unlinking a refused overlay too When the overlay had refused the engine it had removed the user's own datamate entry from the config it shadowed; an unlink then reloads that entry but had nothing to remove, so it was never started. The key is handed back whether or not a workspace engine was running. --- .../src/altimate/workspace/engine-overlay.ts | 10 ++++++---- .../altimate/workspace/engine-overlay.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 1c96b05bc7..01f2261e31 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -246,8 +246,8 @@ function config() { * no longer fills it: the user's own hosted or IDE-written entry, if any. MCP * enumerates live clients only, so a restored config entry must be started or * the project's standalone datamate tools stay gone for the rest of the process. */ -async function releaseKey(loaded: { mcp?: Record } | undefined): Promise { - await mcp().remove(DATAMATE_KEY) +async function releaseKey(loaded: { mcp?: Record } | undefined, hadEngine: boolean): Promise { + if (hadEngine) await mcp().remove(DATAMATE_KEY) const restored = loaded?.mcp?.[DATAMATE_KEY] if (isMcpEntry(restored) && restored.enabled !== false) { log.info("workspace engine released the datamate key; starting the configured entry", { type: restored.type }) @@ -300,7 +300,9 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS await config().invalidate() loaded = await config().get() } - if (state.applied?.entry) await releaseKey(loaded) + // Whether the overlay had an engine running or had refused one (and so had + // removed the key from the config it shadowed), the key is handed back. + if (state.applied) await releaseKey(loaded, !!state.applied.entry) state.applied = null record(sessionID, { kind: "unbound" }) return @@ -322,7 +324,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS const overlayNow = state.current if (!overlayNow) { - if (state.applied?.entry) await releaseKey(loaded) + if (state.applied) await releaseKey(loaded, !!state.applied.entry) state.applied = null record(sessionID, { kind: "unbound" }) return diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index aefb0b8b96..709b145e15 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -459,6 +459,21 @@ describe("beforeTurn — what a turn boundary does", () => { expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) }) + test("an unlink after a refused overlay still hands the key back to the restored entry", async () => { + // The overlay had removed the user's hosted entry (no engine, no fallback); + // once unbound there is nothing to remove but the restored entry must start. + const h = install({ which: null, mcp: { datamate: HOSTED_ENTRY } }) + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("engine-missing") + expect(h.config.mcp!.datamate).toBeUndefined() + h.binding = null + await beforeTurn("s1") + expect(h.removes).toBe(0) + expect(h.added).toEqual([HOSTED_ENTRY]) + expect(h.config.mcp!.datamate).toEqual(HOSTED_ENTRY) + expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) + }) + test("an unlink does not start an entry the user had disabled", async () => { const h = install({ mcp: { datamate: { ...HOSTED_ENTRY, enabled: false } } }) await beforeTurn("s1") From d0ebb1bdaabf6196eda7e6e9a12c8a35be75a8b2 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 03:58:54 +0800 Subject: [PATCH 07/13] fix(workspace): fix a turn's engine for the whole turn The directory lock now spans the turn's first tool catalog, so another session's boundary cannot replace the engine between this session's reconcile and its snapshot; and the engine tools catalogued at step 1 are kept for the turn's later steps, so a mid-turn replacement is never re-catalogued. A call after such a replacement fails on the closed client rather than routing to the other workspace. --- .../src/altimate/workspace/engine-overlay.ts | 59 +++++++++++++--- packages/opencode/src/session/prompt.ts | 50 +++++++------- .../altimate/workspace/engine-overlay.test.ts | 67 +++++++++++++++++++ 3 files changed, 144 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 01f2261e31..d115394cb2 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -32,6 +32,7 @@ import { ENGINE_BINARY, INSTALL_COMMAND, REPAIRABLE, + TOOL_PREFIX, clearsFloor, describeMissing, describeRefusal, @@ -266,23 +267,64 @@ async function declaredFor(workspaceId: string): Promise { /** Reconcile, settle and announce for one session. Runs at the start of every * user turn, before the tool list is resolved. Never throws. */ export async function beforeTurn(sessionID: string): Promise { + await atTurnStart(sessionID, async () => undefined) +} + +/** Run the turn boundary and then `body` — the turn's tool cataloguing — under + * the directory's lock, so no other session's boundary can replace the engine + * between this session's reconcile and its catalog snapshot. The hook's own + * failures are logged and swallowed; `body`'s propagate. */ +export async function atTurnStart(sessionID: string, body: () => Promise): Promise { if (!isEnabled() || isServe()) { record(sessionID, { kind: "disabled" }) - return + return body() } const directory = currentDirectory() if (!directory) { record(sessionID, { kind: "unbound" }) - return + return body() } const state = stateFor(directory) - const run = state.chain.then(() => reconcile(sessionID, directory, state)) - state.chain = run.catch(() => undefined) - try { - await run - } catch (err) { - log.warn("workspace engine turn hook failed", { sessionID, err: String(err) }) + const run = state.chain.then(async () => { + try { + await reconcile(sessionID, directory, state) + } catch (err) { + log.warn("workspace engine turn hook failed", { sessionID, err: String(err) }) + } + return body() + }) + state.chain = run.then( + () => undefined, + () => undefined, + ) + return run +} + +/** The engine tools a turn catalogued at its first step, kept for its later + * steps. `resolveTools` re-snapshots MCP on every step, so a re-link applied by + * another session's boundary mid-turn would otherwise be re-catalogued here; + * pinning keeps this turn on the engine its boundary read. A call through a + * pinned wrapper after a replacement reaches the closed client and fails — it + * never routes to the other workspace. */ +const turnTools = new Map>() + +export function pinTurnTools(sessionID: string, step: number, tools: Record): void { + if (!isEnabled() || isServe()) return + const engine = Object.fromEntries(Object.entries(tools).filter(([key]) => key.startsWith(TOOL_PREFIX))) + if (step === 1) { + turnTools.delete(sessionID) + turnTools.set(sessionID, engine) + while (turnTools.size > MAX_TRACKED_SESSIONS) { + const oldest = turnTools.keys().next().value + if (oldest === undefined) break + turnTools.delete(oldest) + } + return } + const pinned = turnTools.get(sessionID) + if (!pinned) return + for (const key of Object.keys(engine)) delete tools[key] + for (const [key, tool] of Object.entries(pinned)) tools[key] = tool as T } async function reconcile(sessionID: string, directory: string, state: DirectoryState): Promise { @@ -460,6 +502,7 @@ export function resetForTests(): void { directories.clear() probeMemo = null sessions.clear() + turnTools.clear() declaredCache.clear() } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e8894b01fd..fe2ad67057 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1009,36 +1009,38 @@ export namespace SessionPrompt { const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false - // altimate_change start — workspace engine turn boundary. Reconciles the bound - // workspace's engine (re-link, one retry on a failed handshake), settles this - // session's outcome and announces it once per verdict. Runs before - // `resolveTools` so the engine's tools are in this turn's tool list; the cold - // engine boot happens inside MCP's own bootstrap, bounded by its per-server - // timeout. Every user turn starts at step 1 (see the note below on `step`). - if (step === 1) await WorkspaceEngine.beforeTurn(sessionID) - // altimate_change end - // altimate_change start (AI-7519) — trace resolveTools per step. // Included in the parent `bootstrap` span on step===1; on later steps // this measures the per-turn tool-listing overhead (MCP.tools connect // cost etc.). Distinct span name per phase so telemetry doesn't // double-count non-bootstrap turns under "bootstrap.*", and the TUI // falls back to the safe "Thinking..." label on later turns. - const tools = await traceSpan( - step === 1 ? "bootstrap.resolve-tools" : "turn.resolve-tools", - () => - resolveTools({ - agent, - session, - model, - tools: lastUser.tools, - processor, - bypassAgentCheck, - messages: msgs, - }), - { step, agent: agent.name }, - sessionID, - ) + const catalog = () => + traceSpan( + step === 1 ? "bootstrap.resolve-tools" : "turn.resolve-tools", + () => + resolveTools({ + agent, + session, + model, + tools: lastUser.tools, + processor, + bypassAgentCheck, + messages: msgs, + }), + { step, agent: agent.name }, + sessionID, + ) + // Workspace engine turn boundary (step 1): reconcile the bound workspace's + // engine (re-link, one retry on a failed handshake), settle this session's + // outcome, announce it once per verdict — then catalog the tools under the + // same per-directory lock, so another session's boundary cannot replace the + // engine between this reconcile and this snapshot. The cold engine boot + // happens inside MCP's own bootstrap, bounded by its per-server timeout. + // Every user turn starts at step 1 (see the note below on `step`). Later + // steps re-catalog but keep the engine tools this turn started with. + const tools = step === 1 ? await WorkspaceEngine.atTurnStart(sessionID, catalog) : await catalog() + WorkspaceEngine.pinTurnTools(sessionID, step, tools) // altimate_change end // Inject StructuredOutput tool if JSON schema mode enabled diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 709b145e15..8683f7e66a 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -8,8 +8,10 @@ import { FAILED_PROBE_TTL_MS, INSTALL_COMMAND, MAX_TRACKED_SESSIONS, + atTurnStart, beforeTurn, invalidateProbe, + pinTurnTools, managedWorkspace, overlay, overlayForTests, @@ -551,6 +553,71 @@ describe("beforeTurn — what a turn boundary does", () => { expect(managedWorkspace()?.id).toBe("7") }) + test("the lock is held through the turn's catalog, so another boundary waits for the snapshot", async () => { + const h = install({}) + let releaseCatalog: () => void = () => {} + const gate = new Promise((resolve) => { + releaseCatalog = resolve + }) + let catalogued = "" + const a = atTurnStart("A", async () => { + await gate + catalogued = managedWorkspace()?.id ?? "none" + return "A-catalog" + }) + await new Promise((r) => setTimeout(r, 5)) + h.binding = bound(7, "growth") + const b = beforeTurn("B") + await new Promise((r) => setTimeout(r, 5)) + expect(h.added).toEqual([]) + releaseCatalog() + expect(await a).toBe("A-catalog") + await b + // A catalogued while its own workspace was still the one applied. + expect(catalogued).toBe("42") + expect(managedWorkspace()?.id).toBe("7") + }) + + test("a body failure propagates to the caller but does not wedge the directory's lock", async () => { + install({}) + await expect( + atTurnStart("A", async () => { + throw new Error("catalog exploded") + }), + ).rejects.toThrow("catalog exploded") + await expect(atTurnStart("B", async () => "ok")).resolves.toBe("ok") + }) + + test("a turn keeps the engine tools it catalogued at step 1 for its later steps", async () => { + install({}) + const first = { datamate_a: { id: "a1" }, sql_execute: { id: "sql" } } + pinTurnTools("s1", 1, first) + // Another session's boundary replaced the engine mid-turn: step 2 re-catalogs + // a different tool set under the same prefix. + const later: Record = { + datamate_b: { id: "b1" }, + datamate_a: { id: "a2" }, + sql_execute: { id: "sql" }, + } + pinTurnTools("s1", 2, later) + expect(later).toEqual({ sql_execute: { id: "sql" }, datamate_a: { id: "a1" } }) + // A new turn takes a fresh snapshot. + pinTurnTools("s1", 1, { datamate_b: { id: "b1" } }) + const step2: Record = {} + pinTurnTools("s1", 2, step2) + expect(step2).toEqual({ datamate_b: { id: "b1" } }) + }) + + test("pinning is a no-op with the flag off and for a session with no step-1 snapshot", async () => { + install({ flag: false }) + const tools: Record = { datamate_a: { id: "a1" } } + pinTurnTools("s1", 2, tools) + expect(tools).toEqual({ datamate_a: { id: "a1" } }) + install({}) + pinTurnTools("s9", 2, tools) + expect(tools).toEqual({ datamate_a: { id: "a1" } }) + }) + test("the turn hook never throws", async () => { install({}) syncInternals.config = { From 789500350f5506ad2795932f6c17ac0afa61ca8f Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 04:08:44 +0800 Subject: [PATCH 08/13] fix(workspace): run the turn boundary on the first catalog, not the first iteration `step` counts loop iterations, and an iteration can continue before cataloguing (a pending compaction, a context overflow), so a turn that started with a compaction never ran its boundary and catalogued the previous engine after a re-link. The boundary and the pin now key on the turn's first tool catalog. --- .../src/altimate/workspace/engine-overlay.ts | 8 +++--- packages/opencode/src/session/prompt.ts | 28 ++++++++++++------- .../altimate/workspace/engine-overlay.test.ts | 14 +++++----- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index d115394cb2..e6d14c4d9d 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -300,18 +300,18 @@ export async function atTurnStart(sessionID: string, body: () => Promise): return run } -/** The engine tools a turn catalogued at its first step, kept for its later - * steps. `resolveTools` re-snapshots MCP on every step, so a re-link applied by +/** The engine tools a turn catalogued first, kept for its later catalogs. + * `resolveTools` re-snapshots MCP on every step, so a re-link applied by * another session's boundary mid-turn would otherwise be re-catalogued here; * pinning keeps this turn on the engine its boundary read. A call through a * pinned wrapper after a replacement reaches the closed client and fails — it * never routes to the other workspace. */ const turnTools = new Map>() -export function pinTurnTools(sessionID: string, step: number, tools: Record): void { +export function pinTurnTools(sessionID: string, firstCatalog: boolean, tools: Record): void { if (!isEnabled() || isServe()) return const engine = Object.fromEntries(Object.entries(tools).filter(([key]) => key.startsWith(TOOL_PREFIX))) - if (step === 1) { + if (firstCatalog) { turnTools.delete(sessionID) turnTools.set(sessionID, engine) while (turnTools.size > MAX_TRACKED_SESSIONS) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fe2ad67057..568a16c636 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -411,6 +411,11 @@ export namespace SessionPrompt { let structuredOutput: unknown | undefined let step = 0 + // altimate_change start — first tool catalog of this loop. `step` counts loop + // iterations, and an iteration can `continue` before cataloguing (pending + // compaction, context overflow), so "step === 1" is not "first catalog". + let catalogued = false + // altimate_change end // altimate_change start (AI-7519) — capture bootstrap start; emitted as a // single "bootstrap" span right before the first processor.process call so // the pre-first-generation region has a visible parent duration in traces. @@ -1031,16 +1036,19 @@ export namespace SessionPrompt { { step, agent: agent.name }, sessionID, ) - // Workspace engine turn boundary (step 1): reconcile the bound workspace's - // engine (re-link, one retry on a failed handshake), settle this session's - // outcome, announce it once per verdict — then catalog the tools under the - // same per-directory lock, so another session's boundary cannot replace the - // engine between this reconcile and this snapshot. The cold engine boot - // happens inside MCP's own bootstrap, bounded by its per-server timeout. - // Every user turn starts at step 1 (see the note below on `step`). Later - // steps re-catalog but keep the engine tools this turn started with. - const tools = step === 1 ? await WorkspaceEngine.atTurnStart(sessionID, catalog) : await catalog() - WorkspaceEngine.pinTurnTools(sessionID, step, tools) + // Workspace engine turn boundary, on the turn's FIRST catalog (not its first + // loop iteration — a compaction can run before any catalog): reconcile the + // bound workspace's engine (re-link, one retry on a failed handshake), settle + // this session's outcome, announce it once per verdict — then catalog the + // tools under the same per-directory lock, so another session's boundary + // cannot replace the engine between this reconcile and this snapshot. The + // cold engine boot happens inside MCP's own bootstrap, bounded by its + // per-server timeout. Later catalogs keep the engine tools this turn started + // with. + const firstCatalog = !catalogued + catalogued = true + const tools = firstCatalog ? await WorkspaceEngine.atTurnStart(sessionID, catalog) : await catalog() + WorkspaceEngine.pinTurnTools(sessionID, firstCatalog, tools) // altimate_change end // Inject StructuredOutput tool if JSON schema mode enabled diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 8683f7e66a..67d33c28f9 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -588,10 +588,10 @@ describe("beforeTurn — what a turn boundary does", () => { await expect(atTurnStart("B", async () => "ok")).resolves.toBe("ok") }) - test("a turn keeps the engine tools it catalogued at step 1 for its later steps", async () => { + test("a turn keeps the engine tools it catalogued first for its later catalogs", async () => { install({}) const first = { datamate_a: { id: "a1" }, sql_execute: { id: "sql" } } - pinTurnTools("s1", 1, first) + pinTurnTools("s1", true, first) // Another session's boundary replaced the engine mid-turn: step 2 re-catalogs // a different tool set under the same prefix. const later: Record = { @@ -599,22 +599,22 @@ describe("beforeTurn — what a turn boundary does", () => { datamate_a: { id: "a2" }, sql_execute: { id: "sql" }, } - pinTurnTools("s1", 2, later) + pinTurnTools("s1", false, later) expect(later).toEqual({ sql_execute: { id: "sql" }, datamate_a: { id: "a1" } }) // A new turn takes a fresh snapshot. - pinTurnTools("s1", 1, { datamate_b: { id: "b1" } }) + pinTurnTools("s1", true, { datamate_b: { id: "b1" } }) const step2: Record = {} - pinTurnTools("s1", 2, step2) + pinTurnTools("s1", false, step2) expect(step2).toEqual({ datamate_b: { id: "b1" } }) }) test("pinning is a no-op with the flag off and for a session with no step-1 snapshot", async () => { install({ flag: false }) const tools: Record = { datamate_a: { id: "a1" } } - pinTurnTools("s1", 2, tools) + pinTurnTools("s1", false, tools) expect(tools).toEqual({ datamate_a: { id: "a1" } }) install({}) - pinTurnTools("s9", 2, tools) + pinTurnTools("s9", false, tools) expect(tools).toEqual({ datamate_a: { id: "a1" } }) }) From 72dcae15ced7ba9952914e5613273c0ec607697d Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 07:09:20 +0800 Subject: [PATCH 09/13] fix(workspace): close the review findings on the engine overlay Writers of the `datamate` key load config before asking who owns it; `remove` and `create` refuse the managed key like `add`; the SDK route answers a declared 409. A client that predates a mid-session link is removed when the overlay refuses. A failed overlay retries at the probe TTL. `--version` settles on exit with a deadline. A managed-preferences key is left alone. `ALTIMATE_CODE_HEADLESS` is scrubbed from bash-tool children. --- .../opencode/src/altimate/tools/datamate.ts | 37 ++++++++++++- .../src/altimate/workspace/engine-overlay.ts | 42 ++++++++++++-- .../src/altimate/workspace/engine-probes.ts | 32 +++++++++-- packages/opencode/src/config/config.ts | 31 ++++++++--- .../routes/instance/httpapi/groups/mcp.ts | 11 +++- .../routes/instance/httpapi/handlers/mcp.ts | 13 ++++- packages/opencode/src/server/routes/mcp.ts | 4 +- packages/opencode/src/server/server.ts | 4 +- packages/opencode/src/tool/bash.ts | 5 ++ .../altimate/workspace/engine-overlay.test.ts | 55 ++++++++++++++++++- .../altimate/workspace/engine-probes.test.ts | 35 ++++++++++++ 11 files changed, 241 insertions(+), 28 deletions(-) create mode 100644 packages/opencode/test/altimate/workspace/engine-probes.test.ts diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 945a3c7aea..9f38248fb4 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -14,7 +14,7 @@ import { Global } from "../../global" import { Log } from "@/altimate/util/log" import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" // altimate_change - workspace mode owns the datamate key -import { managedWorkspace } from "../workspace/engine-overlay" +import { managedWorkspaceLoaded } from "../workspace/engine-overlay" const log = Log.create({ service: "datamate" }) @@ -220,7 +220,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p // bound workspace's own engine, derived at config load. Adding a datamate // under that key would replace it; refuse and say why. Standalone // `datamate-` entries are a different key and stay the user's. - const managed = serverName === DATAMATE_KEY ? managedWorkspace() : null + const managed = serverName === DATAMATE_KEY ? await managedWorkspaceLoaded() : null if (managed) { return { title: `Datamate add: '${DATAMATE_KEY}' is managed by workspace "${managed.name}"`, @@ -356,6 +356,23 @@ async function handleCreate(args: { output: "Missing required parameter 'name'.", } } + // altimate_change start — with an IDE transport the add that follows would go + // under the shared `datamate` key; in workspace mode that add is refused, so + // refuse here before creating an API datamate nothing would connect to. + if ((await readDatamateTransportFromIde(projectRoot())) !== null) { + const managedKey = await managedWorkspaceLoaded() + if (managedKey) { + return { + title: `Datamate create: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`, + metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id }, + output: + `This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` + + `workspace's own engine under the '${DATAMATE_KEY}' MCP server. Creating datamate '${args.name}' ` + + `here would not connect it. Unlink the project, or run without ALTIMATE_WORKSPACE, first.`, + } + } + } + // altimate_change end try { const integrations = args.integration_ids ? await AltimateApi.resolveIntegrations(args.integration_ids) @@ -518,6 +535,22 @@ async function handleRemove(args: { server_name?: string; scope?: "project" | "g "Missing required parameter 'server_name'. Use 'status' to see active servers or 'list-config' to see saved configs.", } } + // altimate_change start — the workspace-managed `datamate` key is not the + // user's to remove either: it would stop the engine under a turn and delete + // the entry that unlinking hands back. Standalone `datamate-` entries + // are unaffected. + const managedKey = args.server_name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null + if (managedKey) { + return { + title: `Datamate remove: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`, + metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id }, + output: + `This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` + + `workspace's own engine under the '${DATAMATE_KEY}' MCP server. It is not removed. Unlink the project, ` + + `or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`, + } + } + // altimate_change end try { // Fully remove from runtime state (disconnect + purge from MCP list) // altimate_change start — MCP.remove (was disconnect): delete the status entry + publish diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index e6d14c4d9d..8bf5fb1f37 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -121,6 +121,9 @@ type DirectoryState = { * invalidate and reload config between turns, re-running the overlay without * touching MCP. */ applied: Overlay | null | undefined + /** When the last overlay attempt threw. A failed attempt is retried at the + * probe TTL, not on every turn — each retry invalidates the whole config. */ + failedAt?: number } const directories = new Map() @@ -142,13 +145,26 @@ function sameEntry(a: LocalMcpConfig | null, b: LocalMcpConfig | null): boolean * Called from the config loader after external MCP discovery, so it has the * last word over every other source of the key. Mutates `config.mcp` only when * the directory is bound with the pilot on. Never throws. */ -export async function overlay(directory: string, config: { mcp?: Record }): Promise { +export async function overlay( + directory: string, + config: { mcp?: Record }, + opts: { managed?: boolean } = {}, +): Promise { const state = stateFor(directory) + state.failedAt = undefined try { if (!isEnabled() || isServe()) { state.current = null return } + if (opts.managed) { + // Organisation-managed config (MDM) is authoritative over everything, + // this overlay included: the key stays as managed, and nothing here + // claims it, so its writers are not refused either. + log.info("workspace engine overlay skipped: the datamate key is set by managed preferences", { directory }) + state.current = null + return + } const binding = await resolveBinding(directory) if (!binding) { // Logged because "flag on, nothing happened" is the question every @@ -182,6 +198,7 @@ export async function overlay(directory: string, config: { mcp?: Record { + if (!directory) return null + await config().get() + return managedWorkspace(directory) +} + // ── per-session outcome ───────────────────────────────────────────────────── /** `retried`: this session already spent its one re-add on a failed handshake. @@ -353,7 +381,9 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // Reload the overlay when the binding moved, or when a refused engine may // have appeared since (the probe memo bounds how often that is asked). - let reload = !state.current || state.current.workspace.id !== workspaceId + let reload = state.current + ? state.current.workspace.id !== workspaceId + : state.failedAt === undefined || now() - state.failedAt >= FAILED_PROBE_TTL_MS if (!reload && state.current && !state.current.entry) { const probe = await probeEngine() reload = probe.kind === "ok" @@ -377,7 +407,10 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // derived entry changed, drop it when there is none any more. if (overlayNow.entry) { if (!sameEntry(state.applied?.entry ?? null, overlayNow.entry)) await mcp().add(DATAMATE_KEY, overlayNow.entry) - } else if (state.applied?.entry) { + } else if (state.applied?.entry || DATAMATE_KEY in (await mcp().status())) { + // Ours to drop — or a client that predates the link, which MCP bootstrapped + // from an IDE or hosted entry while the directory was unbound. With the + // overlay refusing, nothing may serve the workspace under the key. await mcp().remove(DATAMATE_KEY) } state.applied = overlayNow @@ -480,7 +513,8 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS export async function announceRefusal(sessionID: string, outcome: Outcome, toast: Toast): Promise { const rec = sessions.get(sessionID) ?? record(sessionID, outcome) const detail = "error" in outcome ? outcome.error : "found" in outcome ? String(outcome.found) : "" - const signature = `${outcome.kind}:${detail}:${toast.title}` + const declared = "declared" in outcome ? String(outcome.declared ?? "?") : "" + const signature = `${outcome.kind}:${detail}:${declared}:${toast.title}` if (rec.announced === signature) return rec.announced = signature if (isHeadless()) { diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index d3c5ab7651..94fcb37a95 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -35,26 +35,48 @@ export function which(cmd: string): string | null { * * cross-spawn, not execFile: an npm-installed engine on Windows resolves to a * `.cmd` shim that Node cannot execute without a shell. */ +/** How long `--version` may take before the engine counts as unreadable. */ +export const VERSION_TIMEOUT_MS = 5_000 + export function versionOf(bin: string): Promise { if (syncInternals.versionOf) return syncInternals.versionOf(bin) return new Promise((resolve) => { let settled = false + let timer: ReturnType | undefined const done = (value: string | null) => { if (settled) return settled = true + if (timer) clearTimeout(timer) resolve(value) } try { - const child = launch(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }) + const child = launch(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] }) let out = "" child.stdout?.on("data", (chunk) => { out += String(chunk) }) + // Settle on `exit`, not `close`: a descendant that inherited stdout would + // keep `close` from firing after the engine itself has answered. The + // deadline is ours as well — the runtime's `timeout` only signals the + // direct child, so it could not end a wait on a straggler's pipe. + timer = setTimeout(() => { + try { + child.kill("SIGKILL") + } catch { + // Already gone. + } + child.stdout?.destroy() + done(null) + }, VERSION_TIMEOUT_MS) child.on("error", () => done(null)) - child.on("close", (code) => { - if (code !== 0) return done(null) - const line = out.trim().split(/\r?\n/)[0] ?? "" - done(line || null) + child.on("exit", (code) => { + // Let any bytes still in flight land before reading `out`. + setImmediate(() => { + child.stdout?.destroy() + if (code !== 0) return done(null) + const line = out.trim().split(/\r?\n/)[0] ?? "" + done(line || null) + }) }) } catch { done(null) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8355463e4b..6e59e55901 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -33,6 +33,9 @@ import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigManaged } from "./managed" +// altimate_change start — the workspace engine overlay yields to managed config for this key +import { DATAMATE_KEY } from "../altimate/datamate-transport" +// altimate_change end import { ConfigParse } from "./parse" import { ConfigPaths } from "./paths" import { ConfigPlugin } from "./plugin" @@ -641,6 +644,9 @@ export const layer = Layer.effect( ) } + // altimate_change start — whether organisation-managed config sets the datamate MCP key + let managedOwnsDatamate = false + // altimate_change end const managedDir = ConfigManaged.managedConfigDir() if (existsSync(managedDir)) { // altimate_change start - support altimate-code.json config filename @@ -654,20 +660,25 @@ export const layer = Layer.effect( ]) { // altimate_change end const source = path.join(managedDir, file) - yield* merge(source, yield* loadFile(source), "global") + // altimate_change start — note a managed datamate key before merging + const managedFile = yield* loadFile(source) + if (managedFile?.mcp && DATAMATE_KEY in managedFile.mcp) managedOwnsDatamate = true + yield* merge(source, managedFile, "global") + // altimate_change end } } // macOS managed preferences (.mobileconfig deployed via MDM) override everything const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) if (managed) { - result = mergeConfigConcatArrays( - result, - yield* loadConfig(managed.text, { - dir: path.dirname(managed.source), - source: managed.source, - }), - ) + // altimate_change start — note a managed datamate key before merging + const managedPrefs = yield* loadConfig(managed.text, { + dir: path.dirname(managed.source), + source: managed.source, + }) + if (managedPrefs.mcp && DATAMATE_KEY in managedPrefs.mcp) managedOwnsDatamate = true + result = mergeConfigConcatArrays(result, managedPrefs) + // altimate_change end } for (const [name, mode] of Object.entries(result.mode ?? {})) { @@ -747,7 +758,9 @@ export const layer = Layer.effect( // to any file. See altimate/workspace/engine-overlay.ts. if (Flag.ALTIMATE_WORKSPACE) { const { overlay } = yield* Effect.promise(() => import("../altimate/workspace/engine-overlay")) - yield* Effect.promise(() => overlay(ctx.directory, result as { mcp?: Record })) + yield* Effect.promise(() => + overlay(ctx.directory, result as { mcp?: Record }, { managed: managedOwnsDatamate }), + ) } // altimate_change end diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index a6fb064d73..cdd4331a5e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -28,6 +28,13 @@ export class UnsupportedOAuthError extends Schema.ErrorClass("McpServerManagedError")( + { error: Schema.String }, + { httpApiStatus: 409 }, +) {} +// altimate_change end export const McpPaths = { status: "/mcp", @@ -56,7 +63,9 @@ export const McpApi = HttpApi.make("mcp") query: WorkspaceRoutingQuery, payload: AddPayload, success: described(StatusMap, "MCP server added successfully"), - error: HttpApiError.BadRequest, + // altimate_change start — the workspace-managed refusal is a declared error + error: [HttpApiError.BadRequest, McpServerManagedError], + // altimate_change end }).annotateMerge( OpenApi.annotations({ identifier: "mcp.add", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index 1d883a5bd5..1aecdaebdd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -6,13 +6,18 @@ import { McpServerNotFoundError } from "../errors" import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp" // altimate_change start — workspace mode owns the datamate key import { InstanceState } from "@/effect/instance-state" +import { Config } from "@/config/config" import { DATAMATE_KEY } from "@/altimate/datamate-transport" import { managedWorkspace } from "@/altimate/workspace/engine-overlay" +import { McpServerManagedError } from "../groups/mcp" // altimate_change end export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) => Effect.gen(function* () { const mcp = yield* MCP.Service + // altimate_change start — config is loaded before the managed-key check + const configSvc = yield* Config.Service + // altimate_change end const status = Effect.fn("McpHttpApi.status")(function* () { return yield* mcp.status() @@ -23,14 +28,18 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler // workspace's own engine, derived at config load; adding over it would replace // the engine underneath a turn. Refuse and say why. if (ctx.payload.name === DATAMATE_KEY) { + // The overlay runs inside config load; on a fresh instance this can be + // the first request, so load before asking who owns the key. + yield* configSvc.get() const managed = managedWorkspace(yield* InstanceState.directory) if (managed) { - // BadRequest carries no body on this endpoint; the reason is logged. yield* Effect.logWarning("mcp add refused: key is managed by a workspace", { name: DATAMATE_KEY, workspace: managed.id, }) - return yield* new HttpApiError.BadRequest({}) + return yield* new McpServerManagedError({ + error: `MCP server "${DATAMATE_KEY}" is managed by workspace "${managed.name}" in this project`, + }) } } // altimate_change end diff --git a/packages/opencode/src/server/routes/mcp.ts b/packages/opencode/src/server/routes/mcp.ts index bf31584ee5..c65b743c36 100644 --- a/packages/opencode/src/server/routes/mcp.ts +++ b/packages/opencode/src/server/routes/mcp.ts @@ -4,7 +4,7 @@ import z from "zod" import { MCP } from "../../mcp" // altimate_change start — workspace mode owns the datamate key import { DATAMATE_KEY } from "../../altimate/datamate-transport" -import { managedWorkspace } from "../../altimate/workspace/engine-overlay" +import { managedWorkspaceLoaded } from "../../altimate/workspace/engine-overlay" // altimate_change end // altimate_change start — Config.Mcp + MCP.Status migrated to Effect Schema in v1.17.9; convert to zod for HTTP schemas import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" @@ -64,7 +64,7 @@ export const McpRoutes = lazy(() => async (c) => { const { name, config } = c.req.valid("json") // altimate_change start — workspace mode owns the `datamate` key - const managed = name === DATAMATE_KEY ? managedWorkspace() : null + const managed = name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null if (managed) { return c.json( { error: `MCP server "${name}" is managed by workspace "${managed.name}" in this project` }, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 44c2794826..d8c9caf23a 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" // altimate_change - workspace mode owns the datamate key -import { managedWorkspace } from "../altimate/workspace/engine-overlay" +import { managedWorkspaceLoaded } from "../altimate/workspace/engine-overlay" import { readMcpEntryFromDisk } from "../mcp/config" import { resolveConfigPath } from "../mcp/config" import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt" @@ -689,7 +689,7 @@ export namespace Server { const directory = Instance.directory // In workspace mode the `datamate` key is the bound workspace's own engine, // derived at config load; an IDE reload must not replace it under a turn. - const managed = managedWorkspace() + const managed = await managedWorkspaceLoaded() if (managed) { log.info("reload-datamate: refused, key is managed by a workspace", { workspace: managed.id }) return c.json( diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 4773f11ec6..11d6ee9a5e 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -176,6 +176,11 @@ export const BashTool = Tool.define("bash", async () => { // process.env spread above would silently disable that path in every // nested server invocation. See PR #937 review (Issue #3). delete mergedEnv["ALTIMATE_NON_INTERACTIVE"] + // Same reasoning for the headless marker: `run` sets it so the workspace + // engine's refusals degrade to a printed line, but a nested entrypoint + // launched from here may well have a TUI. Left in place, the child would + // inherit "headless" and print to stderr instead of showing its surface. + delete mergedEnv["ALTIMATE_CODE_HEADLESS"] // altimate_change end const sep = process.platform === "win32" ? ";" : ":" const basePath = mergedEnv.PATH ?? mergedEnv.Path ?? "" diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 67d33c28f9..37d43a9c29 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -26,6 +26,7 @@ import { type Toast, } from "../../../src/altimate/workspace/engine-overlay" import type { CachedBinding } from "../../../src/altimate/workspace/state" +import { DATAMATE_KEY } from "../../../src/altimate/datamate-transport" const DIR = "/tmp/analytics" const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE @@ -50,6 +51,9 @@ type Harness = { toasts: Toast[] lines: string[] clock: number + /** Whether MCP holds a client under the key — set when MCP "bootstraps" from + * the first config load, then tracked through add/remove, as in the runtime. */ + live?: boolean } function install(opts: { @@ -107,12 +111,15 @@ function install(opts: { } syncInternals.now = () => h.clock syncInternals.mcp = { - status: async () => ({ datamate: { status: h.status, ...(h.statusError ? { error: h.statusError } : {}) } }), + status: async () => + h.live ? { datamate: { status: h.status, ...(h.statusError ? { error: h.statusError } : {}) } } : {}, add: async (_name, cfg) => { + h.live = true h.added.push(cfg) h.onAdd?.() }, remove: async () => { + h.live = false h.removes += 1 }, tools: async () => h.tools, @@ -132,6 +139,9 @@ function install(opts: { if (loaded) return h.config h.config = opts.noMcpKey ? {} : { mcp: structuredClone(initialMcp ?? {}) } await overlay(DIR, h.config) + // MCP bootstraps from the config as first loaded — after the overlay had + // its say — and keeps whatever client that started until told otherwise. + if (h.live === undefined) h.live = DATAMATE_KEY in (h.config.mcp ?? {}) loaded = true return h.config }, @@ -446,6 +456,49 @@ describe("beforeTurn — what a turn boundary does", () => { expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) }) + test("a client that predates a mid-session link is removed when the overlay refuses", async () => { + // Unbound at boot with an IDE entry, so MCP bootstrapped that client. The + // directory is then linked with no engine on PATH: the overlay refuses, and + // the pre-link client must not go on serving the workspace under the key. + const h = install({ binding: null, mcp: { datamate: IDE_ENTRY }, which: null }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ kind: "unbound" }) + expect(h.removes).toBe(0) + h.binding = bound(42) + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("engine-missing") + expect(h.removes).toBe(1) + expect(h.config.mcp!.datamate).toBeUndefined() + await beforeTurn("s1") + expect(h.removes).toBe(1) + }) + + test("an overlay that threw is retried at the probe TTL, not on every turn", async () => { + // Each retry invalidates the whole config cache; a persistent fault must + // not turn every turn boundary into a full config reload. + const h = install({}) + syncInternals.which = () => { + throw new Error("PATH unreadable") + } + await beforeTurn("s1") + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.invalidates).toBe(0) + h.clock += FAILED_PROBE_TTL_MS + await beforeTurn("s1") + expect(h.invalidates).toBe(1) + await beforeTurn("s1") + expect(h.invalidates).toBe(1) + }) + + test("a datamate key set by managed preferences is left alone and is not managed here", async () => { + const h = install({ mcp: { datamate: IDE_ENTRY } }) + await overlay(DIR, h.config, { managed: true }) + expect(h.config.mcp).toEqual({ datamate: IDE_ENTRY }) + expect(managedWorkspace()).toBeNull() + expect(h.probes).toBe(0) + }) + test("an unlink hands the key back to the entry the reloaded config restores", async () => { // The overlay had shadowed the user's own hosted entry; once unbound, config // reloads with that entry and MCP must be told to start it, because MCP only diff --git a/packages/opencode/test/altimate/workspace/engine-probes.test.ts b/packages/opencode/test/altimate/workspace/engine-probes.test.ts new file mode 100644 index 0000000000..c5871ca89e --- /dev/null +++ b/packages/opencode/test/altimate/workspace/engine-probes.test.ts @@ -0,0 +1,35 @@ +// altimate_change - new file +// +// The engine probes against real processes: `versionOf` must settle on the +// engine's own exit, never wait on a descendant that inherited its stdout. +import { describe, expect, test } from "bun:test" +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { versionOf } from "../../../src/altimate/workspace/engine-probes" + +const posix = process.platform !== "win32" + +function fakeEngine(script: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "engine-probe-")) + const bin = path.join(dir, "datamate") + writeFileSync(bin, `#!/bin/sh\n${script}\n`) + chmodSync(bin, 0o755) + return bin +} + +describe("versionOf", () => { + test.skipIf(!posix)("reads the version even when a descendant keeps stdout open", async () => { + const bin = fakeEngine('echo "0.7.0"; sleep 5 & exit 0') + const t0 = Date.now() + expect(await versionOf(bin)).toBe("0.7.0") + expect(Date.now() - t0).toBeLessThan(2_000) + }) + test.skipIf(!posix)("a non-zero exit is unreadable", async () => { + const bin = fakeEngine('echo "0.7.0"; exit 3') + expect(await versionOf(bin)).toBeNull() + }) + test("a binary that cannot be spawned is unreadable", async () => { + expect(await versionOf(path.join(os.tmpdir(), "definitely-not-here-" + process.pid))).toBeNull() + }) +}) From b2e5c6aaaed6ee4cf126be30100a2fcef44f7907 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 07:36:24 +0800 Subject: [PATCH 10/13] fix(workspace): guard connect/disconnect; keep the engine through a transient fault The SDK and HTTP connect/disconnect routes refuse the workspace-managed key before `MCP` would restart or close it and persist `enabled`. The add guard runs before the API lookup; create/remove preflights sit inside their error boundaries. A managed-preferences key settles as disabled with no per-turn reload; an overlay that throws after attach keeps the running engine. The headless env scrub is tested. --- .../opencode/src/altimate/tools/datamate.ts | 102 +++++++++--------- .../src/altimate/workspace/engine-overlay.ts | 16 ++- .../routes/instance/httpapi/groups/mcp.ts | 8 +- .../routes/instance/httpapi/handlers/mcp.ts | 24 +++++ packages/opencode/src/server/routes/mcp.ts | 18 ++++ .../altimate/workspace/engine-overlay.test.ts | 38 ++++++- .../release-validation/question-937.test.ts | 80 +++++++++++++- 7 files changed, 225 insertions(+), 61 deletions(-) diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 9f38248fb4..849c99f0eb 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -191,11 +191,30 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p } } try { - const datamate = await AltimateApi.getDatamate(args.datamate_id) // readDatamateTransportFromIde returns the exact command from the IDE config so we // reuse the same process the extension already manages, not a second one. const transport = await readDatamateTransportFromIde(projectRoot()) + // altimate_change start — in workspace mode the shared `datamate` key is the + // bound workspace's own engine, derived at config load. With an IDE transport + // the add would go under that key; refuse and say why, before anything is + // looked up — the refusal must not depend on the API being reachable. + // Standalone `datamate-` entries are a different key and stay the user's. + const managed = transport !== null ? await managedWorkspaceLoaded() : null + if (managed) { + return { + title: `Datamate add: '${DATAMATE_KEY}' is managed by workspace "${managed.name}"`, + metadata: { serverName: DATAMATE_KEY, managedBy: managed.id, datamateId: args.datamate_id }, + output: + `This project is linked to workspace "${managed.name}", whose integrations are served by the ` + + `workspace's own engine under the '${DATAMATE_KEY}' MCP server. Adding datamate '${args.datamate_id}' ` + + `there is not applied. Unlink the project, or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`, + } + } + // altimate_change end + + const datamate = await AltimateApi.getDatamate(args.datamate_id) + if (transport !== null) { log.info("handleAdd: IDE transport detected, entering single-gateway mode", { serverName: DATAMATE_KEY, @@ -216,23 +235,6 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p ? DATAMATE_KEY : (args.name ?? `datamate-${slugify(datamate.name)}`) - // altimate_change start — in workspace mode the shared `datamate` key is the - // bound workspace's own engine, derived at config load. Adding a datamate - // under that key would replace it; refuse and say why. Standalone - // `datamate-` entries are a different key and stay the user's. - const managed = serverName === DATAMATE_KEY ? await managedWorkspaceLoaded() : null - if (managed) { - return { - title: `Datamate add: '${DATAMATE_KEY}' is managed by workspace "${managed.name}"`, - metadata: { serverName: DATAMATE_KEY, managedBy: managed.id, datamateId: args.datamate_id }, - output: - `This project is linked to workspace "${managed.name}", whose integrations are served by the ` + - `workspace's own engine under the '${DATAMATE_KEY}' MCP server. Adding datamate '${datamate.name}' ` + - `there is not applied. Unlink the project, or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`, - } - } - // altimate_change end - const creds = transport ? undefined : await AltimateApi.getCredentials() const mcpConfig = transport?.type === "remote" @@ -356,24 +358,24 @@ async function handleCreate(args: { output: "Missing required parameter 'name'.", } } - // altimate_change start — with an IDE transport the add that follows would go - // under the shared `datamate` key; in workspace mode that add is refused, so - // refuse here before creating an API datamate nothing would connect to. - if ((await readDatamateTransportFromIde(projectRoot())) !== null) { - const managedKey = await managedWorkspaceLoaded() - if (managedKey) { - return { - title: `Datamate create: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`, - metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id }, - output: - `This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` + - `workspace's own engine under the '${DATAMATE_KEY}' MCP server. Creating datamate '${args.name}' ` + - `here would not connect it. Unlink the project, or run without ALTIMATE_WORKSPACE, first.`, + try { + // altimate_change start — with an IDE transport the add that follows would go + // under the shared `datamate` key; in workspace mode that add is refused, so + // refuse here before creating an API datamate nothing would connect to. + if ((await readDatamateTransportFromIde(projectRoot())) !== null) { + const managedKey = await managedWorkspaceLoaded() + if (managedKey) { + return { + title: `Datamate create: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`, + metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id }, + output: + `This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` + + `workspace's own engine under the '${DATAMATE_KEY}' MCP server. Creating datamate '${args.name}' ` + + `here would not connect it. Unlink the project, or run without ALTIMATE_WORKSPACE, first.`, + } } } - } - // altimate_change end - try { + // altimate_change end const integrations = args.integration_ids ? await AltimateApi.resolveIntegrations(args.integration_ids) : undefined @@ -535,23 +537,23 @@ async function handleRemove(args: { server_name?: string; scope?: "project" | "g "Missing required parameter 'server_name'. Use 'status' to see active servers or 'list-config' to see saved configs.", } } - // altimate_change start — the workspace-managed `datamate` key is not the - // user's to remove either: it would stop the engine under a turn and delete - // the entry that unlinking hands back. Standalone `datamate-` entries - // are unaffected. - const managedKey = args.server_name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null - if (managedKey) { - return { - title: `Datamate remove: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`, - metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id }, - output: - `This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` + - `workspace's own engine under the '${DATAMATE_KEY}' MCP server. It is not removed. Unlink the project, ` + - `or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`, - } - } - // altimate_change end try { + // altimate_change start — the workspace-managed `datamate` key is not the + // user's to remove either: it would stop the engine under a turn and delete + // the entry that unlinking hands back. Standalone `datamate-` entries + // are unaffected. + const managedKey = args.server_name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null + if (managedKey) { + return { + title: `Datamate remove: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`, + metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id }, + output: + `This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` + + `workspace's own engine under the '${DATAMATE_KEY}' MCP server. It is not removed. Unlink the project, ` + + `or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`, + } + } + // altimate_change end // Fully remove from runtime state (disconnect + purge from MCP list) // altimate_change start — MCP.remove (was disconnect): delete the status entry + publish // ToolsChanged so the removed server's tools stop being offered without a restart. diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 8bf5fb1f37..a9042a169a 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -124,6 +124,8 @@ type DirectoryState = { /** When the last overlay attempt threw. A failed attempt is retried at the * probe TTL, not on every turn — each retry invalidates the whole config. */ failedAt?: number + /** The key is set by organisation-managed config: nothing here claims it. */ + managed?: boolean } const directories = new Map() @@ -152,6 +154,7 @@ export async function overlay( ): Promise { const state = stateFor(directory) state.failedAt = undefined + state.managed = opts.managed === true try { if (!isEnabled() || isServe()) { state.current = null @@ -362,6 +365,15 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // the overlay as it stands now. if (state.applied === undefined) state.applied = state.current + // Organisation-managed config owns the key: the feature is off for this + // directory, whatever the binding says. Nothing to reload per turn. + if (state.managed) { + if (state.applied?.entry) await releaseKey(await config().get(), true) + state.applied = null + record(sessionID, { kind: "disabled" }) + return + } + const binding = await resolveBinding(directory) if (!binding) { // Unlinked (or never linked): the key is not ours to fill. @@ -394,7 +406,9 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS loaded = await config().get() } - const overlayNow = state.current + // A transient overlay failure (its retry is throttled above) keeps what was + // last applied: a running engine is not released over a fault in the probe. + const overlayNow = state.current ?? (state.failedAt !== undefined ? (state.applied ?? null) : null) if (!overlayNow) { if (state.applied) await releaseKey(loaded, !!state.applied.entry) state.applied = null diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index cdd4331a5e..86ced98317 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -127,7 +127,9 @@ export const McpApi = HttpApi.make("mcp") params: { name: Schema.String }, query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server connected successfully"), - error: McpServerNotFoundError, + // altimate_change start — the workspace-managed key refuses connect/disconnect + error: [McpServerNotFoundError, McpServerManagedError], + // altimate_change end }).annotateMerge( OpenApi.annotations({ identifier: "mcp.connect", @@ -138,7 +140,9 @@ export const McpApi = HttpApi.make("mcp") params: { name: Schema.String }, query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server disconnected successfully"), - error: McpServerNotFoundError, + // altimate_change start — the workspace-managed key refuses connect/disconnect + error: [McpServerNotFoundError, McpServerManagedError], + // altimate_change end }).annotateMerge( OpenApi.annotations({ identifier: "mcp.disconnect", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index 1aecdaebdd..f89bd4596b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -101,7 +101,28 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler return { success: true as const } }) + // altimate_change start — connect/disconnect persist `enabled` for the key and + // restart or close its client: neither may touch the workspace-managed + // `datamate`, which is derived per process and never written to a file. + const refuseManaged = Effect.fn("McpHttpApi.refuseManaged")(function* (name: string) { + if (name !== DATAMATE_KEY) return + yield* configSvc.get() + const managed = managedWorkspace(yield* InstanceState.directory) + if (!managed) return + yield* Effect.logWarning("mcp connect/disconnect refused: key is managed by a workspace", { + name: DATAMATE_KEY, + workspace: managed.id, + }) + return yield* new McpServerManagedError({ + error: `MCP server "${DATAMATE_KEY}" is managed by workspace "${managed.name}" in this project`, + }) + }) + // altimate_change end + const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { + // altimate_change start + yield* refuseManaged(ctx.params.name) + // altimate_change end yield* mcp .connect(ctx.params.name) .pipe( @@ -115,6 +136,9 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler }) const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) { + // altimate_change start + yield* refuseManaged(ctx.params.name) + // altimate_change end yield* mcp .disconnect(ctx.params.name) .pipe( diff --git a/packages/opencode/src/server/routes/mcp.ts b/packages/opencode/src/server/routes/mcp.ts index c65b743c36..1a9dfcd742 100644 --- a/packages/opencode/src/server/routes/mcp.ts +++ b/packages/opencode/src/server/routes/mcp.ts @@ -215,6 +215,15 @@ export const McpRoutes = lazy(() => validator("param", z.object({ name: z.string() })), async (c) => { const { name } = c.req.valid("param") + // altimate_change start — the workspace-managed key is not restarted or persisted from here + const managed = name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null + if (managed) { + return c.json( + { error: `MCP server "${name}" is managed by workspace "${managed.name}" in this project` }, + 409, + ) + } + // altimate_change end await MCP.connect(name) return c.json(true) }, @@ -238,6 +247,15 @@ export const McpRoutes = lazy(() => validator("param", z.object({ name: z.string() })), async (c) => { const { name } = c.req.valid("param") + // altimate_change start — the workspace-managed key is not closed or persisted from here + const managed = name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null + if (managed) { + return c.json( + { error: `MCP server "${name}" is managed by workspace "${managed.name}" in this project` }, + 409, + ) + } + // altimate_change end await MCP.disconnect(name) return c.json(true) }, diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 37d43a9c29..546b7d7380 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -70,6 +70,7 @@ function install(opts: { tools?: Record mcp?: Record noMcpKey?: boolean + managed?: boolean }): Harness { const h: Harness = { config: opts.noMcpKey ? {} : { mcp: opts.mcp ?? {} }, @@ -138,7 +139,7 @@ function install(opts: { h.gets += 1 if (loaded) return h.config h.config = opts.noMcpKey ? {} : { mcp: structuredClone(initialMcp ?? {}) } - await overlay(DIR, h.config) + await overlay(DIR, h.config, { managed: opts.managed === true }) // MCP bootstraps from the config as first loaded — after the overlay had // its say — and keeps whatever client that started until told otherwise. if (h.live === undefined) h.live = DATAMATE_KEY in (h.config.mcp ?? {}) @@ -492,11 +493,42 @@ describe("beforeTurn — what a turn boundary does", () => { }) test("a datamate key set by managed preferences is left alone and is not managed here", async () => { - const h = install({ mcp: { datamate: IDE_ENTRY } }) - await overlay(DIR, h.config, { managed: true }) + const h = install({ mcp: { datamate: IDE_ENTRY }, managed: true }) + await beforeTurn("s1") + await beforeTurn("s1") expect(h.config.mcp).toEqual({ datamate: IDE_ENTRY }) expect(managedWorkspace()).toBeNull() expect(h.probes).toBe(0) + // The feature is off here: no per-turn reload, no toast, nothing removed. + expect(h.invalidates).toBe(0) + expect(h.removes).toBe(0) + expect(h.toasts).toEqual([]) + expect(settledOutcome("s1")).toEqual({ kind: "disabled" }) + }) + + test("a transient overlay failure after attach keeps the running engine", async () => { + const h = install({}) + // MCP bootstrapped the engine from the config as loaded; the hook adds nothing. + await beforeTurn("s1") + expect(h.added).toHaveLength(0) + expect(settledOutcome("s1")?.kind).toBe("attached") + // Something else invalidates config, and the overlay's probe now throws. + syncInternals.which = () => { + throw new Error("PATH unreadable") + } + await syncInternals.config!.invalidate() + await beforeTurn("s1") + expect(h.removes).toBe(0) + expect(h.added).toHaveLength(0) + expect(settledOutcome("s1")?.kind).toBe("attached") + // The fault clears and the TTL passes: still the same engine, not a second one. + h.which = "/usr/local/bin/datamate" + syncInternals.which = () => h.which + h.clock += FAILED_PROBE_TTL_MS + await beforeTurn("s1") + expect(h.added).toHaveLength(0) + expect(h.removes).toBe(0) + expect(settledOutcome("s1")?.kind).toBe("attached") }) test("an unlink hands the key back to the entry the reloaded config restores", async () => { diff --git a/packages/opencode/test/release-validation/question-937.test.ts b/packages/opencode/test/release-validation/question-937.test.ts index e0f0ec5b00..f61748810f 100644 --- a/packages/opencode/test/release-validation/question-937.test.ts +++ b/packages/opencode/test/release-validation/question-937.test.ts @@ -107,8 +107,22 @@ describe("tool.question non-interactive autoAnswer mapping", () => { process.env["ALTIMATE_AUTO_ANSWER"] = "first" const tool = await initTool(QuestionTool) const questions = [ - { question: "Q1", header: "Q1", options: [{ label: "A", description: "" }, { label: "B", description: "" }] }, - { question: "Q2", header: "Q2", options: [{ label: "C", description: "" }, { label: "D", description: "" }] }, + { + question: "Q1", + header: "Q1", + options: [ + { label: "A", description: "" }, + { label: "B", description: "" }, + ], + }, + { + question: "Q2", + header: "Q2", + options: [ + { label: "C", description: "" }, + { label: "D", description: "" }, + ], + }, ] const result = await tool.execute({ questions }, ctx) @@ -124,8 +138,22 @@ describe("tool.question non-interactive autoAnswer mapping", () => { test("no ALTIMATE_AUTO_ANSWER returns Unanswered for every question independently", async () => { const tool = await initTool(QuestionTool) const questions = [ - { question: "Q1", header: "Q1", options: [{ label: "A", description: "" }, { label: "B", description: "" }] }, - { question: "Q2", header: "Q2", options: [{ label: "C", description: "" }, { label: "D", description: "" }] }, + { + question: "Q1", + header: "Q1", + options: [ + { label: "A", description: "" }, + { label: "B", description: "" }, + ], + }, + { + question: "Q2", + header: "Q2", + options: [ + { label: "C", description: "" }, + { label: "D", description: "" }, + ], + }, ] const result = await tool.execute({ questions }, ctx) @@ -185,7 +213,9 @@ describe("tool.question non-interactive autoAnswer mapping", () => { else process.env["ALTIMATE_AUTO_ANSWER"] = mode const tool = await initTool(QuestionTool) - const questions = [{ question: "Empty?", header: "Empty", options: [] as { label: string; description: string }[] }] + const questions = [ + { question: "Empty?", header: "Empty", options: [] as { label: string; description: string }[] }, + ] const result = await tool.execute({ questions }, ctx) expect(askSpy).not.toHaveBeenCalled() @@ -259,6 +289,46 @@ describe("tool.bash strips ALTIMATE_NON_INTERACTIVE from child env", () => { }) }) +// --------------------------------------------------------------------------- +// Workspace engine — bash tool strips ALTIMATE_CODE_HEADLESS from child env. +// `run` sets it so the engine's refusals degrade to a printed line; a nested +// entrypoint launched from the bash tool may have a TUI and must not inherit it. +// Same shape as Gap #7 so a future removal of the delete fails here. +// --------------------------------------------------------------------------- +describe("tool.bash strips ALTIMATE_CODE_HEADLESS from child env", () => { + let prev: string | undefined + + beforeEach(() => { + prev = process.env["ALTIMATE_CODE_HEADLESS"] + process.env["ALTIMATE_CODE_HEADLESS"] = "1" + }) + + afterEach(() => { + if (prev === undefined) delete process.env["ALTIMATE_CODE_HEADLESS"] + else process.env["ALTIMATE_CODE_HEADLESS"] = prev + }) + + test("child process does not inherit ALTIMATE_CODE_HEADLESS", async () => { + const projectRoot = require("path").join(__dirname, "../..") + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initTool(BashTool) + const result = await bash.execute( + { + command: "printenv ALTIMATE_CODE_HEADLESS || echo MISSING", + description: "Echo headless env var from child", + }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output.trim()).toBe("MISSING") + expect(process.env["ALTIMATE_CODE_HEADLESS"]).toBe("1") + }, + }) + }) +}) + // --------------------------------------------------------------------------- // Gap #8 — run command sets ALTIMATE_NON_INTERACTIVE only when undefined and // not --attach. From 76325497caadc12329ad6437494a5a68f6797d2e Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 08:07:17 +0800 Subject: [PATCH 11/13] fix(workspace): refuse the managed key in /mcps; fail closed on a relink whose probe fails `/mcps enable|disable datamate` reached MCP directly and bypassed both guarded routes; it now answers with the managed-key refusal. When a relink to another workspace is followed by a failed probe, the previous workspace's engine is released rather than retained; a bound directory whose overlay could not be derived settles connect-failed and says so once instead of a silent unbound. --- .../src/altimate/workspace/engine-overlay.ts | 21 +++++++++++++--- packages/opencode/src/session/prompt.ts | 16 ++++++++++++- .../altimate/workspace/engine-overlay.test.ts | 24 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index a9042a169a..6157bf849a 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -407,12 +407,27 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS } // A transient overlay failure (its retry is throttled above) keeps what was - // last applied: a running engine is not released over a fault in the probe. - const overlayNow = state.current ?? (state.failedAt !== undefined ? (state.applied ?? null) : null) + // last applied for this same workspace: a running engine is not released + // over a fault in the probe. After a relink nothing is kept — workspace A's + // engine must not serve a directory now bound to B. + const retained = state.failedAt !== undefined && state.applied?.workspace.id === workspaceId ? state.applied : null + const overlayNow = state.current ?? retained if (!overlayNow) { if (state.applied) await releaseKey(loaded, !!state.applied.entry) state.applied = null - record(sessionID, { kind: "unbound" }) + if (state.failedAt === undefined) { + record(sessionID, { kind: "unbound" }) + return + } + // Bound, but the overlay could not be derived: say so, once, rather than + // settling a bound directory as unbound in silence. + const outcome: Outcome = { kind: "connect-failed", error: "the workspace engine could not be checked" } + record(sessionID, outcome) + await announceRefusal(sessionID, outcome, { + title: `Workspace "${binding.datamateName}": engine unavailable`, + message: `${outcome.error}; it is checked again shortly.`, + variant: "warning", + }) return } const workspace = overlayNow.workspace diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 568a16c636..d472b8a586 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -25,8 +25,10 @@ import { MemoryPrompt } from "../memory/prompt" import { UNIFIED_INJECTION_BUDGET } from "../memory/types" // altimate_change - workspace memory read path import * as WorkspaceMemory from "../altimate/workspace/memory-sync" -// altimate_change - workspace engine turn boundary +// altimate_change start — workspace engine turn boundary and managed-key refusal import * as WorkspaceEngine from "../altimate/workspace/engine-overlay" +import { DATAMATE_KEY } from "../altimate/datamate-transport" +// altimate_change end import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" @@ -2985,6 +2987,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) } + // The workspace-managed `datamate` key is derived per process: this + // command must not close or restart that engine, nor persist `enabled` + // for it. Same refusal as the HTTP routes, in the command's own reply. + const managed = name === DATAMATE_KEY ? await WorkspaceEngine.managedWorkspaceLoaded() : null + if (managed) { + return respond( + userMsg.info.id, + `MCP server **${name}** is managed by workspace **${managed.name}** in this project and cannot be ${subCmd}d here. Unlink the project, or run without ALTIMATE_WORKSPACE, to manage it by hand.`, + model, + ) + } + let responseText: string if (isEnable) { diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 546b7d7380..123db8fb16 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -513,14 +513,18 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.added).toHaveLength(0) expect(settledOutcome("s1")?.kind).toBe("attached") // Something else invalidates config, and the overlay's probe now throws. + // The usable-engine memo would mask the throw; forget it so the fault fires. syncInternals.which = () => { throw new Error("PATH unreadable") } + invalidateProbe() await syncInternals.config!.invalidate() await beforeTurn("s1") expect(h.removes).toBe(0) expect(h.added).toHaveLength(0) expect(settledOutcome("s1")?.kind).toBe("attached") + // The fault was real: the overlay is gone until its retry, the engine is not. + expect(overlayForTests()).toBeNull() // The fault clears and the TTL passes: still the same engine, not a second one. h.which = "/usr/local/bin/datamate" syncInternals.which = () => h.which @@ -531,6 +535,26 @@ describe("beforeTurn — what a turn boundary does", () => { expect(settledOutcome("s1")?.kind).toBe("attached") }) + test("a relink whose overlay then fails releases the old workspace's engine and says so", async () => { + // Workspace A's engine may not go on serving a directory now bound to B. + const h = install({}) + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("attached") + h.binding = bound(43, "ops") + syncInternals.which = () => { + throw new Error("PATH unreadable") + } + invalidateProbe() + await beforeTurn("s1") + expect(h.removes).toBe(1) + expect(h.added).toHaveLength(0) + expect(settledOutcome("s1")?.kind).toBe("connect-failed") + // The attach toast, then the failure's — naming the workspace now bound. + expect(h.toasts).toHaveLength(2) + expect(h.toasts[1].title).toContain("ops") + expect(h.toasts[1].title).toContain("unavailable") + }) + test("an unlink hands the key back to the entry the reloaded config restores", async () => { // The overlay had shadowed the user's own hosted entry; once unbound, config // reloads with that entry and MCP must be told to start it, because MCP only From 52cbd411ed11e1f89a21c3c231cd1ed859a29b7e Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 08:22:49 +0800 Subject: [PATCH 12/13] fix(workspace): identify a workspace by credential scope and id, not id alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace ids are tenant-local. After an account switch the same id in the new tenant is another workspace: the engine (started under the old credentials), the inventory cache and the reload decision now key on `tenant|apiUrl|id`. On a relink whose overlay then fails, nothing is handed back to a raw IDE or hosted entry. `/mcps … datamate` asks who owns the key before checking config. --- .../src/altimate/workspace/engine-overlay.ts | 74 ++++++++++++++----- .../src/altimate/workspace/engine-probes.ts | 13 +++- .../src/altimate/workspace/engine-seams.ts | 6 +- .../opencode/src/altimate/workspace/state.ts | 7 ++ packages/opencode/src/session/prompt.ts | 24 +++--- .../altimate/workspace/engine-overlay.test.ts | 43 +++++++++-- 6 files changed, 122 insertions(+), 45 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 6157bf849a..47dd5c3660 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -26,7 +26,15 @@ import { DATAMATE_KEY } from "@/altimate/datamate-transport" import { MCP } from "@/mcp" import { Config } from "@/config/config" -import { currentDirectory, isEnabled, isHeadless, isServe, log, syncInternals } from "./engine-seams" +import { + currentDirectory, + isEnabled, + isHeadless, + isServe, + log, + syncInternals, + type ScopedBinding, +} from "./engine-seams" import { declaredBounded, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" import { ENGINE_BINARY, @@ -95,7 +103,10 @@ export function invalidateProbe(): void { type Overlay = { directory: string - workspace: { id: string; name: string } + /** `key` is the workspace's identity across accounts — ids are tenant-local, + * so the same number in another tenant is another workspace, and a session + * that switched accounts must not keep the old one's engine or inventory. */ + workspace: { id: string; name: string; key: string } /** The derived entry, or null when the engine is unusable. */ entry: LocalMcpConfig | null refusal: Extract | null @@ -138,6 +149,12 @@ function stateFor(directory: string): DirectoryState { return state } +/** Identity of the workspace a binding names: the credential scope it was + * read under plus the tenant-local id. */ +function workspaceKey(binding: ScopedBinding): string { + return `${binding.scope ?? ""}|${binding.datamateId}` +} + function sameEntry(a: LocalMcpConfig | null, b: LocalMcpConfig | null): boolean { return !!a && !!b && a.command.join("\0") === b.command.join("\0") } @@ -176,7 +193,11 @@ export async function overlay( state.current = null return } - const workspace = { id: String(binding.datamateId), name: binding.datamateName } + const workspace = { + id: String(binding.datamateId), + name: binding.datamateName, + key: workspaceKey(binding), + } const probe = await probeEngine() if (probe.kind === "ok") { const entry = engineEntry(workspace.id) @@ -213,7 +234,8 @@ export async function overlay( * the key and say why, instead of replacing the engine underneath a turn. */ export function managedWorkspace(directory: string | null = currentDirectory()): { id: string; name: string } | null { if (!directory) return null - return directories.get(directory)?.current?.workspace ?? null + const workspace = directories.get(directory)?.current?.workspace + return workspace ? { id: workspace.id, name: workspace.name } : null } /** `managedWorkspace` once the overlay has run for this instance. The overlay @@ -287,11 +309,13 @@ async function releaseKey(loaded: { mcp?: Record } | undefined, } } -async function declaredFor(workspaceId: string): Promise { - const cached = declaredCache.get(workspaceId) +async function declaredFor(workspace: { id: string; key: string }): Promise { + // Cached per workspace identity, not per id: the same id in another tenant + // is another allowlist. + const cached = declaredCache.get(workspace.key) if (cached && (cached.value || now() - cached.at < DECLARED_RETRY_MS)) return cached.value - const value = await declaredBounded(workspaceId) - declaredCache.set(workspaceId, { value, at: now() }) + const value = await declaredBounded(workspace.id) + declaredCache.set(workspace.key, { value, at: now() }) return value } @@ -389,12 +413,13 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS record(sessionID, { kind: "unbound" }) return } - const workspaceId = String(binding.datamateId) + const boundKey = workspaceKey(binding) - // Reload the overlay when the binding moved, or when a refused engine may - // have appeared since (the probe memo bounds how often that is asked). + // Reload the overlay when the binding moved — to another workspace, or the + // same id under another account — or when a refused engine may have + // appeared since (the probe memo bounds how often that is asked). let reload = state.current - ? state.current.workspace.id !== workspaceId + ? state.current.workspace.key !== boundKey : state.failedAt === undefined || now() - state.failedAt >= FAILED_PROBE_TTL_MS if (!reload && state.current && !state.current.entry) { const probe = await probeEngine() @@ -410,17 +435,21 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // last applied for this same workspace: a running engine is not released // over a fault in the probe. After a relink nothing is kept — workspace A's // engine must not serve a directory now bound to B. - const retained = state.failedAt !== undefined && state.applied?.workspace.id === workspaceId ? state.applied : null + const retained = state.failedAt !== undefined && state.applied?.workspace.key === boundKey ? state.applied : null const overlayNow = state.current ?? retained if (!overlayNow) { - if (state.applied) await releaseKey(loaded, !!state.applied.entry) - state.applied = null if (state.failedAt === undefined) { + if (state.applied) await releaseKey(loaded, !!state.applied.entry) + state.applied = null record(sessionID, { kind: "unbound" }) return } - // Bound, but the overlay could not be derived: say so, once, rather than - // settling a bound directory as unbound in silence. + // Bound, but the overlay could not be derived. Whatever runs under the key + // is dropped and nothing is handed back: the reloaded config may carry a + // raw IDE or hosted entry, and that must not answer for this workspace. + if (state.applied?.entry || DATAMATE_KEY in (await mcp().status())) await mcp().remove(DATAMATE_KEY) + state.applied = null + // Say so, once, rather than settling a bound directory as unbound in silence. const outcome: Outcome = { kind: "connect-failed", error: "the workspace engine could not be checked" } record(sessionID, outcome) await announceRefusal(sessionID, outcome, { @@ -435,7 +464,12 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // Bring MCP in line with the overlay: start or replace the engine when the // derived entry changed, drop it when there is none any more. if (overlayNow.entry) { - if (!sameEntry(state.applied?.entry ?? null, overlayNow.entry)) await mcp().add(DATAMATE_KEY, overlayNow.entry) + // The argv is the same for the same id in another tenant; the engine + // reads its credentials when it starts, so it is replaced on identity, not + // only on argv. + const replaced = + !sameEntry(state.applied?.entry ?? null, overlayNow.entry) || state.applied?.workspace.key !== workspace.key + if (replaced) await mcp().add(DATAMATE_KEY, overlayNow.entry) } else if (state.applied?.entry || DATAMATE_KEY in (await mcp().status())) { // Ours to drop — or a client that predates the link, which MCP bootstrapped // from an IDE or hosted entry while the directory was unbound. With the @@ -447,7 +481,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS if (!overlayNow.entry) { const refusal = overlayNow.refusal ?? { kind: "engine-missing" as const } if (refusal.kind === "engine-missing") { - const declared = await declaredFor(workspace.id) + const declared = await declaredFor(workspace) const count = declared?.keys.length const outcome: Outcome = count === undefined ? { kind: "engine-missing" } : { kind: "engine-missing", declared: count } @@ -474,7 +508,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // The engine is configured. The first status call boots MCP, which awaits // the engine's handshake; the allowlist lookup overlaps with it. - const [statusMap, declared] = await Promise.all([mcp().status(), declaredFor(workspace.id)]) + const [statusMap, declared] = await Promise.all([mcp().status(), declaredFor(workspace)]) let status = statusMap[DATAMATE_KEY] const session = sessions.get(sessionID) if (status?.status !== "connected" && !session?.retried) { diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index 94fcb37a95..a45d356d00 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -8,17 +8,22 @@ import { AltimateApi } from "@/altimate/api/client" import { AppRuntime } from "@/effect/app-runtime" import { EventV2Bridge } from "@/event-v2-bridge" import { TuiEvent } from "@/server/tui-event" -import { readLocalBinding, type CachedBinding } from "./state" -import { log, syncInternals } from "./engine-seams" +import { credentialScope, readLocalBinding } from "./state" +import { log, syncInternals, type ScopedBinding } from "./engine-seams" import type { Declared, Toast } from "./engine-types" /** How long the allowlist lookup may hold a turn. Once per workspace per process. */ export const DECLARED_TIMEOUT_MS = 4_000 -export async function resolveBinding(directory: string): Promise { +export async function resolveBinding(directory: string): Promise { if (syncInternals.resolveBinding) return syncInternals.resolveBinding(directory) try { - return await readLocalBinding(directory) + const binding = await readLocalBinding(directory) + if (!binding) return null + // The cache only answers for the credentials' own tenant, so a hit is in + // scope; carry that scope, since the workspace id alone is tenant-local. + const scope = await credentialScope() + return { ...binding, scope: scope ? `${scope.tenant}|${scope.apiUrl}` : undefined } } catch (err) { log.warn("could not resolve the workspace binding", { err: String(err) }) return null diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 6202f9b825..0e53f00632 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -11,8 +11,12 @@ import type { Declared, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./eng export const log = Log.create({ service: "workspace-engine" }) /** Test seams. Production leaves every field unset. */ +/** A binding plus the credential scope it was read under (`tenant|apiUrl`). + * Absent when the scope could not be resolved. */ +export type ScopedBinding = CachedBinding & { scope?: string } + export const syncInternals: { - resolveBinding?: (directory: string) => Promise + resolveBinding?: (directory: string) => Promise which?: (cmd: string) => string | null versionOf?: (bin: string) => Promise declared?: (workspaceId: string) => Promise diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 1c5930e02a..1cc8068274 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -163,6 +163,13 @@ function canonicalizeKey(directory: string): string { } } +/** The (tenant, apiUrl) the current credentials name — the scope every + * binding lives in. Workspace ids are tenant-local, so anything that keys on + * a workspace across an account switch needs this alongside the id. */ +export async function credentialScope(): Promise<{ tenant: string; apiUrl: string } | null> { + return tenantKey() +} + async function tenantKey(): Promise<{ tenant: string; apiUrl: string } | null> { // Best-effort: ``AltimateApi.getCredentials`` can throw ``SyntaxError`` on // a corrupt credentials JSON, ``ZodError`` on schema drift, or a raw diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d472b8a586..5f6d320cfb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2974,6 +2974,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) const model = await lastModel(input.sessionID) + // The workspace-managed `datamate` key is derived per process: this + // command must not close or restart that engine, nor persist `enabled` + // for it. Asked before the config check — a refused engine has no + // config entry at all, and "not found" would be the wrong answer. + const managed = name === DATAMATE_KEY ? await WorkspaceEngine.managedWorkspaceLoaded() : null + if (managed) { + return respond( + userMsg.info.id, + `MCP server **${name}** is managed by workspace **${managed.name}** in this project and cannot be ${subCmd}d here. Unlink the project, or run without ALTIMATE_WORKSPACE, to manage it by hand.`, + model, + ) + } // MCP.connect/disconnect on an unknown name logs and returns silently, so // validate against config first and give the user a clear signal on a typo. const cfg = await Config.get() @@ -2987,18 +2999,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) } - // The workspace-managed `datamate` key is derived per process: this - // command must not close or restart that engine, nor persist `enabled` - // for it. Same refusal as the HTTP routes, in the command's own reply. - const managed = name === DATAMATE_KEY ? await WorkspaceEngine.managedWorkspaceLoaded() : null - if (managed) { - return respond( - userMsg.info.id, - `MCP server **${name}** is managed by workspace **${managed.name}** in this project and cannot be ${subCmd}d here. Unlink the project, or run without ALTIMATE_WORKSPACE, to manage it by hand.`, - model, - ) - } - let responseText: string if (isEnable) { diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 123db8fb16..b531260adb 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -25,18 +25,18 @@ import { type McpEntry, type Toast, } from "../../../src/altimate/workspace/engine-overlay" -import type { CachedBinding } from "../../../src/altimate/workspace/state" +import type { ScopedBinding } from "../../../src/altimate/workspace/engine-seams" import { DATAMATE_KEY } from "../../../src/altimate/datamate-transport" const DIR = "/tmp/analytics" const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE -const bound = (id: number, name = "analytics"): CachedBinding => - ({ datamateId: id, datamateName: name, repoRemote: null, projectPath: DIR, linkedAt: 0 }) as CachedBinding +const bound = (id: number, name = "analytics", scope = "acme|https://api.acme.example"): ScopedBinding => + ({ datamateId: id, datamateName: name, repoRemote: null, projectPath: DIR, linkedAt: 0, scope }) as ScopedBinding type Harness = { config: { mcp?: Record } - binding: CachedBinding | null + binding: ScopedBinding | null which: string | null version: string | null status: string @@ -60,7 +60,7 @@ function install(opts: { flag?: boolean serve?: boolean headless?: boolean - binding?: CachedBinding | null + binding?: ScopedBinding | null which?: string | null version?: string | null declared?: Declared | null @@ -396,7 +396,7 @@ describe("beforeTurn — what a turn boundary does", () => { test("overlay state is kept per directory, so one process can host two bound projects", async () => { const DIR_B = "/tmp/growth" const h = install({}) - const bindings: Record = { [DIR]: bound(42), [DIR_B]: bound(7, "growth") } + const bindings: Record = { [DIR]: bound(42), [DIR_B]: bound(7, "growth") } syncInternals.resolveBinding = async (directory) => bindings[directory] ?? null const configA: { mcp?: Record } = { mcp: {} } const configB: { mcp?: Record } = { mcp: {} } @@ -535,9 +535,36 @@ describe("beforeTurn — what a turn boundary does", () => { expect(settledOutcome("s1")?.kind).toBe("attached") }) - test("a relink whose overlay then fails releases the old workspace's engine and says so", async () => { - // Workspace A's engine may not go on serving a directory now bound to B. + test("the same workspace id under another account is another workspace: engine and inventory replaced", async () => { + // Ids are tenant-local. After an account switch the directory may be bound + // to "workspace 42" of the new tenant; the engine started under the old + // credentials and the old allowlist must not be kept. const h = install({}) + let lookups = 0 + const inner = syncInternals.declared! + syncInternals.declared = async (id) => { + lookups += 1 + return inner(id) + } + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("attached") + expect(h.added).toHaveLength(0) + expect(lookups).toBe(1) + h.binding = bound(42, "analytics", "globex|https://api.globex.example") + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("attached") + expect(h.added).toHaveLength(1) + expect(lookups).toBe(2) + // Same account, same workspace: nothing is replaced. + await beforeTurn("s1") + expect(h.added).toHaveLength(1) + expect(lookups).toBe(2) + }) + + test("a relink whose overlay then fails releases the old workspace's engine and says so", async () => { + // Workspace A's engine may not go on serving a directory now bound to B — + // and neither may the IDE entry the reloaded config still carries. + const h = install({ mcp: { datamate: IDE_ENTRY } }) await beforeTurn("s1") expect(settledOutcome("s1")?.kind).toBe("attached") h.binding = bound(43, "ops") From 4d2dae62513a12d58dc7eac309e2dcae4786d992 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 28 Aug 2026 08:37:14 +0800 Subject: [PATCH 13/13] fix(workspace): keep the key owned while a retained engine runs; one credential snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a transient overlay failure is being retried the turn boundary keeps the applied engine, so the key stays owned for that long too — writers could otherwise replace the engine sessions still use. The binding and the scope it was validated against now come from one credential read, so a binding cannot be paired with another tenant's scope. The attached verdict is signed with the scoped workspace key. --- .../src/altimate/workspace/engine-overlay.ts | 8 ++++-- .../src/altimate/workspace/engine-probes.ts | 12 ++++----- .../opencode/src/altimate/workspace/state.ts | 27 +++++++++++++------ .../altimate/workspace/engine-overlay.test.ts | 7 ++++- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 47dd5c3660..6110c86848 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -234,7 +234,11 @@ export async function overlay( * the key and say why, instead of replacing the engine underneath a turn. */ export function managedWorkspace(directory: string | null = currentDirectory()): { id: string; name: string } | null { if (!directory) return null - const workspace = directories.get(directory)?.current?.workspace + const state = directories.get(directory) + // While a transient overlay failure is being retried, the turn boundary + // keeps the applied engine running; the key stays owned for that long too, + // or a writer could replace the very engine the sessions are still using. + const workspace = state?.current?.workspace ?? (state?.failedAt !== undefined ? state.applied?.workspace : undefined) return workspace ? { id: workspace.id, name: workspace.name } : null } @@ -549,7 +553,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS const rec = record(sessionID, outcome) // Keyed on the workspace too: a re-link with an identical inventory is still // a new verdict the user should hear. - const signature = `attached:${workspace.id}:${outcome.available}:${outcome.declared ?? "?"}:${(missing ?? []).join(",")}` + const signature = `attached:${workspace.key}:${outcome.available}:${outcome.declared ?? "?"}:${(missing ?? []).join(",")}` if (rec.announced === signature) return rec.announced = signature log.info("workspace engine attached", { diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index a45d356d00..1ca22bb5b1 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -8,7 +8,7 @@ import { AltimateApi } from "@/altimate/api/client" import { AppRuntime } from "@/effect/app-runtime" import { EventV2Bridge } from "@/event-v2-bridge" import { TuiEvent } from "@/server/tui-event" -import { credentialScope, readLocalBinding } from "./state" +import { readLocalBindingScoped } from "./state" import { log, syncInternals, type ScopedBinding } from "./engine-seams" import type { Declared, Toast } from "./engine-types" @@ -18,12 +18,12 @@ export const DECLARED_TIMEOUT_MS = 4_000 export async function resolveBinding(directory: string): Promise { if (syncInternals.resolveBinding) return syncInternals.resolveBinding(directory) try { - const binding = await readLocalBinding(directory) + // One credential snapshot validates the hit and names its scope, so the + // binding cannot be paired with another tenant's scope by a credentials + // change between two reads. The id alone is tenant-local. + const { binding, scope } = await readLocalBindingScoped(directory) if (!binding) return null - // The cache only answers for the credentials' own tenant, so a hit is in - // scope; carry that scope, since the workspace id alone is tenant-local. - const scope = await credentialScope() - return { ...binding, scope: scope ? `${scope.tenant}|${scope.apiUrl}` : undefined } + return { ...binding, scope: scope ?? undefined } } catch (err) { log.warn("could not resolve the workspace binding", { err: String(err) }) return null diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 1cc8068274..c75c415e92 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -163,13 +163,6 @@ function canonicalizeKey(directory: string): string { } } -/** The (tenant, apiUrl) the current credentials name — the scope every - * binding lives in. Workspace ids are tenant-local, so anything that keys on - * a workspace across an account switch needs this alongside the id. */ -export async function credentialScope(): Promise<{ tenant: string; apiUrl: string } | null> { - return tenantKey() -} - async function tenantKey(): Promise<{ tenant: string; apiUrl: string } | null> { // Best-effort: ``AltimateApi.getCredentials`` can throw ``SyntaxError`` on // a corrupt credentials JSON, ``ZodError`` on schema drift, or a raw @@ -196,8 +189,26 @@ async function tenantKey(): Promise<{ tenant: string; apiUrl: string } | null> { * unresolved key (macOS ``/tmp`` → ``/private/tmp``), then relies on direct * lookup for the process's remaining lifetime. */ export async function readLocalBinding(directory: string): Promise { + return (await readLocalBindingScoped(directory)).binding +} + +/** `readLocalBinding` plus the credential scope (`tenant|apiUrl`) the hit was + * validated against — one credential snapshot for both, so a binding can never + * be paired with another tenant's scope. Workspace ids are tenant-local; the + * scope is what tells the same id in two tenants apart. */ +export async function readLocalBindingScoped( + directory: string, +): Promise<{ binding: CachedBinding | null; scope: string | null }> { const key = await tenantKey() - if (!key) return null + if (!key) return { binding: null, scope: null } + const scope = `${key.tenant}|${key.apiUrl}` + return { binding: await readCachedBinding(directory, key), scope } +} + +async function readCachedBinding( + directory: string, + key: { tenant: string; apiUrl: string }, +): Promise { let cache = readCache() if (!cache) return null if (cache.tenant !== key.tenant || cache.apiUrl !== key.apiUrl) return null diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index b531260adb..eaea653cb9 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -523,8 +523,10 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.removes).toBe(0) expect(h.added).toHaveLength(0) expect(settledOutcome("s1")?.kind).toBe("attached") - // The fault was real: the overlay is gone until its retry, the engine is not. + // The fault was real: the overlay is gone until its retry, the engine is not + // — and the key stays owned while that engine is still the one in use. expect(overlayForTests()).toBeNull() + expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) // The fault clears and the TTL passes: still the same engine, not a second one. h.which = "/usr/local/bin/datamate" syncInternals.which = () => h.which @@ -550,11 +552,14 @@ describe("beforeTurn — what a turn boundary does", () => { expect(settledOutcome("s1")?.kind).toBe("attached") expect(h.added).toHaveLength(0) expect(lookups).toBe(1) + const toastsBefore = h.toasts.length h.binding = bound(42, "analytics", "globex|https://api.globex.example") await beforeTurn("s1") expect(settledOutcome("s1")?.kind).toBe("attached") expect(h.added).toHaveLength(1) expect(lookups).toBe(2) + // A new workspace's attachment is announced even with identical counts. + expect(h.toasts.length).toBe(toastsBefore + 1) // Same account, same workspace: nothing is replaced. await beforeTurn("s1") expect(h.added).toHaveLength(1)