From 20b7824b7a1955ccad5bae917fc21cc983cf4090 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 24 Sep 2026 08:46:15 +0530 Subject: [PATCH 1/4] feat(workspace): expose `/workspace` Refresh and Sync over `serve` HTTP - Add `POST /altimate/workspace/refresh` (optional `{ sessionID }`) and `POST /altimate/workspace/sync`, returning the `Manage` reports as is. Refused with 409 outside the workspace pilot. - `Manage.sync` honours the IDE extension's pin before the on-disk link, as the per-write mirror already does; an unhonourable pin stays gated. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../opencode/src/altimate/workspace/manage.ts | 13 +- packages/opencode/src/server/server.ts | 40 +++++ .../altimate/workspace/manage-pin.test.ts | 140 ++++++++++++++++++ .../server/altimate-workspace-routes.test.ts | 133 +++++++++++++++++ 4 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/altimate/workspace/manage-pin.test.ts create mode 100644 packages/opencode/test/server/altimate-workspace-routes.test.ts diff --git a/packages/opencode/src/altimate/workspace/manage.ts b/packages/opencode/src/altimate/workspace/manage.ts index 32b09358ba..14e8020378 100644 --- a/packages/opencode/src/altimate/workspace/manage.ts +++ b/packages/opencode/src/altimate/workspace/manage.ts @@ -33,6 +33,7 @@ import { peekRowUnscoped, readLocalBinding, resolveBinding, + resolvePinnedBindingForRouting, type CachedBinding, } from "./state" @@ -226,7 +227,17 @@ export async function sync(directory: string): Promise { deferred: 0, }) if (!MemorySync.isEnabled()) return gated("flag-off") - const binding = await readLocalBinding(directory).catch(() => null) + // altimate_change — the IDE extension's pin outranks the project's own link, as it does for + // the per-write mirror (`memory-sync.resolveBinding`). Without it an extension-launched `serve` + // answered "not linked" for the workspace it was pinned to. Only the pin arm is layered, so an + // unpinned session keeps the cache-only read, and a pin that cannot be honoured stays gated + // rather than falling through to the project's link. + const pinned = await resolvePinnedBindingForRouting(directory).catch(() => ({ status: "unknown" as const })) + const binding = pinned + ? pinned.status === "bound" + ? pinned.binding + : null + : await readLocalBinding(directory).catch(() => null) if (!binding) return gated("no-binding") const blocks = await MemoryStore.listAll({ directory }).catch((err) => { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 42cf06339d..554c9da666 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -45,6 +45,8 @@ import { FreeTierConsent } from "../altimate/free/consent" import { InstanceStore } from "@/project/instance-store" import { AppRuntime } from "@/effect/app-runtime" // altimate_change end +// altimate_change - `/workspace` Refresh and Sync are gated on the workspace pilot flag +import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" // altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" @@ -949,6 +951,44 @@ export namespace Server { } }) // altimate_change end + // altimate_change start — POST /altimate/workspace/{refresh,sync} + // The `/workspace` menu's Refresh and Sync for the IDE extension, which runs this CLI + // headless and cannot reach the TUI slash command. Both act on the request's instance + // directory and return the `Manage` report as is; wording is the caller's job. + // Refused outside the workspace pilot: with the flag off, a skill sync purges the snapshot. + .post("/altimate/workspace/refresh", async (c) => { + if (!CoreFlag.ALTIMATE_WORKSPACE) { + return c.json({ ok: false, error: "Workspace mode is not enabled for this server." }, 409) + } + try { + const body = await c.req.json().catch(() => ({})) + const sessionID = typeof body?.sessionID === "string" && body.sessionID ? body.sessionID : undefined + const Manage = await import("../altimate/workspace/manage") + // A changed skill snapshot reaches the registry at the start of the next turn + // (`refreshSkillRegistry` in session/prompt.ts), so nothing is invalidated here. + const report = await Manage.refresh(Instance.directory, sessionID) + return c.json({ ok: true as const, ...report }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("workspace refresh: failed", { error }) + return c.json({ ok: false, error }, 500) + } + }) + .post("/altimate/workspace/sync", async (c) => { + if (!CoreFlag.ALTIMATE_WORKSPACE) { + return c.json({ ok: false, error: "Workspace mode is not enabled for this server." }, 409) + } + try { + const Manage = await import("../altimate/workspace/manage") + const report = await Manage.sync(Instance.directory) + return c.json({ ok: true as const, ...report }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("workspace sync: failed", { error }) + return c.json({ ok: false, error }, 500) + } + }) + // altimate_change end .all("/*", async (c) => { const path = c.req.path diff --git a/packages/opencode/test/altimate/workspace/manage-pin.test.ts b/packages/opencode/test/altimate/workspace/manage-pin.test.ts new file mode 100644 index 0000000000..b304cfadfa --- /dev/null +++ b/packages/opencode/test/altimate/workspace/manage-pin.test.ts @@ -0,0 +1,140 @@ +// altimate_change - new file +// +// `/workspace` Sync follows the IDE extension's pin. `Manage.sync` read only the on-disk link, so a +// `serve` pinned by the extension answered "not linked" for the workspace it was pinned to, while +// the per-write mirror (which resolves through the pin) sent to it. These cover which binding the +// sweep runs against; the sweep itself is covered by manage.test.ts and memory-sync.test.ts. +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, rmSync } from "node:fs" +import path from "node:path" +import os from "node:os" + +const ORIGINAL_XDG_STATE_HOME = process.env.XDG_STATE_HOME +const ORIGINAL_PILOT = process.env.ALTIMATE_WORKSPACE +const SANDBOX = path.join(os.tmpdir(), `altimate-manage-pin-test-${process.pid}-${Date.now()}`) +mkdirSync(path.join(SANDBOX, "state"), { recursive: true }) +process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") + +const { recordApprovedBinding, __resetPinValidation } = await import("../../../src/altimate/workspace/state") +const { sync } = await import("../../../src/altimate/workspace/manage") +const { resetEnablementMemoForTests } = await import("../../../src/altimate/workspace/memory-sync") +const { AltimateApi } = await import("../../../src/altimate/api/client") +const { WorkspaceApi } = await import("../../../src/altimate/workspace/api-client") + +const ROOT = path.join(SANDBOX, "project") +const OUTSIDE = path.join(SANDBOX, "elsewhere") +mkdirSync(ROOT, { recursive: true }) +mkdirSync(OUTSIDE, { recursive: true }) + +const originalIsConfigured = AltimateApi.isConfigured +const originalGetCreds = AltimateApi.getCredentials +const originalList = WorkspaceApi.listDatamates +type Creds = Awaited> + +const PIN_VARS = [ + "ALTIMATE_CODE_SERVE", + "ALTIMATE_PINNED_WORKSPACE_ID", + "ALTIMATE_PINNED_WORKSPACE_NAME", + "ALTIMATE_PINNED_WORKSPACE_ROOT", +] + +/** The pinned workspace has memory ON and the project's own link has it OFF, so the gate reason + * says which of the two the sweep ran against. */ +const WORKSPACES = [ + { id: 42, name: "pinned-workspace", memoryEnabled: true }, + { id: 7, name: "project-link", memoryEnabled: false }, +] + +function setPin(id = "42") { + process.env.ALTIMATE_CODE_SERVE = "1" + process.env.ALTIMATE_PINNED_WORKSPACE_ID = id + process.env.ALTIMATE_PINNED_WORKSPACE_NAME = "pinned-workspace" + process.env.ALTIMATE_PINNED_WORKSPACE_ROOT = ROOT +} + +function clearPin() { + for (const k of PIN_VARS) delete process.env[k] +} + +async function seedLocalLink(directory = ROOT) { + await recordApprovedBinding(directory, { + datamateId: 7, + datamateName: "project-link", + linkedAt: Date.now(), + repoRemote: "git@example.com:acme/project.git", + projectPath: null, + } as never) +} + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" + __resetPinValidation() + resetEnablementMemoForTests() + ;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = async () => true + ;(AltimateApi as unknown as { getCredentials: () => Promise }).getCredentials = async () => + ({ altimateInstanceName: "acme", altimateUrl: "https://api.test", altimateApiKey: "k" }) as Creds + ;(WorkspaceApi as unknown as { listDatamates: () => Promise }).listDatamates = async () => WORKSPACES + clearPin() +}) + +afterEach(() => { + clearPin() +}) + +afterAll(() => { + ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = originalIsConfigured + ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = originalGetCreds + ;(WorkspaceApi as unknown as { listDatamates: typeof originalList }).listDatamates = originalList + if (ORIGINAL_XDG_STATE_HOME === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = ORIGINAL_XDG_STATE_HOME + if (ORIGINAL_PILOT === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_PILOT + rmSync(SANDBOX, { recursive: true, force: true }) +}) + +describe("sync under an IDE pin", () => { + test("runs against the pinned workspace in a project that was never linked", async () => { + setPin() + const report = await sync(ROOT) + expect(report.gated).toBe(false) + expect(report.gatedBecause).toBeUndefined() + }) + + test("the pin outranks the project's own link", async () => { + await seedLocalLink() + setPin() + // The local link (7) has memory off and would gate; the pin (42) has it on. + expect((await sync(ROOT)).gated).toBe(false) + }) + + test("a pin naming a workspace this account cannot see fails closed, not onto the local link", async () => { + await seedLocalLink() + setPin("99") + const report = await sync(ROOT) + expect(report.gated).toBe(true) + expect(report.gatedBecause).toBe("no-binding") + }) + + test("a directory outside the pinned root is not treated as pinned", async () => { + setPin() + const report = await sync(OUTSIDE) + expect(report.gated).toBe(true) + expect(report.gatedBecause).toBe("no-binding") + }) +}) + +describe("sync without a pin", () => { + test("still reads the project's own link", async () => { + await seedLocalLink() + const report = await sync(ROOT) + // Reached the sweep with link 7, whose memory is off. + expect(report.gated).toBe(true) + expect(report.gatedBecause).toBe("memory-off") + }) + + test("an unlinked project is still gated on the missing binding", async () => { + // A directory no test links: the binding cache is process-memoized, so `ROOT` may still hold one. + const report = await sync(OUTSIDE) + expect(report.gatedBecause).toBe("no-binding") + }) +}) diff --git a/packages/opencode/test/server/altimate-workspace-routes.test.ts b/packages/opencode/test/server/altimate-workspace-routes.test.ts new file mode 100644 index 0000000000..109d79faa9 --- /dev/null +++ b/packages/opencode/test/server/altimate-workspace-routes.test.ts @@ -0,0 +1,133 @@ +// altimate_change - new file +// +// `/altimate/workspace/{refresh,sync}`: the `/workspace` menu's Refresh and Sync for the IDE +// extension. These cover the ROUTE only — the flag gate, argument passthrough and report shape — +// with `Manage` stubbed; the operations themselves are covered by test/altimate/workspace/manage.test.ts. +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { Server } from "../../src/server/server" +import * as Manage from "../../src/altimate/workspace/manage" +import { resetDatabase } from "./db" +import { disposeAllInstances } from "../fixture/fixture" + +const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE +let spies: Array<{ mockRestore: () => void }> = [] + +function post(path: string, body?: unknown) { + return Server.Default().request(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }) +} + +beforeEach(() => { + process.env.ALTIMATE_WORKSPACE = "1" +}) + +afterEach(async () => { + for (const spy of spies) spy.mockRestore() + spies = [] + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_WORKSPACE + else process.env.ALTIMATE_WORKSPACE = ORIGINAL_FLAG + await disposeAllInstances() + await resetDatabase() +}) + +describe("POST /altimate/workspace/refresh", () => { + test("returns the refresh report and passes the session through", async () => { + const refresh = spyOn(Manage, "refresh").mockResolvedValue({ + skillsChanged: true, + memory: { ok: true, status: "reloaded", blocks: 4 } as unknown as Manage.RefreshReport["memory"], + errors: [], + }) + spies.push(refresh) + + const response = await post("/altimate/workspace/refresh", { sessionID: "ses_123" }) + expect(response.status).toBe(200) + const body = (await response.json()) as Record + expect(body.ok).toBe(true) + expect(body.skillsChanged).toBe(true) + expect(body.errors).toEqual([]) + expect(refresh).toHaveBeenCalledTimes(1) + expect(refresh.mock.calls[0][1]).toBe("ses_123") + }) + + test("works without a body, leaving the memory overlay to reload on the next turn", async () => { + const refresh = spyOn(Manage, "refresh").mockResolvedValue({ + skillsChanged: false, + memoryInvalidated: true, + errors: [], + }) + spies.push(refresh) + + const response = await post("/altimate/workspace/refresh") + expect(response.status).toBe(200) + expect(((await response.json()) as Record).memoryInvalidated).toBe(true) + expect(refresh.mock.calls[0][1]).toBeUndefined() + }) + + test("ignores a session id that is not a string", async () => { + const refresh = spyOn(Manage, "refresh").mockResolvedValue({ skillsChanged: false, errors: [] }) + spies.push(refresh) + + await post("/altimate/workspace/refresh", { sessionID: 42 }) + expect(refresh.mock.calls[0][1]).toBeUndefined() + }) + + test("is refused outside the workspace pilot, without touching the snapshot", async () => { + delete process.env.ALTIMATE_WORKSPACE + const refresh = spyOn(Manage, "refresh") + spies.push(refresh) + + const response = await post("/altimate/workspace/refresh") + expect(response.status).toBe(409) + expect(((await response.json()) as Record).ok).toBe(false) + expect(refresh).not.toHaveBeenCalled() + }) + + test("reports a thrown error as a 500 with its message", async () => { + spies.push(spyOn(Manage, "refresh").mockRejectedValue(new Error("boom"))) + + const response = await post("/altimate/workspace/refresh") + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ ok: false, error: "boom" }) + }) +}) + +describe("POST /altimate/workspace/sync", () => { + test("returns the sync report", async () => { + const report: Manage.SyncReport = { gated: false, sent: 2, failed: 0, skipped: 5, declined: 0, deferred: 1 } + spies.push(spyOn(Manage, "sync").mockResolvedValue(report)) + + const response = await post("/altimate/workspace/sync") + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true, ...report }) + }) + + test("passes a gated sweep through with its reason", async () => { + spies.push( + spyOn(Manage, "sync").mockResolvedValue({ + gated: true, + gatedBecause: "memory-off", + sent: 0, + failed: 0, + skipped: 0, + declined: 0, + deferred: 0, + }), + ) + + const body = (await (await post("/altimate/workspace/sync")).json()) as Record + expect(body.gated).toBe(true) + expect(body.gatedBecause).toBe("memory-off") + }) + + test("is refused outside the workspace pilot", async () => { + delete process.env.ALTIMATE_WORKSPACE + const sync = spyOn(Manage, "sync") + spies.push(sync) + + expect((await post("/altimate/workspace/sync")).status).toBe(409) + expect(sync).not.toHaveBeenCalled() + }) +}) From 8a8369635fc85a6ea9619b95b43ffe517000b375 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 24 Sep 2026 14:05:54 +0530 Subject: [PATCH 2/4] fix(workspace): address review on the `/workspace` routes - Refuse a browser origin on an unsecured server (403), as the Altimate Base registration route does; native clients send no Origin. - Refresh rejects a malformed or non-object JSON body (400) instead of treating it as a session-less refresh that resets every overlay. - `Manage.sync` reports an unhonourable pin as `pin-unresolved`, distinct from `no-binding`; the TUI toast names it. - Tests: await the bind's backfill with a stubbed `fetch` (its detached lookup leaked into `create-then-rebind` in CI), restore the pin env after the file, and cover sync's 500 and both routes' origin refusal. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../opencode/src/altimate/workspace/manage.ts | 12 ++--- .../src/plugin/tui/altimate/workspace.tsx | 2 + packages/opencode/src/server/server.ts | 53 ++++++++++++++++--- .../altimate/workspace/manage-pin.test.ts | 35 ++++++++---- .../server/altimate-workspace-routes.test.ts | 50 +++++++++++++++-- 5 files changed, 125 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/manage.ts b/packages/opencode/src/altimate/workspace/manage.ts index 14e8020378..1da4632339 100644 --- a/packages/opencode/src/altimate/workspace/manage.ts +++ b/packages/opencode/src/altimate/workspace/manage.ts @@ -82,7 +82,7 @@ export interface SyncReport { * and only one of them is the workspace's memory toggle; a toast that said * "memory is off" for a failed local read sent the user to a setting that was * fine. */ - gatedBecause?: "flag-off" | "no-binding" | "memory-off" | "read-failed" + gatedBecause?: "flag-off" | "no-binding" | "pin-unresolved" | "memory-off" | "read-failed" sent: number failed: number /** Already present in the workspace at their current payload. */ @@ -230,14 +230,12 @@ export async function sync(directory: string): Promise { // altimate_change — the IDE extension's pin outranks the project's own link, as it does for // the per-write mirror (`memory-sync.resolveBinding`). Without it an extension-launched `serve` // answered "not linked" for the workspace it was pinned to. Only the pin arm is layered, so an - // unpinned session keeps the cache-only read, and a pin that cannot be honoured stays gated + // unpinned session keeps the cache-only read, and a pin that cannot be honoured stays gated — + // under its own reason, since "nothing is linked" would misdescribe a workspace that exists — // rather than falling through to the project's link. const pinned = await resolvePinnedBindingForRouting(directory).catch(() => ({ status: "unknown" as const })) - const binding = pinned - ? pinned.status === "bound" - ? pinned.binding - : null - : await readLocalBinding(directory).catch(() => null) + if (pinned && pinned.status !== "bound") return gated("pin-unresolved") + const binding = pinned ? pinned.binding : await readLocalBinding(directory).catch(() => null) if (!binding) return gated("no-binding") const blocks = await MemoryStore.listAll({ directory }).catch((err) => { diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 654d1b3d19..16ebc29429 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1789,6 +1789,8 @@ function syncMessage(result: Manage.SyncReport): string { return "Could not read this project's local memory, so nothing was synced." case "no-binding": return "Nothing to sync — this project is not linked to a workspace." + case "pin-unresolved": + return "Nothing to sync — the pinned workspace could not be confirmed for this project." case "flag-off": return "Nothing to sync — workspace memory is not enabled in this build." default: diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 554c9da666..6c52a388b3 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -73,6 +73,32 @@ globalThis.AI_SDK_LOG_WARNINGS = false export namespace Server { const log = Log.create({ service: "server" }) + // altimate_change start — shared gate for the `/altimate/workspace/*` routes + /** Why a `/workspace` action must not run, or undefined when it may. + * + * Outside the workspace pilot a skill sync purges the snapshot, so the flag is checked first. + * A browser origin on an unsecured server is refused for the same reason as Altimate Base + * registration: a CORS-allowed page is not a local process. Native clients (the extension host, + * curl) send no Origin; with a server password set, basicAuth has already vetted the caller. */ + function workspaceRouteRefusal( + origin: string | undefined, + ): { status: 403 | 409; body: { ok: false; error: string } } | undefined { + if (!CoreFlag.ALTIMATE_WORKSPACE) { + return { status: 409, body: { ok: false, error: "Workspace mode is not enabled for this server." } } + } + if (origin && !Flag.OPENCODE_SERVER_PASSWORD) { + log.warn("refused browser-originated workspace action on an unsecured server", { origin }) + return { + status: 403, + body: { + ok: false, + error: "Workspace actions cannot be run from a browser origin on an unsecured server. Set OPENCODE_SERVER_PASSWORD.", + }, + } + } + return undefined + } + // altimate_change end // altimate_change start — the Base credential every provider cache in this process is known to // reflect: set after the register route has disposed both registries for it. Unset until then, // because a cache built before a background registration finished cannot be told apart from one @@ -955,14 +981,26 @@ export namespace Server { // The `/workspace` menu's Refresh and Sync for the IDE extension, which runs this CLI // headless and cannot reach the TUI slash command. Both act on the request's instance // directory and return the `Manage` report as is; wording is the caller's job. - // Refused outside the workspace pilot: with the flag off, a skill sync purges the snapshot. .post("/altimate/workspace/refresh", async (c) => { - if (!CoreFlag.ALTIMATE_WORKSPACE) { - return c.json({ ok: false, error: "Workspace mode is not enabled for this server." }, 409) + const refused = workspaceRouteRefusal(c.req.header("origin")) + if (refused) return c.json(refused.body, refused.status) + // An absent or empty body is a session-less refresh; a malformed one is an error, not a + // silent fall-back to resetting every session's memory overlay. + const text = await c.req.text() + let body: unknown = {} + if (text.trim()) { + try { + body = JSON.parse(text) + } catch { + return c.json({ ok: false, error: "Request body is not valid JSON." }, 400) + } } + if (body === null || typeof body !== "object" || Array.isArray(body)) { + return c.json({ ok: false, error: "Request body must be a JSON object." }, 400) + } + const raw = (body as Record).sessionID + const sessionID = typeof raw === "string" && raw ? raw : undefined try { - const body = await c.req.json().catch(() => ({})) - const sessionID = typeof body?.sessionID === "string" && body.sessionID ? body.sessionID : undefined const Manage = await import("../altimate/workspace/manage") // A changed skill snapshot reaches the registry at the start of the next turn // (`refreshSkillRegistry` in session/prompt.ts), so nothing is invalidated here. @@ -975,9 +1013,8 @@ export namespace Server { } }) .post("/altimate/workspace/sync", async (c) => { - if (!CoreFlag.ALTIMATE_WORKSPACE) { - return c.json({ ok: false, error: "Workspace mode is not enabled for this server." }, 409) - } + const refused = workspaceRouteRefusal(c.req.header("origin")) + if (refused) return c.json(refused.body, refused.status) try { const Manage = await import("../altimate/workspace/manage") const report = await Manage.sync(Instance.directory) diff --git a/packages/opencode/test/altimate/workspace/manage-pin.test.ts b/packages/opencode/test/altimate/workspace/manage-pin.test.ts index b304cfadfa..4a81ee36ce 100644 --- a/packages/opencode/test/altimate/workspace/manage-pin.test.ts +++ b/packages/opencode/test/altimate/workspace/manage-pin.test.ts @@ -37,6 +37,9 @@ const PIN_VARS = [ "ALTIMATE_PINNED_WORKSPACE_NAME", "ALTIMATE_PINNED_WORKSPACE_ROOT", ] +// Restored in `afterAll`: `bun test` shares one process, and later files must see the pin they had. +const ORIGINAL_PIN = Object.fromEntries(PIN_VARS.map((k) => [k, process.env[k]])) +const originalFetch = globalThis.fetch /** The pinned workspace has memory ON and the project's own link has it OFF, so the gate reason * says which of the two the sweep ran against. */ @@ -57,13 +60,19 @@ function clearPin() { } async function seedLocalLink(directory = ROOT) { - await recordApprovedBinding(directory, { - datamateId: 7, - datamateName: "project-link", - linkedAt: Date.now(), - repoRemote: "git@example.com:acme/project.git", - projectPath: null, - } as never) + // Awaited, so the bind's skill sync and memory backfill finish inside this test's stubbed + // `fetch` instead of straddling `afterEach` into another file's request log. + await recordApprovedBinding( + directory, + { + datamateId: 7, + datamateName: "project-link", + linkedAt: Date.now(), + repoRemote: "git@example.com:acme/project.git", + projectPath: null, + } as never, + { awaitBackfill: true }, + ) } beforeEach(() => { @@ -75,10 +84,13 @@ beforeEach(() => { ({ altimateInstanceName: "acme", altimateUrl: "https://api.test", altimateApiKey: "k" }) as Creds ;(WorkspaceApi as unknown as { listDatamates: () => Promise }).listDatamates = async () => WORKSPACES clearPin() + globalThis.fetch = (async (_input: unknown, _init?: unknown) => + new Response(JSON.stringify({}), { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch }) afterEach(() => { clearPin() + globalThis.fetch = originalFetch }) afterAll(() => { @@ -89,6 +101,10 @@ afterAll(() => { else process.env.XDG_STATE_HOME = ORIGINAL_XDG_STATE_HOME if (ORIGINAL_PILOT === undefined) delete process.env.ALTIMATE_WORKSPACE else process.env.ALTIMATE_WORKSPACE = ORIGINAL_PILOT + for (const [k, v] of Object.entries(ORIGINAL_PIN)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } rmSync(SANDBOX, { recursive: true, force: true }) }) @@ -112,14 +128,15 @@ describe("sync under an IDE pin", () => { setPin("99") const report = await sync(ROOT) expect(report.gated).toBe(true) - expect(report.gatedBecause).toBe("no-binding") + // Its own reason: the project IS linked (to 7), so "no-binding" would misdescribe it. + expect(report.gatedBecause).toBe("pin-unresolved") }) test("a directory outside the pinned root is not treated as pinned", async () => { setPin() const report = await sync(OUTSIDE) expect(report.gated).toBe(true) - expect(report.gatedBecause).toBe("no-binding") + expect(report.gatedBecause).toBe("pin-unresolved") }) }) diff --git a/packages/opencode/test/server/altimate-workspace-routes.test.ts b/packages/opencode/test/server/altimate-workspace-routes.test.ts index 109d79faa9..bb55b22ad8 100644 --- a/packages/opencode/test/server/altimate-workspace-routes.test.ts +++ b/packages/opencode/test/server/altimate-workspace-routes.test.ts @@ -12,11 +12,11 @@ import { disposeAllInstances } from "../fixture/fixture" const ORIGINAL_FLAG = process.env.ALTIMATE_WORKSPACE let spies: Array<{ mockRestore: () => void }> = [] -function post(path: string, body?: unknown) { +function post(path: string, body?: unknown, headers: Record = {}) { return Server.Default().request(path, { method: "POST", - headers: { "content-type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), + headers: { "content-type": "application/json", ...headers }, + body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body), }) } @@ -92,6 +92,34 @@ describe("POST /altimate/workspace/refresh", () => { expect(response.status).toBe(500) expect(await response.json()).toEqual({ ok: false, error: "boom" }) }) + + test("rejects malformed JSON rather than resetting every session's memory", async () => { + const refresh = spyOn(Manage, "refresh") + spies.push(refresh) + + const response = await post("/altimate/workspace/refresh", "{not json") + expect(response.status).toBe(400) + expect(((await response.json()) as Record).ok).toBe(false) + expect(refresh).not.toHaveBeenCalled() + }) + + test("rejects a body that is not an object", async () => { + const refresh = spyOn(Manage, "refresh") + spies.push(refresh) + + expect((await post("/altimate/workspace/refresh", "[]")).status).toBe(400) + expect((await post("/altimate/workspace/refresh", "null")).status).toBe(400) + expect(refresh).not.toHaveBeenCalled() + }) + + test("refuses a browser origin on an unsecured server", async () => { + const refresh = spyOn(Manage, "refresh") + spies.push(refresh) + + const response = await post("/altimate/workspace/refresh", {}, { origin: "https://evil.test" }) + expect(response.status).toBe(403) + expect(refresh).not.toHaveBeenCalled() + }) }) describe("POST /altimate/workspace/sync", () => { @@ -130,4 +158,20 @@ describe("POST /altimate/workspace/sync", () => { expect((await post("/altimate/workspace/sync")).status).toBe(409) expect(sync).not.toHaveBeenCalled() }) + + test("reports a thrown error as a 500 with its message", async () => { + spies.push(spyOn(Manage, "sync").mockRejectedValue(new Error("boom"))) + + const response = await post("/altimate/workspace/sync") + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ ok: false, error: "boom" }) + }) + + test("refuses a browser origin on an unsecured server", async () => { + const sync = spyOn(Manage, "sync") + spies.push(sync) + + expect((await post("/altimate/workspace/sync", undefined, { origin: "https://evil.test" })).status).toBe(403) + expect(sync).not.toHaveBeenCalled() + }) }) From 8af77604e475f85fb74da1df2cb6e76e6f088a37 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 24 Sep 2026 14:41:48 +0530 Subject: [PATCH 3/4] fix(workspace): tighten `/workspace` refresh input and origin checks - A present `sessionID` must be a non-empty string (400), must exist (404), and must belong to the request's directory (409), since the reload loads this directory's workspace memory into that session. - With a server password set, refuse cross-origin requests too: a browser replays cached Basic credentials on a cross-site form POST. Same-origin pages and Origin-less native clients are unaffected. - Read the request body inside a guard, so a failed read keeps the route's `{ ok: false, error }` 500 contract. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/server/server.ts | 60 ++++++++++++++++--- .../server/altimate-workspace-routes.test.ts | 44 ++++++++++++-- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6c52a388b3..90c0339dc0 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -45,8 +45,10 @@ import { FreeTierConsent } from "../altimate/free/consent" import { InstanceStore } from "@/project/instance-store" import { AppRuntime } from "@/effect/app-runtime" // altimate_change end -// altimate_change - `/workspace` Refresh and Sync are gated on the workspace pilot flag +// altimate_change - `/workspace` Refresh and Sync: pilot flag gate, and the session-directory check import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" +import nodePath from "node:path" +import { Session } from "../session" // altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" @@ -79,14 +81,16 @@ export namespace Server { * Outside the workspace pilot a skill sync purges the snapshot, so the flag is checked first. * A browser origin on an unsecured server is refused for the same reason as Altimate Base * registration: a CORS-allowed page is not a local process. Native clients (the extension host, - * curl) send no Origin; with a server password set, basicAuth has already vetted the caller. */ + * curl) send no Origin. With a server password set, a same-origin page may call; others may not. */ function workspaceRouteRefusal( origin: string | undefined, + host: string | undefined, ): { status: 403 | 409; body: { ok: false; error: string } } | undefined { if (!CoreFlag.ALTIMATE_WORKSPACE) { return { status: 409, body: { ok: false, error: "Workspace mode is not enabled for this server." } } } - if (origin && !Flag.OPENCODE_SERVER_PASSWORD) { + if (!origin) return undefined + if (!Flag.OPENCODE_SERVER_PASSWORD) { log.warn("refused browser-originated workspace action on an unsecured server", { origin }) return { status: 403, @@ -96,8 +100,21 @@ export namespace Server { }, } } + // With a password set, basicAuth has vetted the credentials — but a browser replays cached + // Basic credentials on a cross-site form POST too, so only this server's own pages may call. + if (!sameOrigin(origin, host)) { + log.warn("refused cross-origin workspace action", { origin }) + return { status: 403, body: { ok: false, error: "Workspace actions cannot be run from another origin." } } + } return undefined } + export function sameOrigin(origin: string, host: string | undefined): boolean { + try { + return !!host && new URL(origin).host === host + } catch { + return false + } + } // altimate_change end // altimate_change start — the Base credential every provider cache in this process is known to // reflect: set after the register route has disposed both registries for it. Unset until then, @@ -982,11 +999,19 @@ export namespace Server { // headless and cannot reach the TUI slash command. Both act on the request's instance // directory and return the `Manage` report as is; wording is the caller's job. .post("/altimate/workspace/refresh", async (c) => { - const refused = workspaceRouteRefusal(c.req.header("origin")) + const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host")) if (refused) return c.json(refused.body, refused.status) - // An absent or empty body is a session-less refresh; a malformed one is an error, not a - // silent fall-back to resetting every session's memory overlay. - const text = await c.req.text() + // An absent or empty body is a session-less refresh; anything else must be well formed. + // Falling back to "no session" on bad input would silently widen the operation to + // resetting every session's memory overlay. + let text: string + try { + text = await c.req.text() + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("workspace refresh: could not read the request body", { error }) + return c.json({ ok: false, error }, 500) + } let body: unknown = {} if (text.trim()) { try { @@ -999,7 +1024,24 @@ export namespace Server { return c.json({ ok: false, error: "Request body must be a JSON object." }, 400) } const raw = (body as Record).sessionID - const sessionID = typeof raw === "string" && raw ? raw : undefined + if (raw !== undefined && (typeof raw !== "string" || !raw)) { + return c.json({ ok: false, error: "sessionID must be a non-empty string." }, 400) + } + const sessionID = raw as string | undefined + // The memory reload loads THIS directory's workspace memory into the named session, so the + // session must be one of this directory's; another project's would receive it. + if (sessionID) { + const session = await Session.get(sessionID as never).catch((err) => err as Error) + if (session instanceof NotFoundError) { + return c.json({ ok: false, error: `Session not found: ${sessionID}` }, 404) + } + if (session instanceof Error) { + return c.json({ ok: false, error: `Invalid sessionID: ${sessionID}` }, 400) + } + if (nodePath.resolve(session.directory) !== nodePath.resolve(Instance.directory)) { + return c.json({ ok: false, error: "That session belongs to a different project directory." }, 409) + } + } try { const Manage = await import("../altimate/workspace/manage") // A changed skill snapshot reaches the registry at the start of the next turn @@ -1013,7 +1055,7 @@ export namespace Server { } }) .post("/altimate/workspace/sync", async (c) => { - const refused = workspaceRouteRefusal(c.req.header("origin")) + const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host")) if (refused) return c.json(refused.body, refused.status) try { const Manage = await import("../altimate/workspace/manage") diff --git a/packages/opencode/test/server/altimate-workspace-routes.test.ts b/packages/opencode/test/server/altimate-workspace-routes.test.ts index bb55b22ad8..8c006575b9 100644 --- a/packages/opencode/test/server/altimate-workspace-routes.test.ts +++ b/packages/opencode/test/server/altimate-workspace-routes.test.ts @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { Server } from "../../src/server/server" import * as Manage from "../../src/altimate/workspace/manage" +import { Session } from "../../src/session" +import { NotFoundError } from "../../src/storage/db" import { resetDatabase } from "./db" import { disposeAllInstances } from "../fixture/fixture" @@ -35,9 +37,10 @@ afterEach(async () => { describe("POST /altimate/workspace/refresh", () => { test("returns the refresh report and passes the session through", async () => { + spies.push(spyOn(Session, "get").mockResolvedValue({ directory: process.cwd() } as never)) const refresh = spyOn(Manage, "refresh").mockResolvedValue({ skillsChanged: true, - memory: { ok: true, status: "reloaded", blocks: 4 } as unknown as Manage.RefreshReport["memory"], + memory: { ok: true, status: "loaded", count: 4 }, errors: [], }) spies.push(refresh) @@ -52,6 +55,25 @@ describe("POST /altimate/workspace/refresh", () => { expect(refresh.mock.calls[0][1]).toBe("ses_123") }) + test("refuses a session that belongs to another project directory", async () => { + spies.push(spyOn(Session, "get").mockResolvedValue({ directory: "/somewhere/else" } as never)) + const refresh = spyOn(Manage, "refresh") + spies.push(refresh) + + const response = await post("/altimate/workspace/refresh", { sessionID: "ses_123" }) + expect(response.status).toBe(409) + expect(refresh).not.toHaveBeenCalled() + }) + + test("answers 404 for a session that does not exist", async () => { + spies.push(spyOn(Session, "get").mockRejectedValue(new NotFoundError({ message: "Session not found" }))) + const refresh = spyOn(Manage, "refresh") + spies.push(refresh) + + expect((await post("/altimate/workspace/refresh", { sessionID: "ses_123" })).status).toBe(404) + expect(refresh).not.toHaveBeenCalled() + }) + test("works without a body, leaving the memory overlay to reload on the next turn", async () => { const refresh = spyOn(Manage, "refresh").mockResolvedValue({ skillsChanged: false, @@ -66,12 +88,14 @@ describe("POST /altimate/workspace/refresh", () => { expect(refresh.mock.calls[0][1]).toBeUndefined() }) - test("ignores a session id that is not a string", async () => { - const refresh = spyOn(Manage, "refresh").mockResolvedValue({ skillsChanged: false, errors: [] }) + test("rejects a session id that is present but not a non-empty string", async () => { + const refresh = spyOn(Manage, "refresh") spies.push(refresh) - await post("/altimate/workspace/refresh", { sessionID: 42 }) - expect(refresh.mock.calls[0][1]).toBeUndefined() + // Falling back to "no session" would widen the refresh to every session's memory overlay. + expect((await post("/altimate/workspace/refresh", { sessionID: 42 })).status).toBe(400) + expect((await post("/altimate/workspace/refresh", { sessionID: "" })).status).toBe(400) + expect(refresh).not.toHaveBeenCalled() }) test("is refused outside the workspace pilot, without touching the snapshot", async () => { @@ -175,3 +199,13 @@ describe("POST /altimate/workspace/sync", () => { expect(sync).not.toHaveBeenCalled() }) }) + +describe("same-origin check used when a server password is set", () => { + test("accepts this server's own pages only", () => { + expect(Server.sameOrigin("http://127.0.0.1:4096", "127.0.0.1:4096")).toBe(true) + expect(Server.sameOrigin("https://evil.test", "127.0.0.1:4096")).toBe(false) + expect(Server.sameOrigin("http://127.0.0.1:9999", "127.0.0.1:4096")).toBe(false) + expect(Server.sameOrigin("null", "127.0.0.1:4096")).toBe(false) + expect(Server.sameOrigin("http://127.0.0.1:4096", undefined)).toBe(false) + }) +}) From 46c28950bcbd7df2c5e8f7dfe50c09fd6cd9686a Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 24 Sep 2026 15:22:53 +0530 Subject: [PATCH 4/4] fix(workspace): reserve 409 for the pilot gate; classify session lookup failures - A session from another directory is a bad request (400), so 409 means only "workspace mode is off" and a caller can act on the status alone. - `Session.get` validates synchronously: defer it into the promise chain so a malformed id is answered instead of escaping the route, and report a lookup failure that is not a validation error as a logged 500. - `workspaceRouteRefusal` takes the password as a parameter (defaulting to the flag) so the password-set origin policy is covered by tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/server/server.ts | 22 +++++++--- .../server/altimate-workspace-routes.test.ts | 40 ++++++++++++++++++- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 90c0339dc0..e2d070474a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -77,20 +77,24 @@ export namespace Server { const log = Log.create({ service: "server" }) // altimate_change start — shared gate for the `/altimate/workspace/*` routes /** Why a `/workspace` action must not run, or undefined when it may. + * + * 409 is reserved for the pilot gate, so a caller can tell "this server is not in workspace mode" + * apart from a bad request (400) without parsing the message. * * Outside the workspace pilot a skill sync purges the snapshot, so the flag is checked first. * A browser origin on an unsecured server is refused for the same reason as Altimate Base * registration: a CORS-allowed page is not a local process. Native clients (the extension host, * curl) send no Origin. With a server password set, a same-origin page may call; others may not. */ - function workspaceRouteRefusal( + export function workspaceRouteRefusal( origin: string | undefined, host: string | undefined, + password: string | undefined = Flag.OPENCODE_SERVER_PASSWORD, ): { status: 403 | 409; body: { ok: false; error: string } } | undefined { if (!CoreFlag.ALTIMATE_WORKSPACE) { return { status: 409, body: { ok: false, error: "Workspace mode is not enabled for this server." } } } if (!origin) return undefined - if (!Flag.OPENCODE_SERVER_PASSWORD) { + if (!password) { log.warn("refused browser-originated workspace action on an unsecured server", { origin }) return { status: 403, @@ -1031,15 +1035,23 @@ export namespace Server { // The memory reload loads THIS directory's workspace memory into the named session, so the // session must be one of this directory's; another project's would receive it. if (sessionID) { - const session = await Session.get(sessionID as never).catch((err) => err as Error) + // `Session.get` validates the id synchronously, so the call is deferred into the promise + // chain for a malformed id to land in the handler below rather than escape the route. + const session = await Promise.resolve() + .then(() => Session.get(sessionID as never)) + .catch((err) => err as Error) if (session instanceof NotFoundError) { return c.json({ ok: false, error: `Session not found: ${sessionID}` }, 404) } - if (session instanceof Error) { + if (session instanceof z.ZodError) { return c.json({ ok: false, error: `Invalid sessionID: ${sessionID}` }, 400) } + if (session instanceof Error) { + log.error("workspace refresh: session lookup failed", { error: session.message }) + return c.json({ ok: false, error: session.message }, 500) + } if (nodePath.resolve(session.directory) !== nodePath.resolve(Instance.directory)) { - return c.json({ ok: false, error: "That session belongs to a different project directory." }, 409) + return c.json({ ok: false, error: "That session belongs to a different project directory." }, 400) } } try { diff --git a/packages/opencode/test/server/altimate-workspace-routes.test.ts b/packages/opencode/test/server/altimate-workspace-routes.test.ts index 8c006575b9..c3d1ebd9ce 100644 --- a/packages/opencode/test/server/altimate-workspace-routes.test.ts +++ b/packages/opencode/test/server/altimate-workspace-routes.test.ts @@ -61,7 +61,28 @@ describe("POST /altimate/workspace/refresh", () => { spies.push(refresh) const response = await post("/altimate/workspace/refresh", { sessionID: "ses_123" }) - expect(response.status).toBe(409) + expect(response.status).toBe(400) + expect(refresh).not.toHaveBeenCalled() + }) + + test("reports a failed session lookup as a 500, not as a bad sessionID", async () => { + spies.push(spyOn(Session, "get").mockRejectedValue(new Error("database is locked"))) + const refresh = spyOn(Manage, "refresh") + spies.push(refresh) + + const response = await post("/altimate/workspace/refresh", { sessionID: "ses_123" }) + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ ok: false, error: "database is locked" }) + expect(refresh).not.toHaveBeenCalled() + }) + + test("an arbitrary session id is looked up for real and answered, never escaping the route", async () => { + const refresh = spyOn(Manage, "refresh") + spies.push(refresh) + + const response = await post("/altimate/workspace/refresh", { sessionID: "not-a-session" }) + expect(response.status).toBe(404) + expect(((await response.json()) as Record).ok).toBe(false) expect(refresh).not.toHaveBeenCalled() }) @@ -200,6 +221,23 @@ describe("POST /altimate/workspace/sync", () => { }) }) +describe("origin policy with a server password set", () => { + // The password flag is read once at module load, so the policy is exercised directly with one. + test("lets a native client (no Origin) and this server's own page through", () => { + expect(Server.workspaceRouteRefusal(undefined, "127.0.0.1:4096", "pw")).toBeUndefined() + expect(Server.workspaceRouteRefusal("http://127.0.0.1:4096", "127.0.0.1:4096", "pw")).toBeUndefined() + }) + + test("refuses another origin even though Basic credentials would be replayed", () => { + expect(Server.workspaceRouteRefusal("https://evil.test", "127.0.0.1:4096", "pw")?.status).toBe(403) + }) + + test("refuses every origin when no password is set", () => { + expect(Server.workspaceRouteRefusal("http://127.0.0.1:4096", "127.0.0.1:4096", undefined)?.status).toBe(403) + expect(Server.workspaceRouteRefusal(undefined, "127.0.0.1:4096", undefined)).toBeUndefined() + }) +}) + describe("same-origin check used when a server password is set", () => { test("accepts this server's own pages only", () => { expect(Server.sameOrigin("http://127.0.0.1:4096", "127.0.0.1:4096")).toBe(true)