diff --git a/DESKTOP_V2_COMPARISON.md b/DESKTOP_V2_COMPARISON.md index 924b7327..3573697a 100644 --- a/DESKTOP_V2_COMPARISON.md +++ b/DESKTOP_V2_COMPARISON.md @@ -4,11 +4,11 @@ This review compares: -- CodeNomad `DEV-v2` at `aef4452b`, plus the fixes recorded below. -- Official OpenCode Desktop V2 from `anomalyco/opencode` branch `upstream/v2` at `bc1f67e518` (2026-08-24). -- CodeNomad follows the latest `@opencode-ai/client@beta` contract; the managed CLI updater independently resolves and installs the latest CLI beta. +- CodeNomad `DEV-v2` through the fixes accompanying this document. +- Official OpenCode Desktop V2 from `anomalyco/opencode` branch `upstream/beta` at `eb1ac54d73` (2026-08-25). +- CodeNomad declares `@opencode-ai/client@beta`; the reviewed root lock resolves `0.0.0-beta-18219`. The managed CLI updater independently resolves and installs the latest CLI beta. -The official reference is `packages/desktop` for the Electron host, `packages/app` for the shared UI, and the V2 client, protocol, schema, server, and core packages for wire behavior. The old `upstream/opencode-2-0` branch and the intermediate `desktop-v2-*` branches are historical, not the current Desktop V2 reference. +The official reference is `packages/desktop` for the Electron host, `packages/app` for the shared UI, and the V2 client, protocol, schema, server, and core packages for wire behavior. Older `v2`, `opencode-2-0`, and intermediate `desktop-v2-*` branches are historical, not the current Desktop V2 reference. This is an incremental review. It does not repeat issues already closed by CodeNomad parity commits including `4b96f462`, `4359b4bf`, `249a96e7`, `ef70a8b7`, `affdb96f`, `e1987b9c`, `5484f9c9`, `b5f3fc6e`, `2c9ced63`, and `db0464f7`. @@ -16,7 +16,7 @@ This is an incremental review. It does not repeat issues already closed by CodeN CodeNomad implements the important V2 architecture rather than emulating the V1 desktop model. It uses the native client contract, locations, shared service, sessions, messages, Forms, permissions, providers, Shells, worktrees, and event stream. Its multi-window and cross-host restore implementation is broader than the official Electron-only desktop implementation. -The comparison found five concrete CodeNomad defects and one obsolete configuration block. All six are fixed in the commits accompanying this document. The remaining differences are scoped workflow defects, release hardening, or optional Desktop features. None requires restoring V1 code or replacing native V2 cursors. +The comparison and subsequent beta-contract audit found concrete CodeNomad defects in pagination, navigation, model projection, inbox delivery, follow behavior, background control, location selectors, and proxy route coverage. Those defects are fixed in the commits accompanying this document. The remaining differences are scoped workflow defects, release hardening, or optional Desktop features. None requires restoring V1 code or replacing native V2 cursors. ## Closed Findings @@ -72,12 +72,14 @@ The review reconfirmed these areas and found no current incompatibility: - Multiple logical CodeNomad workspaces over one native location, including duplicate-folder instances. - Project-wide session inventory with directory scoping for native `global` projects, native session and message cursors, replace-in-place resident history windows, and ancestor hydration. - Optimistic prompt admission with client-minted identity and authoritative event reconciliation. +- Native inbox queue/steer delivery with a persisted primary preference, inverse alternate shortcut, cancellation, and pending prompts ordered at the transcript tail. +- User-controlled follow state across older-page positioning and remote updates, with bottom pinning reserved for a local send. - Native Forms, permissions, provider authentication, commands, agents, variants, attachments, and instructions. - One upstream event stream with reconnect generation fencing and targeted authoritative refresh. -- Native background Shell listing, bounded output, removal, and ownership-checked Shell/PTY proxy routes. +- Native `session.background` control plus background Shell listing, bounded output, removal, and ownership-checked Shell/PTY proxy routes. - Root/worktree location ownership, physical-identity mutation fencing and session evacuation before worktree deletion, and WSL translation. - Independent multi-window tabs and content-addressed restore state across Electron and Tauri. -- Strict proxy route allowlisting, traversal protection, selector stripping, authentication isolation, and location ownership checks. +- Strict proxy route allowlisting, traversal protection, validated native workspace selectors, authentication isolation, and location ownership checks. Service stop removal is intentional: CodeNomad does not own the shared daemon. Upstream session sharing is disabled, so its absence is not a parity gap. Upstream's temporary SSE heartbeat change was reverted and requires no CodeNomad change. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 0a2aec5d..0e2cc66f 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -6,7 +6,7 @@ This branch replaces CodeNomad's OpenCode V1 SDK, custom plugin, and per-workspa The work grew beyond an SDK swap. It also introduces location-based ownership, native Forms and Shell resources, project-wide session pagination, reconnect reconciliation, bounded virtualized timelines, multi-window desktop state, and a content-addressed restore format. -Server and UI follow the latest `@opencode-ai/client@beta` contract. The selected `opencode2` CLI is managed independently: CodeNomad's updater resolves and installs the latest published CLI beta, while startup accepts another healthy CLI instead of enforcing an exact version. +Server and UI declare `@opencode-ai/client@beta`; the root lock currently resolves `0.0.0-beta-18219`. The selected `opencode2` CLI is managed independently: CodeNomad's updater resolves and installs the latest published CLI beta, while startup accepts another healthy CLI instead of enforcing an exact version. The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md). @@ -62,6 +62,7 @@ The incremental comparison with official OpenCode Desktop V2, including closed f - Bound instance logs and validate restore-state counts, IDs, paths, snapshots, string budgets, partition sizes, and graph sizes. - Reconcile only affected resources after native events or reconnects instead of periodically reloading full message history. - Keep optimistic prompts visible before native admission and replace temporary parts with authoritative native parts without duplicating output. +- Keep every pending inbox prompt after delivered transcript messages in native inbox admission order. A local send pins the new prompt to the bottom, while pagination and remote updates preserve a user's escaped follow state. - Reduce every native compaction delta in order while keeping intermediate chunks off the renderer path; project accumulated content at the next boundary and refresh immediately when compaction starts, ends, or fails. ## Forms, Permissions, and Providers @@ -94,7 +95,7 @@ Git status, diff, stage, unstage, commit, worktree creation, and worktree remova - Expose only reviewed method/path pairs; new upstream APIs are unavailable until explicitly allowlisted. - Verify workspace ownership for native locations, sessions, projects, cursors, Shell/PTY CWDs, imported session locations, and prompt file URIs before forwarding. -- Reject encoded path traversal, foreign locations/projects, forged cursors, and browser-supplied workspace selectors. +- Reject encoded path traversal, foreign locations/projects, forged cursors, and mismatched workspace selectors. Preserve validated native location workspace identities, including global Form headers, across the proxy. - Translate host/WSL paths only after ownership validation. - Strip CodeNomad cookies, browser authorization, forwarding headers, and incoming `x-opencode-*` headers; inject shared-service authentication server-side. - Block upstream cookies and authentication challenges and avoid logging unredacted secret-bearing request bodies. @@ -143,8 +144,8 @@ During stabilization, CodeNomad implements the public native V2 contract and rem | Delete-to-boundary / undo | `session.revert.stage` and `session.revert.clear` use V2 staged-revert semantics instead of arbitrary deletion. | Undo uses `revert.stage`. Redo/clear remains a CodeNomad UI gap, not a reason to restore V1 deletion. | Expose `revert.clear` through the existing native contract. | | Compaction | Native checkpoint compaction summarizes the older head and retains a server-selected recent tail controlled by `compaction.keep.tokens`. | Uses `session.compact`; there is no message-level selective compaction. CodeNomad displays the terminal summary without rendering every streamed delta. | Add scoped controls only if V2 defines scoped compaction semantics. | | Full-session search | Native message cursors exist, but there is no server search endpoint that returns message identity and position. | Retained through bounded cursor traversal while keeping only the 200-message resident window and collected matches. Large searches still fetch every page. | V2 exposes server search plus a rank/cursor navigation target. | -| Queued prompt management | Native inbox list, cancel, steer, and queue operations are available. | Implemented with authoritative steering, cancellation, safe edit replacement, draft preservation, and queue admission for follow-up prompts. | Add in-place editing and reordering when V2 exposes atomic inbox mutations; replacement currently moves an edited prompt to the queue tail. | -| Background execution | Native Shell resources support listing, bounded output, and removal, but do not reproduce every custom V1 process-manager control. | Replaced the custom manager with native Shells and removed unsupported controls such as rename. | Add controls only when native Shell APIs support them. | +| Queued prompt management | Native inbox list, cancel, steer, and queue operations are available. | Implements a persisted primary `steer`/`queue` preference, inverse alternate shortcut, delivery switching, cancellation with draft restoration, and ordered timeline projection. | Add in-place editing or reordering only when V2 exposes an atomic inbox mutation; neither is currently implemented. | +| Background execution | Native `session.background` moves blocking tools out of the foreground; Shell resources separately support listing, bounded output, and removal. | Exposes `session.background` through the proxy and UI with `Ctrl/Cmd+B`, and replaces the custom process manager with native Shells. Unsupported controls such as rename remain removed. | Add controls only when the native session or Shell APIs support them. | | Service lifecycle | V2 uses one shared externally owned service rather than one runtime per workspace. | CodeNomad discovers or starts the service but never exposes workspace stop or stops the daemon on shutdown. | No parity work planned unless V2 changes service ownership semantics. | This table is release-facing and must remain synchronized with the open migration pull request description whenever a capability is removed, restored, or becomes available in the current V2 client. diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 7ba95699..a98ce79b 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -135,7 +135,9 @@ async function harness( return pathMappings[candidate] ?? candidate }, getSharedServiceClient: async () => client, - ownsLocationWorkspace: (_id, workspaceID) => workspaceID === "owned-location", + ownsLocation: async (_id, location) => owned.has(location.directory) + && (!location.workspaceID || location.workspaceID === (location.directory.includes("worktree") ? "worktree-location" : "owned-location")), + ownsLocationWorkspace: async (_id, workspaceID) => workspaceID === "owned-location" || workspaceID === "worktree-location", ownsDirectory: async (_id, directory) => owned.has(directory), ownsPath: async (_id, candidate) => { pathOwnershipChecks.push(candidate) @@ -191,6 +193,37 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(bodyResponse.body, /internal-secret/) }) + it("preserves owned native workspace selectors and rejects mismatched pairs", async () => { + const { app, requestCount } = await harness() + const query = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/model?location%5Bdirectory%5D=%2Frepo%2Fworktree&location%5Bworkspace%5D=worktree-location", + }) + assert.equal(query.statusCode, 200) + assert.match(JSON.parse(query.body).url, /location%5Bworkspace%5D=worktree-location/) + + const create = await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session", + payload: { location: { directory: "/repo", workspaceID: "owned-location" } }, + }) + assert.equal(create.statusCode, 200) + assert.equal(JSON.parse(create.body).body.location.workspaceID, "owned-location") + + const foreign = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/model?location%5Bdirectory%5D=%2Frepo%2Fworktree&location%5Bworkspace%5D=owned-location", + }) + assert.equal(foreign.statusCode, 403) + + const unsupported = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/model?workspace=owned-location", + }) + assert.equal(unsupported.statusCode, 400) + assert.equal(requestCount(), 2) + }) + it("rejects session admission while a worktree deletion is pending", async () => { const { app, worktreeDeletionFence, requestCount } = await harness() let release!: () => void @@ -357,12 +390,13 @@ describe("instance proxy location enforcement", () => { const ownedCursor = cursor({ directory: "/repo/worktree", anchor: { id: "session-1", time: 1, direction: "next" } }) const response = await app.inject({ method: "GET", - url: `/workspaces/workspace/instance/api/session?cursor=${ownedCursor}&directory=%2Fother`, + url: `/workspaces/workspace/instance/api/session?cursor=${ownedCursor}&directory=%2Fother&workspace=foreign-location`, }) assert.equal(response.statusCode, 200) const upstreamUrl = JSON.parse(response.body).url as string assert.match(upstreamUrl, new RegExp(`cursor=${ownedCursor}`)) assert.doesNotMatch(upstreamUrl, /directory=/) + assert.doesNotMatch(upstreamUrl, /workspace=/) const forgedCursor = cursor({ directory: "/other", anchor: { id: "session-1", time: 1, direction: "next" } }) assert.equal((await app.inject({ @@ -503,14 +537,14 @@ describe("instance proxy location enforcement", () => { assert.deepEqual(sessionGets, ["global"]) }) - it("forwards a validated global Form root location instead of browser routing headers", async () => { + it("forwards a validated global Form root location and workspace", async () => { const { app } = await harness("/repo/worktree", {}, {}, "/repo", "/srv/repo") const response = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/global/form/form-1/reply", headers: { "x-opencode-directory": encodeURIComponent("/repo"), - "x-opencode-workspace": "untrusted-workspace", + "x-opencode-workspace": "owned-location", }, payload: { answers: {} }, }) @@ -518,7 +552,7 @@ describe("instance proxy location enforcement", () => { assert.equal(response.statusCode, 200) const upstream = JSON.parse(response.body) assert.equal(upstream.headers["x-opencode-directory"], encodeURIComponent("/srv/repo")) - assert.equal(upstream.headers["x-opencode-workspace"], undefined) + assert.equal(upstream.headers["x-opencode-workspace"], "owned-location") }) it("translates and forwards a validated global Form worktree location", async () => { @@ -553,6 +587,15 @@ describe("instance proxy location enforcement", () => { headers: { "x-opencode-directory": "%ZZ" }, payload: { answers: {} }, })).statusCode, 400) + assert.equal((await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/global/form/form-1/reply", + headers: { + "x-opencode-directory": encodeURIComponent("/repo"), + "x-opencode-workspace": "foreign-location", + }, + payload: { answers: {} }, + })).statusCode, 403) assert.equal(requestCount(), 0) }) @@ -617,10 +660,17 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 0) }) - it("allows native inbox management for owned sessions", async () => { + it("allows native pending-state management for owned sessions", async () => { const { app } = await harness() const requests = [ ["GET", "/workspaces/workspace/instance/api/session/owned/inbox"], + ["GET", "/workspaces/workspace/instance/api/session/owned/permission"], + ["GET", "/workspaces/workspace/instance/api/session/owned/form"], + ["POST", "/workspaces/workspace/instance/api/session/owned/background"], + ["GET", "/workspaces/workspace/instance/api/skill"], + ["GET", "/workspaces/workspace/instance/api/reference"], + ["GET", "/workspaces/workspace/instance/api/mcp/resource"], + ["GET", "/workspaces/workspace/instance/api/websearch/provider"], ["DELETE", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1"], ["POST", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1/steer"], ["POST", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1/queue"], diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 564edd85..8e686a7f 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -9,7 +9,7 @@ import { connect as connectTls, type TLSSocket } from "tls" import { fetch, type Headers } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" -import { isPtyNotFoundError, isSessionNotFoundError, isShellNotFoundError, type OpenCodeClient } from "@opencode-ai/client" +import { isPtyNotFoundError, isSessionNotFoundError, isShellNotFoundError, type LocationRef, type OpenCodeClient } from "@opencode-ai/client" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -371,7 +371,8 @@ export interface InstanceProxyWorkspaceManager { getWorktreeIdentityForPath(id: string, directory: string): Promise getServicePathForPath?(id: string, candidate: string): Promise getSharedServiceClient(): Promise - ownsLocationWorkspace(id: string, workspaceID: string): boolean + ownsLocation(id: string, location: LocationRef): ReturnType + ownsLocationWorkspace(id: string, workspaceID: string): ReturnType ownsDirectory(id: string, directory: string): Promise ownsPath(id: string, candidate: string): Promise } @@ -628,32 +629,44 @@ async function proxyWorkspaceRequest(args: { } const sessionListHasScope = request.method === "GET" && pathname.replace(/\/+$/, "") === "/api/session" - && (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project")) + && (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project") || targetUrl.searchParams.has("workspace")) const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId) if (sessionListScope !== "allowed") { reply.code(sessionListScope === "invalid" ? 400 : 403).send({ error: "Session list does not belong to workspace" }) return } const serviceDirectory = workspaceManager.getServiceDirectory?.(workspaceId) ?? workspace.path - let globalFormDirectory: string | undefined + let globalFormLocation: LocationRef | undefined if (isGlobalFormAction(pathname, request.method)) { - const header = request.headers["x-opencode-directory"] - if (Array.isArray(header)) { + const directoryHeader = request.headers["x-opencode-directory"] + const workspaceHeader = request.headers["x-opencode-workspace"] + if (Array.isArray(directoryHeader) || Array.isArray(workspaceHeader)) { reply.code(400).send({ error: "Invalid Form location" }) return } - if (header !== undefined) { + let directory: string | undefined + if (directoryHeader !== undefined) { try { - globalFormDirectory = decodeURIComponent(header) + directory = decodeURIComponent(directoryHeader) } catch { reply.code(400).send({ error: "Invalid Form location" }) return } - if (!globalFormDirectory.trim()) { + if (!directory.trim()) { reply.code(400).send({ error: "Invalid Form location" }) return } } + if (workspaceHeader !== undefined && !workspaceHeader.trim()) { + reply.code(400).send({ error: "Invalid Form location" }) + return + } + if (directory !== undefined || workspaceHeader !== undefined) { + globalFormLocation = { + directory: directory ?? workspace.path, + ...(workspaceHeader ? { workspaceID: workspaceHeader } : {}), + } + } } const imported = prepareSessionImport( pathname, @@ -661,13 +674,19 @@ async function proxyWorkspaceRequest(args: { stripLocationSelectors(targetUrl, request.body, workspace.path, serviceDirectory), serviceDirectory, ) - const requestLocations = readRequestDirectories(targetUrl, imported.body) - if (globalFormDirectory) requestLocations.directories.push(globalFormDirectory) + const requestLocations = readRequestDirectories(targetUrl, imported.body, workspace.path) + if (globalFormLocation) { + requestLocations.directories.push(globalFormLocation.directory) + requestLocations.locations.push(globalFormLocation) + } requestLocations.directories.push(...imported.directories) + requestLocations.locations.push(...imported.locations) requestLocations.invalid ||= imported.invalid readNativeCwd(targetUrl, imported.body, requestLocations) const promptFiles = readPromptFilePaths(pathname, request.method, imported.body) - if (requestLocations.invalid || !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories))) { + if (requestLocations.invalid + || !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories)) + || !(await allLocationsOwned(workspaceManager, workspaceId, requestLocations.locations))) { reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" }) return } @@ -801,8 +820,9 @@ async function proxyWorkspaceRequest(args: { ...(body !== request.body ? { body } : {}), rewriteRequestHeaders: (_originalRequest, headers) => { const outgoingHeaders = sanitizeInstanceProxyRequestHeaders(headers, instanceAuthHeader) - if (globalFormDirectory) { - outgoingHeaders["x-opencode-directory"] = encodeURIComponent(translatedDirectories.get(globalFormDirectory)!) + if (globalFormLocation) { + outgoingHeaders["x-opencode-directory"] = encodeURIComponent(translatedDirectories.get(globalFormLocation.directory)!) + if (globalFormLocation.workspaceID) outgoingHeaders["x-opencode-workspace"] = globalFormLocation.workspaceID } if (logger.isLevelEnabled("trace")) { @@ -857,8 +877,13 @@ function appendIncomingQuery(targetUrl: URL, incomingUrl: string): URL { return targetUrl } -function readRequestDirectories(targetUrl: URL, body: unknown): { directories: string[]; invalid: boolean } { +function readRequestDirectories( + targetUrl: URL, + body: unknown, + defaultDirectory: string, +): { directories: string[]; locations: LocationRef[]; invalid: boolean } { const directories: string[] = [] + const locations: LocationRef[] = [] let invalid = false for (const key of ["location[directory]", "directory"]) { for (const value of targetUrl.searchParams.getAll(key)) { @@ -866,6 +891,16 @@ function readRequestDirectories(targetUrl: URL, body: unknown): { directories: s else invalid = true } } + const queryWorkspaces = targetUrl.searchParams.getAll("location[workspace]") + const queryDirectories = targetUrl.searchParams.getAll("location[directory]") + if (queryWorkspaces.length > 1 || queryDirectories.length > 1) invalid = true + if (queryWorkspaces.length === 1) { + const workspaceID = queryWorkspaces[0] + if (!workspaceID.trim()) invalid = true + else locations.push({ directory: queryDirectories[0] ?? defaultDirectory, workspaceID }) + } + if (targetUrl.pathname.replace(/\/+$/, "") !== "/api/session" && targetUrl.searchParams.has("workspace")) invalid = true + if (targetUrl.searchParams.has("workspaceID") || targetUrl.searchParams.has("location[workspaceID]")) invalid = true if (body && typeof body === "object" && !Array.isArray(body) && !Buffer.isBuffer(body)) { const input = body as Record @@ -876,21 +911,35 @@ function readRequestDirectories(targetUrl: URL, body: unknown): { directories: s if ("location" in input) { const location = input.location if (location && typeof location === "object" && !Array.isArray(location)) { - const directory = (location as Record).directory + const source = location as Record + const directory = source.directory if (typeof directory === "string" && directory.trim()) directories.push(directory) else invalid = true + if (source.workspace !== undefined) invalid = true + if (source.workspaceID !== undefined) { + if (typeof source.workspaceID === "string" && source.workspaceID.trim() && typeof directory === "string" && directory.trim()) { + locations.push({ directory, workspaceID: source.workspaceID }) + } else invalid = true + } } else if (location !== null && location !== undefined) { invalid = true } } + if (/^\/api\/session\/[^/]+\/move\/?$/.test(targetUrl.pathname) && "workspaceID" in input) { + if (typeof input.workspaceID === "string" && input.workspaceID.trim() && typeof input.directory === "string" && input.directory.trim()) { + locations.push({ directory: input.directory, workspaceID: input.workspaceID }) + } else invalid = true + } else if ("workspaceID" in input) { + invalid = true + } } - return { directories, invalid } + return { directories, locations, invalid } } function readNativeCwd( targetUrl: URL, body: unknown, - locations: { directories: string[]; invalid: boolean }, + locations: { directories: string[]; locations: LocationRef[]; invalid: boolean }, ) { if (!/^\/api\/(?:shell|pty)\/?$/.test(targetUrl.pathname) || !body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return const input = body as Record @@ -941,6 +990,11 @@ async function allDirectoriesOwned(manager: InstanceProxyWorkspaceManager, works return (await Promise.all(directories.map((directory) => manager.ownsDirectory(workspaceId, directory)))).every(Boolean) } +async function allLocationsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, locations: LocationRef[]) { + const unique = new Map(locations.map((location) => [`${location.directory}\0${location.workspaceID ?? ""}`, location])) + return (await Promise.all([...unique.values()].map((location) => manager.ownsLocation(workspaceId, location)))).every(Boolean) +} + async function allPathsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, paths: string[]) { return (await Promise.all(paths.map((candidate) => manager.ownsPath(workspaceId, candidate)))).every(Boolean) } @@ -990,18 +1044,30 @@ async function authorizeSessionList( if (cursors.length === 1) { const scope = decodeSessionListCursor(cursors[0]) if (!scope) return "invalid" - for (const key of ["directory", "location[directory]", "project", "subpath"]) targetUrl.searchParams.delete(key) + for (const key of ["directory", "workspace", "location[directory]", "location[workspace]", "project", "subpath"]) { + targetUrl.searchParams.delete(key) + } return ownsSessionListScope(manager, workspaceId, scope) } const projects = targetUrl.searchParams.getAll("project") const subpaths = targetUrl.searchParams.getAll("subpath") - if (projects.length > 1 || subpaths.length > 1 || (subpaths.length && !projects.length)) return "invalid" - if (!projects.length) return "allowed" + const workspaces = targetUrl.searchParams.getAll("workspace") + const directories = targetUrl.searchParams.getAll("directory") + if (projects.length > 1 || subpaths.length > 1 || workspaces.length > 1 || directories.length > 1 + || (subpaths.length && !projects.length)) return "invalid" + const workspace = workspaces[0] + const directory = directories[0] + if ((workspace !== undefined && !workspace.trim()) || (directory !== undefined && !directory.trim())) return "invalid" + if (!projects.length) { + if (workspace && directory) return ownsSessionListScope(manager, workspaceId, { workspace, directory }) + if (workspace) return ownsSessionListScope(manager, workspaceId, { workspace }) + return "allowed" + } const project = projects[0] const subpath = subpaths[0] if (!project || (subpath !== undefined && !isSafeRelativePath(subpath))) return "invalid" - return ownsSessionListScope(manager, workspaceId, { project, subpath }) + return ownsSessionListScope(manager, workspaceId, { ...(workspace ? { workspace } : {}), project, subpath }) } type SessionListScope = { @@ -1053,9 +1119,13 @@ async function ownsSessionListScope( workspaceId: string, scope: SessionListScope, ): Promise<"allowed" | "foreign"> { - if (scope.workspace && !manager.ownsLocationWorkspace(workspaceId, scope.workspace)) return "foreign" - if (scope.directory) return await manager.ownsDirectory(workspaceId, scope.directory) ? "allowed" : "foreign" - if (!scope.project) return scope.workspace ? "allowed" : "foreign" + if (scope.directory) { + const owned = scope.workspace + ? await manager.ownsLocation(workspaceId, { directory: scope.directory, workspaceID: scope.workspace }) + : await manager.ownsDirectory(workspaceId, scope.directory) + return owned ? "allowed" : "foreign" + } + if (!scope.project) return scope.workspace && await manager.ownsLocationWorkspace(workspaceId, scope.workspace) ? "allowed" : "foreign" const project = (await (await manager.getSharedServiceClient()).project.list()).find((candidate) => candidate.id === scope.project) if (!project) return "foreign" const directory = scope.subpath === undefined @@ -1063,7 +1133,10 @@ async function ownsSessionListScope( : /^[A-Za-z]:[\\/]|^\\\\/.test(project.canonical) ? path.win32.resolve(project.canonical, scope.subpath) : path.posix.resolve(project.canonical, scope.subpath) - return await manager.ownsDirectory(workspaceId, directory) ? "allowed" : "foreign" + const owned = scope.workspace + ? await manager.ownsLocation(workspaceId, { directory, workspaceID: scope.workspace }) + : await manager.ownsDirectory(workspaceId, directory) + return owned ? "allowed" : "foreign" } function getPtyRouteId(pathname: string): string | null { @@ -1105,7 +1178,8 @@ function hasDotSegment(value: string): boolean { function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { const route = pathname.replace(/\/+$/, "") const allowed: Array<[string, RegExp]> = [ - ["GET", /^\/api\/(?:agent|command|config|integration|location|mcp|model|plugin|provider)$/], + ["GET", /^\/api\/(?:agent|command|config|integration|location|mcp|model|plugin|provider|reference|skill)$/], + ["GET", /^\/api\/(?:mcp\/resource|websearch\/provider)$/], ["GET", /^\/api\/agent\/[^/]+$/], ["GET", /^\/api\/model\/default$/], ["GET", /^\/api\/(?:permission|question)\/request$/], @@ -1130,9 +1204,10 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { ["POST", /^\/api\/session(?:\/import)?$/], ["GET", /^\/api\/session\/[^/]+(?:\/message(?:\/[^/]+)?)?$/], ["GET", /^\/api\/session\/[^/]+\/inbox$/], + ["GET", /^\/api\/session\/[^/]+\/(?:permission|form)$/], ["DELETE", /^\/api\/session\/[^/]+$/], ["DELETE", /^\/api\/session\/[^/]+\/inbox\/[^/]+$/], - ["POST", /^\/api\/session\/[^/]+\/(?:agent|model|rename|move|prompt|command|shell|compact|interrupt|fork)$/], + ["POST", /^\/api\/session\/[^/]+\/(?:agent|model|rename|move|prompt|command|shell|compact|interrupt|background|fork)$/], ["POST", /^\/api\/session\/[^/]+\/inbox\/[^/]+\/(?:steer|queue)$/], ["POST", /^\/api\/session\/[^/]+\/revert\/stage$/], ["PUT", /^\/api\/session\/[^/]+\/instructions\/entries\/[^/]+$/], @@ -1202,9 +1277,6 @@ function stripLocationSelectors(targetUrl: URL, body: unknown, workspaceDirector for (const value of values) targetUrl.searchParams.append(key, value === workspaceDirectory ? serviceDirectory : value) } } - for (const key of ["workspace", "workspaceID", "location[workspace]", "location[workspaceID]"]) { - targetUrl.searchParams.delete(key) - } if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return body const input = body as Record const canonicalInput = { ...input } @@ -1213,7 +1285,7 @@ function stripLocationSelectors(targetUrl: URL, body: unknown, workspaceDirector } const location = input.location if (!location || typeof location !== "object" || Array.isArray(location) || Buffer.isBuffer(location)) return canonicalInput - const { workspace: _workspace, workspaceID: _workspaceID, ...canonicalLocation } = location as Record + const canonicalLocation = { ...(location as Record) } if (canonicalLocation.directory === workspaceDirectory) canonicalLocation.directory = serviceDirectory return { ...canonicalInput, location: canonicalLocation } } @@ -1285,7 +1357,7 @@ function replacePromptFileUris(body: unknown, replacements: ReadonlyMap if (location.directory === null || location.directory === undefined) location.directory = directory - if (typeof location.directory === "string" && location.directory.trim()) result.directories.push(location.directory) - else result.invalid = true + if (typeof location.directory !== "string" || !location.directory.trim()) { + result.invalid = true + return + } + result.directories.push(location.directory) + if (location.workspace !== undefined) result.invalid = true + if (location.workspaceID !== undefined) { + if (typeof location.workspaceID === "string" && location.workspaceID.trim()) { + result.locations.push({ directory: location.directory, workspaceID: location.workspaceID }) + } else result.invalid = true + } } addLocation(input, "location") diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 5508d16e..cac6ca1e 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -31,6 +31,7 @@ class ControlledSharedService { afterValidation?: () => void headerFailures = 0 validationCalls: Array<{ location: LocationRef; options?: OpenCodeSharedServiceOptions }> = [] + debugLocations: LocationRef[] = [] shutdownCalls = 0 shutdownGate?: ReturnType> shutdownTimeouts: number[] = [] @@ -44,7 +45,7 @@ class ControlledSharedService { } async client() { - return {} as OpenCodeClient + return { debug: { location: { list: async () => this.debugLocations } } } as OpenCodeClient } async headers() { @@ -118,6 +119,21 @@ function createHarness(service = new ControlledSharedService(), overrides: Recor } describe("workspace manager shared service lifecycle", () => { + it("validates native workspace identity against an owned directory", async () => { + const service = new ControlledSharedService() + service.debugLocations = [ + { directory: process.cwd(), workspaceID: "worktree-location" }, + { directory: path.join(path.parse(process.cwd()).root, "__codenomad_foreign__"), workspaceID: "mismatch-location" }, + ] + const { manager } = createHarness(service) + const workspace = await manager.create(process.cwd()) + + assert.equal(await manager.ownsLocation(workspace.workspace.id, { directory: process.cwd(), workspaceID: "location-1" }), true) + assert.equal(await manager.ownsLocation(workspace.workspace.id, { directory: process.cwd(), workspaceID: "foreign" }), false) + assert.equal(await manager.ownsLocation(workspace.workspace.id, { directory: process.cwd(), workspaceID: "mismatch-location" }), false) + assert.equal(await manager.ownsLocationWorkspace(workspace.workspace.id, "worktree-location"), true) + }) + it("distinguishes WSL service paths from Windows host paths", () => { assert.equal(isWindowsHostPath("/mnt/d/repo"), false) assert.equal(isWindowsHostPath("D:\\repo"), true) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 4ff9b8c6..92a85d7a 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -222,9 +222,41 @@ export class WorkspaceManager { return Boolean(hostDirectory && await this.ownsHostDirectory(record, hostDirectory)) } - ownsLocationWorkspace(id: string, workspaceID: string): boolean { + async ownsLocation(id: string, location: LocationRef): Promise { const record = this.workspaces.get(id) - return Boolean(record?.[WORKSPACE_STATE].published && record.location?.workspaceID === workspaceID) + if (!record?.[WORKSPACE_STATE].published) return false + const requested = await this.resolveOwnedWorktree(record, location.directory) + if (!requested) return false + if (!location.workspaceID) return true + try { + const candidates = record.location?.workspaceID === location.workspaceID + ? [record.location] + : (await (await this.sharedService.client(record[WORKSPACE_STATE].serviceOptions)).debug.location.list()) + .filter((candidate) => candidate.workspaceID === location.workspaceID) + for (const candidate of candidates) { + const resolved = await this.resolveOwnedWorktree(record, candidate.directory) + if (resolved && canonicalWorktreeIdentity(resolved.worktreeDirectory, this.options.platform) + === canonicalWorktreeIdentity(requested.worktreeDirectory, this.options.platform)) return true + } + return false + } catch { + return false + } + } + + async ownsLocationWorkspace(id: string, workspaceID: string): Promise { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published) return false + if (record.location?.workspaceID === workspaceID) return true + try { + const locations = await (await this.sharedService.client(record[WORKSPACE_STATE].serviceOptions)).debug.location.list() + for (const location of locations) { + if (location.workspaceID === workspaceID && await this.ownsDirectory(id, location.directory)) return true + } + return false + } catch { + return false + } } async getServiceDirectoryForPath(id: string, directory: string): Promise { diff --git a/packages/ui/src/components/message-history-pagination.test.ts b/packages/ui/src/components/message-history-pagination.test.ts index 669bd2f6..ac226848 100644 --- a/packages/ui/src/components/message-history-pagination.test.ts +++ b/packages/ui/src/components/message-history-pagination.test.ts @@ -99,6 +99,25 @@ describe("message history pagination", () => { assert.notEqual(getMessageWindowPageKey(older), getMessageWindowPageKey(next)) }) + it("keeps follow escaped after positioning an older resident page", () => { + const source = fs.readFileSync(new URL("./message-section.tsx", import.meta.url), "utf8") + const start = source.indexOf("async function pageWindow(") + const paging = source.slice(start, source.indexOf("function messageWindowPageKey", start)) + const position = paging.indexOf("after(api)") + + assert.ok(position >= 0) + assert.ok(paging.indexOf('api.setAutoScroll(direction === "latest")', position) > position) + }) + + it("keeps native inbox prompts after delivered transcript messages", () => { + const source = fs.readFileSync(new URL("./message-section.tsx", import.meta.url), "utf8") + const start = source.indexOf("const visibleMessageIds") + const projection = source.slice(start, source.indexOf("const sessionRevision", start)) + + assert.match(projection, /visible\.filter\(\(messageId\) => !props\.pendingPrompts!\.has\(messageId\)\)/) + assert.match(projection, /Array\.from\(props\.pendingPrompts\.keys\(\)\)\.filter\(\(messageId\) => visibleSet\.has\(messageId\)\)/) + }) + it("uses a finite ceiling and leaves failed pages retryable", async () => { let attempts = 0 const options = { diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 52d9d52d..8a813f4d 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -79,7 +79,7 @@ export default function MessageSection(props: MessageSectionProps) { const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId)) const visibleMessageIds = createMemo(() => { const resolvedStore = store() - return messageIds().filter((messageId) => { + const visible = messageIds().filter((messageId) => { const record = resolvedStore.getMessage(messageId) if (!record) return false @@ -103,6 +103,12 @@ export default function MessageSection(props: MessageSectionProps) { const timeInfo = info.time as { created: number; completed?: number } | undefined return Boolean(timeInfo && (timeInfo.completed === undefined || timeInfo.completed === 0)) }) + if (!props.pendingPrompts?.size) return visible + const visibleSet = new Set(visible) + return [ + ...visible.filter((messageId) => !props.pendingPrompts!.has(messageId)), + ...Array.from(props.pendingPrompts.keys()).filter((messageId) => visibleSet.has(messageId)), + ] }) const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId)) @@ -723,6 +729,7 @@ export default function MessageSection(props: MessageSectionProps) { await waitTwoFrames() if (!isCurrent()) return after(api) + api.setAutoScroll(direction === "latest") api.notifyContentRendered() } catch (error) { if (isCurrent()) { diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index 9166b4d5..17bf6362 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -292,12 +292,9 @@ export default function ModelSelector(props: ModelSelectorProps) { setManualAll(false) } - const showAllModels = () => { - setManualAll(true) - setExplicitFavorites(false) - setAutoFavoritesEligibleAtOpen(false) - setTimeout(() => searchInputRef?.focus(), 0) - } + const favoritesToggleLabel = () => t(favoritesOnlyEnabled() + ? "modelSelector.favoritesOnly.showAll" + : "modelSelector.favoritesOnly.toggle.ariaLabel") return ( diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 824e368c..0914a271 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -1,9 +1,9 @@ import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js" -import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Paperclip, Volume2, X } from "lucide-solid" +import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Minimize2, Paperclip, Volume2, X } from "lucide-solid" import ExpandButton from "./expand-button" import { clearAttachments, removeAttachment } from "../stores/attachments" import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders" -import { preparePromptSubmission } from "./prompt-input/submitPrompt" +import { preparePromptSubmission, resolvePromptDelivery } from "./prompt-input/submitPrompt" import { focusConversationStream } from "./focus-conversation" import Kbd from "./kbd" import { getActiveInstance } from "../stores/instances" @@ -459,10 +459,17 @@ export default function PromptInput(props: PromptInputProps) { resizeDragState = undefined }) + const promptDelivery = (alternate = false) => resolvePromptDelivery( + Boolean(props.isSessionBusy || submissionsInFlight > 0), + preferences().followUpBehavior, + alternate, + ) + async function handleSend(delivery?: PromptDelivery) { const text = prompt().trim() const currentAttachments = attachments() if (props.disabled || (!text && currentAttachments.length === 0)) return + const resolvedDelivery = delivery ?? promptDelivery() const isShellMode = mode() === "shell" @@ -535,7 +542,7 @@ export default function PromptInput(props: PromptInputProps) { await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs) } } else { - await props.onSend(submitPrompt, currentAttachments, delivery ?? "steer") + await props.onSend(submitPrompt, currentAttachments, resolvedDelivery) } } catch (error) { log.error("Failed to send message:", error) @@ -558,6 +565,14 @@ export default function PromptInput(props: PromptInputProps) { void props.onAbortSession() } + const canBackground = () => Boolean(props.isSessionBusy && props.onBackgroundSession) + + function handleBackground() { + if (!canBackground()) return false + void props.onBackgroundSession?.() + return true + } + function handleExpandToggle(nextState: "normal" | "expanded") { setInputHeight(null) setExpandState(nextState) @@ -741,7 +756,8 @@ export default function PromptInput(props: PromptInputProps) { getAttachments: attachments, removeAttachment: (attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId), submitOnEnter, - onSend: (alternate) => void handleSend(alternate && (props.isSessionBusy || submissionsInFlight > 0) ? "queue" : "steer"), + onSend: (alternate) => void handleSend(promptDelivery(Boolean(alternate))), + onBackground: handleBackground, selectPreviousHistory: (force) => selectPreviousHistory({ force, isPickerOpen: showPicker(), getTextarea: () => textareaRef ?? null }), selectNextHistory: (force) => @@ -1069,13 +1085,24 @@ export default function PromptInput(props: PromptInputProps) { + + +