diff --git a/apps/desktop/src/main/lib/auto-updater.test.ts b/apps/desktop/src/main/lib/auto-updater.test.ts index aabf134b3f4..4f8c8f84133 100644 --- a/apps/desktop/src/main/lib/auto-updater.test.ts +++ b/apps/desktop/src/main/lib/auto-updater.test.ts @@ -41,9 +41,9 @@ mock.module("main/index", () => ({ // shape here defensively to avoid order-dependent breakage. mock.module("electron-log/main", () => ({ default: { - info: () => {}, - warn: () => {}, - error: () => {}, + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), transports: { file: { level: "info" } }, }, })); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 7ccc6122c1d..b0b5dbe1b9c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -81,6 +81,7 @@ export function TerminalPane({ }); const baseWebsocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`); const themedUrl = new URL(baseWebsocketUrl); + themedUrl.searchParams.set("workspaceId", workspaceId); themedUrl.searchParams.set("themeType", themeType); const websocketUrl = themedUrl.toString(); const websocketUrlRef = useRef(websocketUrl); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 222983bcc3a..c3bd6a43b93 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -15,6 +15,7 @@ import { Check, ChevronDown, LoaderCircle, Plus, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; +import type { TerminalLauncher } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher"; import type { PaneViewerData, TerminalPaneData, @@ -25,6 +26,7 @@ import { TerminalPaneIcon } from "../TerminalPaneIcon"; interface TerminalSessionDropdownProps { context: RendererContext; + launcher: TerminalLauncher; workspaceId: string; } @@ -76,9 +78,11 @@ function getTerminalPaneLocations( export function TerminalSessionDropdown({ context, + launcher, workspaceId, }: TerminalSessionDropdownProps) { const [isOpen, setIsOpen] = useState(false); + const [isCreatingTerminal, setIsCreatingTerminal] = useState(false); const collections = useCollections(); const { terminalId } = context.pane.data as TerminalPaneData; const terminalInstanceId = context.pane.id; @@ -219,25 +223,36 @@ export function TerminalSessionDropdown({ }); }; - const handleNewTerminal = () => { - const state = context.store.getState(); - const terminalPaneLocations = getTerminalPaneLocations(context); - if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { - markTerminalForBackground(terminalId); + const handleNewTerminal = async () => { + if (isCreatingTerminal) return; + setIsCreatingTerminal(true); + try { + const nextTerminalId = await launcher.create(); + const state = context.store.getState(); + const terminalPaneLocations = getTerminalPaneLocations(context); + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { + markTerminalForBackground(terminalId); + } + state.setPaneData({ + paneId: context.pane.id, + data: { + terminalId: nextTerminalId, + } as PaneViewerData, + }); + state.setPaneTitleOverride({ + tabId: context.tab.id, + paneId: context.pane.id, + titleOverride: undefined, + }); + void utils.terminal.listSessions.invalidate({ workspaceId }); + setIsOpen(false); + } catch (error) { + toast.error("Failed to create terminal", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + setIsCreatingTerminal(false); } - state.setPaneData({ - paneId: context.pane.id, - data: { - terminalId: crypto.randomUUID(), - } as PaneViewerData, - }); - state.setPaneTitleOverride({ - tabId: context.tab.id, - paneId: context.pane.id, - titleOverride: undefined, - }); - void utils.terminal.listSessions.invalidate({ workspaceId }); - setIsOpen(false); }; const hostTitle = @@ -286,14 +301,19 @@ export function TerminalSessionDropdown({ type="button" aria-label="New terminal" title="New terminal" + disabled={isCreatingTerminal} className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" onClick={(event) => { event.preventDefault(); event.stopPropagation(); - handleNewTerminal(); + void handleNewTerminal(); }} > - + {isCreatingTerminal ? ( + + ) : ( + + )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 4f8fa8c5c83..b69b67a3574 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -41,6 +41,7 @@ import type { PaneViewerData, TerminalPaneData, } from "../../types"; +import type { TerminalLauncher } from "../useV2TerminalLauncher"; import { BrowserPane, BrowserPaneToolbar } from "./components/BrowserPane"; import { ChatPane } from "./components/ChatPane"; import { ChatPaneTitle } from "./components/ChatPane/components/ChatPaneTitle"; @@ -102,11 +103,13 @@ const MOD_KEY = navigator.platform.toLowerCase().includes("mac") interface UsePaneRegistryOptions { onOpenFile: (path: string, openInNewTab?: boolean) => void; onRevealPath: (path: string) => void; + launcher: TerminalLauncher; } export function usePaneRegistry({ onOpenFile, onRevealPath, + launcher, }: UsePaneRegistryOptions): PaneRegistry { const { workspace } = useWorkspace(); const workspaceId = workspace.id; @@ -282,7 +285,11 @@ export function usePaneRegistry({ }, renderTitle: (ctx: RendererContext) => (
- + @@ -501,6 +508,7 @@ export function usePaneRegistry({ killTerminalSession, killTerminalSessionSilently, isKillingTerminalSession, + launcher, onOpenFile, onRevealPath, ], diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 5a4a06fa7f9..a03fcb819d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -164,6 +164,7 @@ function V2WorkspaceContent() { const paneRegistry = usePaneRegistry({ onOpenFile: openFilePane, onRevealPath: revealPath, + launcher, }); const defaultContextMenuActions = useDefaultContextMenuActions({ paneRegistry, diff --git a/packages/host-service/src/terminal/terminal.adoption.node-test.ts b/packages/host-service/src/terminal/terminal.adoption.node-test.ts index 3bcedb95e16..83d06389565 100644 --- a/packages/host-service/src/terminal/terminal.adoption.node-test.ts +++ b/packages/host-service/src/terminal/terminal.adoption.node-test.ts @@ -33,6 +33,7 @@ import { __resetSessionsForTesting, createTerminalSessionInternal, disposeSession, + disposeSessionAndWait, listTerminalSessions, } from "./terminal.ts"; @@ -45,12 +46,16 @@ let server: Server; let db: HostDb; let projectId: string; let workspaceId: string; +let otherWorkspaceId: string; let worktreePath: string; +let otherWorktreePath: string; before(async () => { fs.mkdirSync(TEST_HOME, { recursive: true }); worktreePath = path.join(TEST_HOME, "worktree"); + otherWorktreePath = path.join(TEST_HOME, "other-worktree"); fs.mkdirSync(worktreePath, { recursive: true }); + fs.mkdirSync(otherWorktreePath, { recursive: true }); server = new Server({ socketPath: SOCK, @@ -82,6 +87,15 @@ before(async () => { branch: "main", }) .run(); + otherWorkspaceId = randomUUID(); + db.insert(workspaces) + .values({ + id: otherWorkspaceId, + projectId, + worktreePath: otherWorktreePath, + branch: "feature/other", + }) + .run(); }); after(async () => { @@ -152,6 +166,43 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = disposeSession(terminalId, db); }); + test("rejects reusing a live terminal id from another workspace", async () => { + const terminalId = `e2e-cross-live-${randomUUID().slice(0, 8)}`; + + const first = await createTerminalSessionInternal({ + terminalId, + workspaceId, + db, + listed: true, + }); + assert.ok(!("error" in first)); + + const second = await createTerminalSessionInternal({ + terminalId, + workspaceId: otherWorkspaceId, + db, + listed: true, + }); + assert.ok("error" in second); + if ("error" in second) { + assert.match(second.error, /belongs to workspace/); + } + + assert.ok( + listTerminalSessions({ workspaceId }).some( + (s) => s.terminalId === terminalId, + ), + ); + assert.equal( + listTerminalSessions({ workspaceId: otherWorkspaceId }).some( + (s) => s.terminalId === terminalId, + ), + false, + ); + + disposeSession(terminalId, db); + }); + test("adoptOnly refuses to spawn when daemon does not own the session", async () => { const terminalId = `e2e-adopt-only-${randomUUID().slice(0, 8)}`; db.insert(terminalSessions) @@ -289,6 +340,45 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = disposeSession(terminalId, db); }); + test("rejects adopting a daemon session from another workspace after host-service restart simulation", async () => { + const terminalId = `e2e-cross-adopt-${randomUUID().slice(0, 8)}`; + + const first = await createTerminalSessionInternal({ + terminalId, + workspaceId, + db, + listed: true, + }); + assert.ok(!("error" in first)); + + __resetSessionsForTesting(); + await disposeDaemonClient(); + + const second = await createTerminalSessionInternal({ + terminalId, + workspaceId: otherWorkspaceId, + db, + listed: true, + }); + assert.ok("error" in second); + if ("error" in second) { + assert.match(second.error, /belongs to workspace/); + } + + const record = db.query.terminalSessions + .findFirst({ where: eq(terminalSessions.id, terminalId) }) + .sync(); + assert.equal(record?.originWorkspaceId, workspaceId); + assert.equal( + listTerminalSessions({ workspaceId: otherWorkspaceId }).some( + (s) => s.terminalId === terminalId, + ), + false, + ); + + await disposeSessionAndWait(terminalId, db); + }); + test("adopted session does NOT re-fire initialCommand", async () => { // Regression guard: setup.sh terminals pass an initialCommand. After // host-service restart, adopting the same terminalId must NOT run diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index be3e2a9480f..8774605a412 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -993,6 +993,22 @@ function resolveTerminalCwd( return existsSync(resolvedPath) ? resolvedPath : worktreePath; } +function getTerminalWorkspaceMismatchError({ + terminalId, + ownerWorkspaceId, + requestedWorkspaceId, +}: { + terminalId: string; + ownerWorkspaceId: string | null | undefined; + requestedWorkspaceId: string; +}): string | null { + if (!ownerWorkspaceId || ownerWorkspaceId === requestedWorkspaceId) { + return null; + } + + return `Terminal session "${terminalId}" belongs to workspace "${ownerWorkspaceId}", not "${requestedWorkspaceId}".`; +} + export async function createTerminalSessionInternal({ terminalId, workspaceId, @@ -1009,11 +1025,28 @@ export async function createTerminalSessionInternal({ }: CreateTerminalSessionOptions): Promise { const existing = sessions.get(terminalId); if (existing) { + const mismatchError = getTerminalWorkspaceMismatchError({ + terminalId, + ownerWorkspaceId: existing.workspaceId, + requestedWorkspaceId: workspaceId, + }); + if (mismatchError) return { error: mismatchError }; + if (listed) existing.listed = true; if (initialCommand) queueInitialCommand(existing, initialCommand); return existing; } + const existingRecord = db.query.terminalSessions + .findFirst({ where: eq(terminalSessions.id, terminalId) }) + .sync(); + const recordMismatchError = getTerminalWorkspaceMismatchError({ + terminalId, + ownerWorkspaceId: existingRecord?.originWorkspaceId, + requestedWorkspaceId: workspaceId, + }); + if (recordMismatchError) return { error: recordMismatchError }; + const workspace = db.query.workspaces .findFirst({ where: eq(workspaces.id, workspaceId) }) .sync(); @@ -1140,7 +1173,12 @@ export async function createTerminalSessionInternal({ }) .onConflictDoUpdate({ target: terminalSessions.id, - set: { status: "active", createdAt, endedAt: null }, + set: { + originWorkspaceId: workspaceId, + status: "active", + createdAt, + endedAt: null, + }, }) .run(); @@ -1384,6 +1422,7 @@ export function registerWorkspaceTerminalRoute({ "/terminal/:terminalId", upgradeWebSocket((c) => { const terminalId = c.req.param("terminalId") ?? ""; + const requestedWorkspaceId = c.req.query("workspaceId") || null; const attachSocketToSession = ( session: TerminalSession, ws: TerminalSocket, @@ -1412,7 +1451,17 @@ export function registerWorkspaceTerminalRoute({ TerminalSession | { error: string } > => { const existing = sessions.get(terminalId); - if (existing) return existing; + if (existing) { + if (requestedWorkspaceId) { + const mismatchError = getTerminalWorkspaceMismatchError({ + terminalId, + ownerWorkspaceId: existing.workspaceId, + requestedWorkspaceId, + }); + if (mismatchError) return { error: mismatchError }; + } + return existing; + } const record = db.query.terminalSessions .findFirst({ where: eq(terminalSessions.id, terminalId) }) @@ -1433,6 +1482,14 @@ export function registerWorkspaceTerminalRoute({ error: `Terminal session "${terminalId}" is missing a workspace.`, }; } + if (requestedWorkspaceId) { + const mismatchError = getTerminalWorkspaceMismatchError({ + terminalId, + ownerWorkspaceId: record.originWorkspaceId, + requestedWorkspaceId, + }); + if (mismatchError) return { error: mismatchError }; + } const themeType = parseThemeType(c.req.query("themeType"));