-
Notifications
You must be signed in to change notification settings - Fork 135
feat(workspace): expose /workspace Refresh and Sync over serve HTTP
#1366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
20b7824
8a83696
8af7760
46c2895
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -45,6 +45,10 @@ import { FreeTierConsent } from "../altimate/free/consent" | |||||
| import { InstanceStore } from "@/project/instance-store" | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The sync route's 500 error mapping ( Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in 8a83696: a sync test that asserts a thrown error returns 500 |
||||||
| import { AppRuntime } from "@/effect/app-runtime" | ||||||
| // altimate_change end | ||||||
| // 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" | ||||||
|
|
@@ -71,6 +75,51 @@ 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. | ||||||
| * | ||||||
| * 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. */ | ||||||
| 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 (!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.", | ||||||
| }, | ||||||
| } | ||||||
| } | ||||||
| // 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, | ||||||
| // because a cache built before a background registration finished cannot be told apart from one | ||||||
|
|
@@ -949,6 +998,88 @@ 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. | ||||||
| .post("/altimate/workspace/refresh", async (c) => { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The pilot gate is bypassed when control-plane workspace routing is enabled: Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not changing this. |
||||||
| 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; 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 { | ||||||
| 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<string, unknown>).sessionID | ||||||
| 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) { | ||||||
| // `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 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) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- server route ---'
sed -n '1015,1060p' packages/opencode/src/server/server.ts
printf '%s\n' '--- related test ---'
sed -n '55,85p' packages/opencode/test/server/altimate-workspace-routes.test.ts
printf '%s\n' '--- focused diff ---'
git diff --unified=25 10fa4610f457cb292eb8a1823901223b8738b96a4 46c28950bcbd7df2c5e8f7dfe50c09fd6cd9686a -- packages/opencode/src/server/server.ts packages/opencode/test/server/altimate-workspace-routes.test.tsRepository: AltimateAI/altimate-code Length of output: 28311 🏁 Script executed: set -eu
sed -n '1015,1060p' packages/opencode/src/server/server.ts
sed -n '55,85p' packages/opencode/test/server/altimate-workspace-routes.test.ts
git diff --unified=25 10fa4610f457cb292eb8a1823901223b8738b96a4 46c28950bcbd7df2c5e8f7dfe50c09fd6cd9686a -- packages/opencode/src/server/server.ts packages/opencode/test/server/altimate-workspace-routes.test.tsRepository: AltimateAI/altimate-code Length of output: 28248 Information Disclosure Reachability: External Return a fixed message for unexpected session-lookup failures. The handler logs Suggested fix- return c.json({ ok: false, error: session.message }, 500)
+ return c.json({ ok: false, error: "Session lookup failed." }, 500)- expect(await response.json()).toEqual({ ok: false, error: "database is locked" })
+ expect(await response.json()).toEqual({ ok: false, error: "Session lookup failed." })📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Learnings |
||||||
| } | ||||||
| if (nodePath.resolve(session.directory) !== nodePath.resolve(Instance.directory)) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Keep the session-directory check valid until the overlay is installed
Reply with |
||||||
| return c.json({ ok: false, error: "That session belongs to a different project directory." }, 400) | ||||||
| } | ||||||
| } | ||||||
| try { | ||||||
| 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) | ||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||
| 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) => { | ||||||
| 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") | ||||||
| const report = await Manage.sync(Instance.directory) | ||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||
| 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 | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| // 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<ReturnType<typeof AltimateApi.getCredentials>> | ||
|
|
||
| const PIN_VARS = [ | ||
| "ALTIMATE_CODE_SERVE", | ||
| "ALTIMATE_PINNED_WORKSPACE_ID", | ||
| "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. */ | ||
| 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) { | ||
| // 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(() => { | ||
| process.env.ALTIMATE_WORKSPACE = "1" | ||
| __resetPinValidation() | ||
| resetEnablementMemoForTests() | ||
| ;(AltimateApi as unknown as { isConfigured: () => Promise<boolean> }).isConfigured = async () => true | ||
| ;(AltimateApi as unknown as { getCredentials: () => Promise<Creds> }).getCredentials = async () => | ||
| ({ altimateInstanceName: "acme", altimateUrl: "https://api.test", altimateApiKey: "k" }) as Creds | ||
| ;(WorkspaceApi as unknown as { listDatamates: () => Promise<unknown> }).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 | ||
| }) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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 | ||
| 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 }) | ||
| }) | ||
|
|
||
| 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) | ||
| // 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("pin-unresolved") | ||
| }) | ||
| }) | ||
|
|
||
| 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") | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: This validates the canonical pin path, then reads blocks from the raw
directoryafter an asynchronous network check. A symlink swap can make the sync read another checkout’s blocks and upload them to the pinned workspace; carry the canonical directory through the operation and use it for the local read.Prompt for AI agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not changing this here.
Manage.syncread blocks from the rawdirectorybefore this PR as well; the existing link path validates in the same order. Exploiting it requires write access to the user's own checkout during the sync. Carrying the canonical path would also change theMemoryStorekey blocks are stored under. I'd rather do that as a separate change covering both paths, if we want it.