Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/desktop/src/main/lib/auto-updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
},
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +26,7 @@ import { TerminalPaneIcon } from "../TerminalPaneIcon";

interface TerminalSessionDropdownProps {
context: RendererContext<PaneViewerData>;
launcher: TerminalLauncher;
workspaceId: string;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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();
}}
>
<Plus className="size-3.5" />
{isCreatingTerminal ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Plus className="size-3.5" />
)}
</button>
</DropdownMenuLabel>
<DropdownMenuSeparator />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<PaneViewerData> {
const { workspace } = useWorkspace();
const workspaceId = workspace.id;
Expand Down Expand Up @@ -282,7 +285,11 @@ export function usePaneRegistry({
},
renderTitle: (ctx: RendererContext<PaneViewerData>) => (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TerminalSessionDropdown context={ctx} workspaceId={workspaceId} />
<TerminalSessionDropdown
context={ctx}
launcher={launcher}
workspaceId={workspaceId}
/>
<V2NotificationStatusIndicator
sources={getV2NotificationSourcesForPane(ctx.pane)}
/>
Expand Down Expand Up @@ -501,6 +508,7 @@ export function usePaneRegistry({
killTerminalSession,
killTerminalSessionSilently,
isKillingTerminalSession,
launcher,
onOpenFile,
onRevealPath,
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ function V2WorkspaceContent() {
const paneRegistry = usePaneRegistry({
onOpenFile: openFilePane,
onRevealPath: revealPath,
launcher,
});
const defaultContextMenuActions = useDefaultContextMenuActions({
paneRegistry,
Expand Down
90 changes: 90 additions & 0 deletions packages/host-service/src/terminal/terminal.adoption.node-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
__resetSessionsForTesting,
createTerminalSessionInternal,
disposeSession,
disposeSessionAndWait,
listTerminalSessions,
} from "./terminal.ts";

Expand All @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading