Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions packages/opencode/src/altimate/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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() {
Expand Down
79 changes: 73 additions & 6 deletions packages/opencode/src/altimate/tools/datamate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { managedWorkspaceLoaded } from "../workspace/engine-overlay"

const log = Log.create({ service: "datamate" })

Expand Down Expand Up @@ -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) {
Expand All @@ -176,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-<name>` entries are a different key and stay the user's.
const managed = transport !== null ? await managedWorkspaceLoaded() : null

@sahrizvi sahrizvi Aug 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major — required to merge (#1 of 4). Violates C5 and C2.

datamate_manager add still reaches the managed key when name is passed explicitly

This looks like a regression from the bot-round-2 fix. The guard used to key off the resolved server name:

const managed = serverName === DATAMATE_KEY ? await managedWorkspaceLoaded() : null

Moving it ahead of the API lookup — so the refusal would not depend on the API being reachable, which is the right goal — meant serverName was not yet computed, since it depends on datamate.name from the API response. transport !== null was substituted as a proxy, and the proxy is not equivalent to the thing it replaced.

args.name is a free-text tool argument the model chooses. With no IDE transport and name: "datamate", line 234 resolves serverName to DATAMATE_KEY, this guard never runs, and the standalone branch executes:

await addMcpToConfig(serverName, { ...mcpConfig, enabled: true }, configPath)  // :309
await MCP.add(serverName, mcpConfig)                                           // :310

That writes the key to a config file (C2: "nothing is written to any config file") and replaces the running workspace engine with a hosted cloud entry mid-turn (C5) — the "answers for the workspace with tools it did not declare" case the design rules out. handleRemove still guards correctly on args.server_name === DATAMATE_KEY; only handleAdd lost it.

E2E row 9 exercises the IDE-transport path, which is why this survived.

Fix — keep the guard ahead of the API lookup, but cover both routes to the key:

const wantsManagedKey = transport !== null || args.name === DATAMATE_KEY
const managed = wantsManagedKey ? await managedWorkspaceLoaded() : null

Worth a test with an explicit name: "datamate" and no IDE transport — the current suite covers the IDE-transport path only, which is what let the substitution through.

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,
Expand Down Expand Up @@ -325,6 +359,23 @@ async function handleCreate(args: {
}
}
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
const integrations = args.integration_ids
? await AltimateApi.resolveIntegrations(args.integration_ids)
: undefined
Expand Down Expand Up @@ -487,6 +538,22 @@ async function handleRemove(args: { server_name?: string; scope?: "project" | "g
}
}
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-<name>` 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.
Expand Down
Loading
Loading