Skip to content

Commit 25ff62f

Browse files
author
ralphstodomingo
committed
fix(workspace): close the review findings on the engine overlay
- every writer of the `datamate` key loads config before asking who owns it, so a fresh instance's first request cannot slip past the guard; `remove` and `create` refuse the managed key like `add`; the SDK route answers with a declared 409 and a reason - a client that predates a mid-session link is removed when the overlay refuses, so nothing serves the workspace under the key - an overlay that threw is retried at the probe TTL, not every turn - `--version` settles on the engine's exit with its own deadline - a key set by managed preferences (MDM) is left alone - `ALTIMATE_CODE_HEADLESS` is scrubbed from bash-tool children
1 parent 7895003 commit 25ff62f

11 files changed

Lines changed: 241 additions & 28 deletions

File tree

packages/opencode/src/altimate/tools/datamate.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Global } from "../../global"
1414
import { Log } from "@/altimate/util/log"
1515
import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport"
1616
// altimate_change - workspace mode owns the datamate key
17-
import { managedWorkspace } from "../workspace/engine-overlay"
17+
import { managedWorkspaceLoaded } from "../workspace/engine-overlay"
1818

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

@@ -220,7 +220,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p
220220
// bound workspace's own engine, derived at config load. Adding a datamate
221221
// under that key would replace it; refuse and say why. Standalone
222222
// `datamate-<name>` entries are a different key and stay the user's.
223-
const managed = serverName === DATAMATE_KEY ? managedWorkspace() : null
223+
const managed = serverName === DATAMATE_KEY ? await managedWorkspaceLoaded() : null
224224
if (managed) {
225225
return {
226226
title: `Datamate add: '${DATAMATE_KEY}' is managed by workspace "${managed.name}"`,
@@ -356,6 +356,23 @@ async function handleCreate(args: {
356356
output: "Missing required parameter 'name'.",
357357
}
358358
}
359+
// altimate_change start — with an IDE transport the add that follows would go
360+
// under the shared `datamate` key; in workspace mode that add is refused, so
361+
// refuse here before creating an API datamate nothing would connect to.
362+
if ((await readDatamateTransportFromIde(projectRoot())) !== null) {
363+
const managedKey = await managedWorkspaceLoaded()
364+
if (managedKey) {
365+
return {
366+
title: `Datamate create: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`,
367+
metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id },
368+
output:
369+
`This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` +
370+
`workspace's own engine under the '${DATAMATE_KEY}' MCP server. Creating datamate '${args.name}' ` +
371+
`here would not connect it. Unlink the project, or run without ALTIMATE_WORKSPACE, first.`,
372+
}
373+
}
374+
}
375+
// altimate_change end
359376
try {
360377
const integrations = args.integration_ids
361378
? await AltimateApi.resolveIntegrations(args.integration_ids)
@@ -518,6 +535,22 @@ async function handleRemove(args: { server_name?: string; scope?: "project" | "g
518535
"Missing required parameter 'server_name'. Use 'status' to see active servers or 'list-config' to see saved configs.",
519536
}
520537
}
538+
// altimate_change start — the workspace-managed `datamate` key is not the
539+
// user's to remove either: it would stop the engine under a turn and delete
540+
// the entry that unlinking hands back. Standalone `datamate-<name>` entries
541+
// are unaffected.
542+
const managedKey = args.server_name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null
543+
if (managedKey) {
544+
return {
545+
title: `Datamate remove: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`,
546+
metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id },
547+
output:
548+
`This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` +
549+
`workspace's own engine under the '${DATAMATE_KEY}' MCP server. It is not removed. Unlink the project, ` +
550+
`or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`,
551+
}
552+
}
553+
// altimate_change end
521554
try {
522555
// Fully remove from runtime state (disconnect + purge from MCP list)
523556
// altimate_change start — MCP.remove (was disconnect): delete the status entry + publish

packages/opencode/src/altimate/workspace/engine-overlay.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ type DirectoryState = {
121121
* invalidate and reload config between turns, re-running the overlay without
122122
* touching MCP. */
123123
applied: Overlay | null | undefined
124+
/** When the last overlay attempt threw. A failed attempt is retried at the
125+
* probe TTL, not on every turn — each retry invalidates the whole config. */
126+
failedAt?: number
124127
}
125128
const directories = new Map<string, DirectoryState>()
126129

@@ -142,13 +145,26 @@ function sameEntry(a: LocalMcpConfig | null, b: LocalMcpConfig | null): boolean
142145
* Called from the config loader after external MCP discovery, so it has the
143146
* last word over every other source of the key. Mutates `config.mcp` only when
144147
* the directory is bound with the pilot on. Never throws. */
145-
export async function overlay(directory: string, config: { mcp?: Record<string, unknown> }): Promise<void> {
148+
export async function overlay(
149+
directory: string,
150+
config: { mcp?: Record<string, unknown> },
151+
opts: { managed?: boolean } = {},
152+
): Promise<void> {
146153
const state = stateFor(directory)
154+
state.failedAt = undefined
147155
try {
148156
if (!isEnabled() || isServe()) {
149157
state.current = null
150158
return
151159
}
160+
if (opts.managed) {
161+
// Organisation-managed config (MDM) is authoritative over everything,
162+
// this overlay included: the key stays as managed, and nothing here
163+
// claims it, so its writers are not refused either.
164+
log.info("workspace engine overlay skipped: the datamate key is set by managed preferences", { directory })
165+
state.current = null
166+
return
167+
}
152168
const binding = await resolveBinding(directory)
153169
if (!binding) {
154170
// Logged because "flag on, nothing happened" is the question every
@@ -182,6 +198,7 @@ export async function overlay(directory: string, config: { mcp?: Record<string,
182198
} catch (err) {
183199
log.warn("workspace engine overlay failed; leaving the MCP config as loaded", { err: String(err) })
184200
state.current = null
201+
state.failedAt = now()
185202
}
186203
}
187204

@@ -196,6 +213,17 @@ export function managedWorkspace(directory: string | null = currentDirectory()):
196213
return directories.get(directory)?.current?.workspace ?? null
197214
}
198215

216+
/** `managedWorkspace` once the overlay has run for this instance. The overlay
217+
* runs inside config load, and on a fresh instance a writer's request can be
218+
* the first thing that happens — asked before the load, the key looks free. */
219+
export async function managedWorkspaceLoaded(
220+
directory: string | null = currentDirectory(),
221+
): Promise<{ id: string; name: string } | null> {
222+
if (!directory) return null
223+
await config().get()
224+
return managedWorkspace(directory)
225+
}
226+
199227
// ── per-session outcome ─────────────────────────────────────────────────────
200228

201229
/** `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
353381

354382
// Reload the overlay when the binding moved, or when a refused engine may
355383
// have appeared since (the probe memo bounds how often that is asked).
356-
let reload = !state.current || state.current.workspace.id !== workspaceId
384+
let reload = state.current
385+
? state.current.workspace.id !== workspaceId
386+
: state.failedAt === undefined || now() - state.failedAt >= FAILED_PROBE_TTL_MS
357387
if (!reload && state.current && !state.current.entry) {
358388
const probe = await probeEngine()
359389
reload = probe.kind === "ok"
@@ -377,7 +407,10 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
377407
// derived entry changed, drop it when there is none any more.
378408
if (overlayNow.entry) {
379409
if (!sameEntry(state.applied?.entry ?? null, overlayNow.entry)) await mcp().add(DATAMATE_KEY, overlayNow.entry)
380-
} else if (state.applied?.entry) {
410+
} else if (state.applied?.entry || DATAMATE_KEY in (await mcp().status())) {
411+
// Ours to drop — or a client that predates the link, which MCP bootstrapped
412+
// from an IDE or hosted entry while the directory was unbound. With the
413+
// overlay refusing, nothing may serve the workspace under the key.
381414
await mcp().remove(DATAMATE_KEY)
382415
}
383416
state.applied = overlayNow
@@ -480,7 +513,8 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
480513
export async function announceRefusal(sessionID: string, outcome: Outcome, toast: Toast): Promise<void> {
481514
const rec = sessions.get(sessionID) ?? record(sessionID, outcome)
482515
const detail = "error" in outcome ? outcome.error : "found" in outcome ? String(outcome.found) : ""
483-
const signature = `${outcome.kind}:${detail}:${toast.title}`
516+
const declared = "declared" in outcome ? String(outcome.declared ?? "?") : ""
517+
const signature = `${outcome.kind}:${detail}:${declared}:${toast.title}`
484518
if (rec.announced === signature) return
485519
rec.announced = signature
486520
if (isHeadless()) {

packages/opencode/src/altimate/workspace/engine-probes.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,26 +35,48 @@ export function which(cmd: string): string | null {
3535
*
3636
* cross-spawn, not execFile: an npm-installed engine on Windows resolves to a
3737
* `.cmd` shim that Node cannot execute without a shell. */
38+
/** How long `--version` may take before the engine counts as unreadable. */
39+
export const VERSION_TIMEOUT_MS = 5_000
40+
3841
export function versionOf(bin: string): Promise<string | null> {
3942
if (syncInternals.versionOf) return syncInternals.versionOf(bin)
4043
return new Promise((resolve) => {
4144
let settled = false
45+
let timer: ReturnType<typeof setTimeout> | undefined
4246
const done = (value: string | null) => {
4347
if (settled) return
4448
settled = true
49+
if (timer) clearTimeout(timer)
4550
resolve(value)
4651
}
4752
try {
48-
const child = launch(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
53+
const child = launch(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] })
4954
let out = ""
5055
child.stdout?.on("data", (chunk) => {
5156
out += String(chunk)
5257
})
58+
// Settle on `exit`, not `close`: a descendant that inherited stdout would
59+
// keep `close` from firing after the engine itself has answered. The
60+
// deadline is ours as well — the runtime's `timeout` only signals the
61+
// direct child, so it could not end a wait on a straggler's pipe.
62+
timer = setTimeout(() => {
63+
try {
64+
child.kill("SIGKILL")
65+
} catch {
66+
// Already gone.
67+
}
68+
child.stdout?.destroy()
69+
done(null)
70+
}, VERSION_TIMEOUT_MS)
5371
child.on("error", () => done(null))
54-
child.on("close", (code) => {
55-
if (code !== 0) return done(null)
56-
const line = out.trim().split(/\r?\n/)[0] ?? ""
57-
done(line || null)
72+
child.on("exit", (code) => {
73+
// Let any bytes still in flight land before reading `out`.
74+
setImmediate(() => {
75+
child.stdout?.destroy()
76+
if (code !== 0) return done(null)
77+
const line = out.trim().split(/\r?\n/)[0] ?? ""
78+
done(line || null)
79+
})
5880
})
5981
} catch {
6082
done(null)

packages/opencode/src/config/config.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
3333
import { ConfigAgent } from "./agent"
3434
import { ConfigCommand } from "./command"
3535
import { ConfigManaged } from "./managed"
36+
// altimate_change start — the workspace engine overlay yields to managed config for this key
37+
import { DATAMATE_KEY } from "../altimate/datamate-transport"
38+
// altimate_change end
3639
import { ConfigParse } from "./parse"
3740
import { ConfigPaths } from "./paths"
3841
import { ConfigPlugin } from "./plugin"
@@ -641,6 +644,9 @@ export const layer = Layer.effect(
641644
)
642645
}
643646

647+
// altimate_change start — whether organisation-managed config sets the datamate MCP key
648+
let managedOwnsDatamate = false
649+
// altimate_change end
644650
const managedDir = ConfigManaged.managedConfigDir()
645651
if (existsSync(managedDir)) {
646652
// altimate_change start - support altimate-code.json config filename
@@ -654,20 +660,25 @@ export const layer = Layer.effect(
654660
]) {
655661
// altimate_change end
656662
const source = path.join(managedDir, file)
657-
yield* merge(source, yield* loadFile(source), "global")
663+
// altimate_change start — note a managed datamate key before merging
664+
const managedFile = yield* loadFile(source)
665+
if (managedFile?.mcp && DATAMATE_KEY in managedFile.mcp) managedOwnsDatamate = true
666+
yield* merge(source, managedFile, "global")
667+
// altimate_change end
658668
}
659669
}
660670

661671
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
662672
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
663673
if (managed) {
664-
result = mergeConfigConcatArrays(
665-
result,
666-
yield* loadConfig(managed.text, {
667-
dir: path.dirname(managed.source),
668-
source: managed.source,
669-
}),
670-
)
674+
// altimate_change start — note a managed datamate key before merging
675+
const managedPrefs = yield* loadConfig(managed.text, {
676+
dir: path.dirname(managed.source),
677+
source: managed.source,
678+
})
679+
if (managedPrefs.mcp && DATAMATE_KEY in managedPrefs.mcp) managedOwnsDatamate = true
680+
result = mergeConfigConcatArrays(result, managedPrefs)
681+
// altimate_change end
671682
}
672683

673684
for (const [name, mode] of Object.entries(result.mode ?? {})) {
@@ -747,7 +758,9 @@ export const layer = Layer.effect(
747758
// to any file. See altimate/workspace/engine-overlay.ts.
748759
if (Flag.ALTIMATE_WORKSPACE) {
749760
const { overlay } = yield* Effect.promise(() => import("../altimate/workspace/engine-overlay"))
750-
yield* Effect.promise(() => overlay(ctx.directory, result as { mcp?: Record<string, unknown> }))
761+
yield* Effect.promise(() =>
762+
overlay(ctx.directory, result as { mcp?: Record<string, unknown> }, { managed: managedOwnsDatamate }),
763+
)
751764
}
752765
// altimate_change end
753766

packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ export class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthErr
2828
{ error: Schema.String },
2929
{ httpApiStatus: 400 },
3030
) {}
31+
// altimate_change start — workspace mode owns the `datamate` key; an add over it
32+
// is refused with a reason the SDK caller can show, not a bare 400.
33+
export class McpServerManagedError extends Schema.ErrorClass<McpServerManagedError>("McpServerManagedError")(
34+
{ error: Schema.String },
35+
{ httpApiStatus: 409 },
36+
) {}
37+
// altimate_change end
3138

3239
export const McpPaths = {
3340
status: "/mcp",
@@ -56,7 +63,9 @@ export const McpApi = HttpApi.make("mcp")
5663
query: WorkspaceRoutingQuery,
5764
payload: AddPayload,
5865
success: described(StatusMap, "MCP server added successfully"),
59-
error: HttpApiError.BadRequest,
66+
// altimate_change start — the workspace-managed refusal is a declared error
67+
error: [HttpApiError.BadRequest, McpServerManagedError],
68+
// altimate_change end
6069
}).annotateMerge(
6170
OpenApi.annotations({
6271
identifier: "mcp.add",

packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@ import { McpServerNotFoundError } from "../errors"
66
import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp"
77
// altimate_change start — workspace mode owns the datamate key
88
import { InstanceState } from "@/effect/instance-state"
9+
import { Config } from "@/config/config"
910
import { DATAMATE_KEY } from "@/altimate/datamate-transport"
1011
import { managedWorkspace } from "@/altimate/workspace/engine-overlay"
12+
import { McpServerManagedError } from "../groups/mcp"
1113
// altimate_change end
1214

1315
export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) =>
1416
Effect.gen(function* () {
1517
const mcp = yield* MCP.Service
18+
// altimate_change start — config is loaded before the managed-key check
19+
const configSvc = yield* Config.Service
20+
// altimate_change end
1621

1722
const status = Effect.fn("McpHttpApi.status")(function* () {
1823
return yield* mcp.status()
@@ -23,14 +28,18 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler
2328
// workspace's own engine, derived at config load; adding over it would replace
2429
// the engine underneath a turn. Refuse and say why.
2530
if (ctx.payload.name === DATAMATE_KEY) {
31+
// The overlay runs inside config load; on a fresh instance this can be
32+
// the first request, so load before asking who owns the key.
33+
yield* configSvc.get()
2634
const managed = managedWorkspace(yield* InstanceState.directory)
2735
if (managed) {
28-
// BadRequest carries no body on this endpoint; the reason is logged.
2936
yield* Effect.logWarning("mcp add refused: key is managed by a workspace", {
3037
name: DATAMATE_KEY,
3138
workspace: managed.id,
3239
})
33-
return yield* new HttpApiError.BadRequest({})
40+
return yield* new McpServerManagedError({
41+
error: `MCP server "${DATAMATE_KEY}" is managed by workspace "${managed.name}" in this project`,
42+
})
3443
}
3544
}
3645
// altimate_change end

packages/opencode/src/server/routes/mcp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import z from "zod"
44
import { MCP } from "../../mcp"
55
// altimate_change start — workspace mode owns the datamate key
66
import { DATAMATE_KEY } from "../../altimate/datamate-transport"
7-
import { managedWorkspace } from "../../altimate/workspace/engine-overlay"
7+
import { managedWorkspaceLoaded } from "../../altimate/workspace/engine-overlay"
88
// altimate_change end
99
// altimate_change start — Config.Mcp + MCP.Status migrated to Effect Schema in v1.17.9; convert to zod for HTTP schemas
1010
import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"
@@ -64,7 +64,7 @@ export const McpRoutes = lazy(() =>
6464
async (c) => {
6565
const { name, config } = c.req.valid("json")
6666
// altimate_change start — workspace mode owns the `datamate` key
67-
const managed = name === DATAMATE_KEY ? managedWorkspace() : null
67+
const managed = name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null
6868
if (managed) {
6969
return c.json(
7070
{ error: `MCP server "${name}" is managed by workspace "${managed.name}" in this project` },

packages/opencode/src/server/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { MCP } from "../mcp"
3535
// Using datamate-transport.ts instead of serve.ts avoids a dep on a cmd handler.
3636
import { syncDatamateUrlFromVscodeMcp } from "../altimate/datamate-transport"
3737
// altimate_change - workspace mode owns the datamate key
38-
import { managedWorkspace } from "../altimate/workspace/engine-overlay"
38+
import { managedWorkspaceLoaded } from "../altimate/workspace/engine-overlay"
3939
import { readMcpEntryFromDisk } from "../mcp/config"
4040
import { resolveConfigPath } from "../mcp/config"
4141
import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt"
@@ -689,7 +689,7 @@ export namespace Server {
689689
const directory = Instance.directory
690690
// In workspace mode the `datamate` key is the bound workspace's own engine,
691691
// derived at config load; an IDE reload must not replace it under a turn.
692-
const managed = managedWorkspace()
692+
const managed = await managedWorkspaceLoaded()
693693
if (managed) {
694694
log.info("reload-datamate: refused, key is managed by a workspace", { workspace: managed.id })
695695
return c.json(

packages/opencode/src/tool/bash.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ export const BashTool = Tool.define("bash", async () => {
176176
// process.env spread above would silently disable that path in every
177177
// nested server invocation. See PR #937 review (Issue #3).
178178
delete mergedEnv["ALTIMATE_NON_INTERACTIVE"]
179+
// Same reasoning for the headless marker: `run` sets it so the workspace
180+
// engine's refusals degrade to a printed line, but a nested entrypoint
181+
// launched from here may well have a TUI. Left in place, the child would
182+
// inherit "headless" and print to stderr instead of showing its surface.
183+
delete mergedEnv["ALTIMATE_CODE_HEADLESS"]
179184
// altimate_change end
180185
const sep = process.platform === "win32" ? ";" : ":"
181186
const basePath = mergedEnv.PATH ?? mergedEnv.Path ?? ""

0 commit comments

Comments
 (0)