Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/ssh-in-subshells.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@internal/server": patch
---

Open SSH terminals from New subshell, workspace additions, and splits. Explain the destination and connecting machine, move alternate keys under advanced options, and approve keys inside the launch dialog without losing workspace context. Existing Connect links open the shared subshell launcher. The selected subshell type has a distinct highlight and checkmark, with initial focus following the selected type.
10 changes: 9 additions & 1 deletion apps/node/agent/src/__tests__/commands-ssh-exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,15 @@ describe("ssh_exec kick + status", () => {
expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({ ok: true, data: { state: "running" } });

finish({ code: 0, stdout: "==> downloading subshell\n==> done.\n", stderr: "" });
await new Promise((r) => setTimeout(r, 5)); // let the off-chain completion land
// Completion schedules asynchronous file removal; a fixed 5 ms sleep
// races filesystem work under parallel suite load.
for (
let attempt = 0;
attempt < 100 && (existsSync(configPath) || existsSync(buildSshKnownHostsPath(ctx.config.dataDir, EXEC_ID)));
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(await execSshExecStatus(ctx, statusCmd(), seams)).toEqual({
ok: true,
data: { state: "done", code: 0, timedOut: false, stdout: "==> downloading subshell\n==> done.\n", stderr: "" },
Expand Down
7 changes: 4 additions & 3 deletions apps/server/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,12 +386,13 @@ probe gates): read apps/server/web/docs/terminal-gotchas.md first.**

## SSH relay launch and setup

Connect can use another owned node's loaded SSH-agent keys. The default omits
The SSH terminal choice in New subshell can use another owned node's loaded SSH-agent keys. The default omits
`keyHome` and uses the connecting machine's own keys. Never silently substitute
keys when the chosen machine goes offline. Approval links preserve the launch
choices and require a fresh Connect click; approval itself launches nothing.
keys when the chosen machine goes offline. Inline approval preserves the launch choices and workspace/split context,
and requires a fresh Start SSH subshell click; approval itself launches nothing.
Approval return links bind the preserved choices to the exact request ID, never
just the machine pair: several destinations can await approval on that pair.
Old `/connect` links open the same subshell dialog through `/new?kind=ssh`.
The pending-request view carries the exact destination account and port, and
new requests have no pane yet, so the approvals list must not link a nonexistent
pane. Failed settings reads must stay distinct from empty lists.
Expand Down
18 changes: 18 additions & 0 deletions apps/server/web/docs/launch-form-picker.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,21 @@ Mobile's New
screen does NOT mirror this tier (operator scope call: web only;
`agent-default.ts` keeps its "change one, change both" for the AGENT rule
only).

## SSH subshells

New subshell, Add/Split, and New workspace offer the same `SubshellKindPicker`:
Agent or terminal, or SSH terminal. The SSH choice uses `ConnectPanel`, with
callbacks that attach the created subshell to the caller's workspace instead
of navigating away. Keep launch pending until that attachment finishes.

The SSH destination accepts typed text directly; suggestions retain their
canonical destination until edited. Explain the connecting machine beside its
picker. Alternate key machines belong under Advanced SSH options. Approval
runs inside the same dialog, scoped to the exact request, and returns to the
form without launching automatically. This preserves workspace selections and
split direction. Saved destination management lives in Settings → SSH.

`/connect` is a compatibility redirect to `/new?kind=ssh`, preserving validated
connection choices. The quick-add provider opens the shared dialog over the
subshell list; there is no separate Connect navigation item or page.
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ describe("the header account card", () => {
const calls = stubFetch(opts);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ describe("the Server Settings group's open/close wiring", () => {
const restoreFetch = stubFetch(true);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
Expand Down Expand Up @@ -181,6 +182,7 @@ describe("the Server Settings group's open/close wiring", () => {
const restoreFetch = stubFetch(false);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
Expand Down Expand Up @@ -217,6 +219,7 @@ describe("the Settings group", () => {
const restoreFetch = stubFetch(false); // NOT an admin: the group is ungated
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ const withRail = async (
const restoreFetch = stubFetch(subshells, state);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
Expand Down Expand Up @@ -652,7 +653,11 @@ describe("the eye toggle (operator ask 2026-09-27)", () => {
it("reads a hidden section back from storage on the next mount", async () => {
localStorage.setItem(HIDDEN_KEY, JSON.stringify({ subshells: true }));
const restore = stubFetch([subshell({ id: "a", name: "one", nodeId: "local" })]);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} });
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
await renderRail();
// The eye is ALWAYS drawn (it is the way back); it says Show, and the
Expand Down Expand Up @@ -693,7 +698,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => {
draft("w1", "2026-08-28T16:45:00.000Z"),
draft("w2", "2026-09-01T09:05:00.000Z"),
]);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} });
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
await renderRail("/");
await waitFor(() => expect(document.querySelector("a[href='/workspaces/w1']")).toBeTruthy());
Expand All @@ -709,7 +718,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => {

it("highlights the draft you are standing in", async () => {
const restore = stubFetch([], { failNodes: false }, [], undefined, [draft("w1", "2026-08-28T16:45:00.000Z")]);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} });
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
await renderRail("/workspaces/w1");
await waitFor(() => expect(document.querySelector("a[href='/workspaces/w1']")).toBeTruthy());
Expand All @@ -724,7 +737,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => {

it("collapses a Workspaces section on its header and remembers it", async () => {
const restore = stubFetch([], { failNodes: false }, [], undefined, [draft("w1", "2026-08-28T16:45:00.000Z")]);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} });
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
await renderRail("/");
await waitFor(() => expect(document.getElementById("ws-group-drafts")).toBeTruthy());
Expand Down Expand Up @@ -759,7 +776,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => {
updatedAt: "2026-09-25T00:00:00.000Z",
};
const restore = stubFetch([], { failNodes: false }, [saved], undefined, [draft("d1", "2026-08-28T16:45:00.000Z")]);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} });
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
await renderRail("/workspaces/s1");
await waitFor(() => expect(document.querySelector("a[href='/workspaces/d1']")).toBeTruthy());
Expand All @@ -778,7 +799,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => {
draft("w1", "2026-08-28T16:45:00.000Z"),
draft("w2", "2026-08-28T16:45:30.000Z"),
]);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} });
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
await renderRail("/");
await waitFor(() => expect(document.querySelector("a[href='/workspaces/w2']")).toBeTruthy());
Expand All @@ -800,7 +825,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => {
// so a user with nothing saved typing a non-matching query unmounted the
// section — the input they were typing in — with no way to clear it.
const restore = stubFetch([], { failNodes: false }, [], undefined, [draft("d1", "2026-08-28T16:45:00.000Z")]);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} });
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
await renderRail("/");
const input = await screen.findByLabelText("Filter workspaces");
Expand All @@ -824,7 +853,11 @@ describe("the Drafts section (operator ask 2026-09-27)", () => {
updatedAt: "2026-09-25T00:00:00.000Z",
};
const restore = stubFetch([], { failNodes: false }, [saved]);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} });
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
await renderRail("/");
await waitFor(() => expect(screen.getByText("My saved")).toBeTruthy());
Expand Down Expand Up @@ -872,7 +905,11 @@ describe("the workspace search filter (operator ask 2026-09-27)", () => {
draft("d1", "Doc-thing", "2026-08-28T16:45:00.000Z"),
draft("d2", "Other", "2026-08-28T16:45:30.000Z"),
]);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} });
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
await renderRail("/");
const savedHeader = () => screen.getByRole("button", { name: /^Saved/ }) as HTMLButtonElement;
Expand All @@ -898,7 +935,11 @@ describe("the workspace search filter (operator ask 2026-09-27)", () => {
it("filters the workspace list as you type, over the whole set", async () => {
const rows = [ws("w1", "Alpha", "2026-09-20T00:00:00.000Z"), ws("w2", "Docs", "2026-09-25T00:00:00.000Z")];
const restore = stubFetch([], { failNodes: false }, rows);
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({ openLaunch: () => {}, openNewWorkspace: () => {} });
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
try {
await renderRail();
const input = await screen.findByLabelText("Filter workspaces");
Expand Down
8 changes: 3 additions & 5 deletions apps/server/web/src/components/__tests__/app-sidebar.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { Cable, Fingerprint, ServerCog, Settings, SlidersHorizontal } from "lucide-react";
import { Fingerprint, ServerCog, Settings, SlidersHorizontal } from "lucide-react";
import { isNavGroup, visibleNavEntries, visibleNavItems } from "@/components/sidebar/sidebar-nav";

describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => {
Expand All @@ -20,10 +20,8 @@ describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => {
expect(entries.filter(isNavGroup).find((g) => g.id === "server-settings")?.icon).toBe(ServerCog);
});

it("Connect takes Cable, beside Subshells as a primary action", () => {
// The Connect page is a top-level action (spec 2026-10-07 §7), not a
// settings page; Cable was free of the rail when it landed.
expect(items.find((i) => i.to === "/connect")?.icon).toBe(Cable);
it("SSH launches with subshells rather than a separate navigation page", () => {
expect(items.some((i) => i.to === "/connect")).toBe(false);
});

it("SSH takes Fingerprint, in the personal Settings group (its ledger is per-owner)", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ function stubFetch() {
// provider would mount two dialogs and their data, none of it under test.
const spy = spyOn(quickAdd, "useQuickAdd").mockReturnValue({
openLaunch: () => {},
openSshLaunch: () => {},
openNewWorkspace: () => {},
});
return () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/server/web/src/components/__tests__/quick-add.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe("QuickAddProvider", () => {
await act(async () => {
screen.getByText("rail + (subshell)").click();
});
expect(await screen.findByText("Launch an agent in a working directory.")).toBeDefined();
expect(await screen.findByText("Start an agent, a terminal, or an SSH session in a subshell.")).toBeDefined();
// The workspace dialog stays closed — the two triggers are independent.
expect(screen.queryByText("Start it with subshells already tiled in, or empty.")).toBeNull();
await flush();
Expand All @@ -86,7 +86,7 @@ describe("QuickAddProvider", () => {
screen.getByText("rail + (workspace)").click();
});
expect(await screen.findByText("Start it with subshells already tiled in, or empty.")).toBeDefined();
expect(screen.queryByText("Launch an agent in a working directory.")).toBeNull();
expect(screen.queryByText("Start an agent, a terminal, or an SSH session in a subshell.")).toBeNull();
await flush();
});

Expand Down
4 changes: 2 additions & 2 deletions apps/server/web/src/components/__tests__/sidebar-nav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ describe("visibleNavItems", () => {
// `/settings/ssh` rides the PERSONAL group: its path is under
// /settings but the page is the caller's own ledger (spec 2026-10-08
// §8), so a member reaches it and the gate never hides it.
expect(paths).toEqual(["/", "/connect", "/workspaces", "/nodes", "/presets", "/prompts", "/settings/ssh"]);
expect(paths).toEqual(["/", "/workspaces", "/nodes", "/presets", "/prompts", "/settings/ssh"]);
}
});

it("gives an admin every group page, flattened in rail order", () => {
expect(visibleNavItems(true).map((i) => i.to)).toEqual([
"/",
"/connect",

"/workspaces",
"/nodes",
"/presets",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ function machineInput(): HTMLInputElement {
}

function destinationInput(): HTMLInputElement {
return screen.getByPlaceholderText("Choose or type a destination") as HTMLInputElement;
return screen.getByPlaceholderText("user@hostname:22 or an SSH alias") as HTMLInputElement;
}

/** The destination field opens on focus (the working-directory posture). */
Expand All @@ -181,7 +181,7 @@ async function commitTyped(text: string): Promise<void> {
}

async function clickConnect(): Promise<void> {
fireEvent.click(screen.getByRole("button", { name: /^Connect$/ }));
fireEvent.click(screen.getByRole("button", { name: /^Start SSH subshell$/ }));
await settle();
}

Expand Down Expand Up @@ -277,7 +277,7 @@ describe("machine disclosure (contract 1)", () => {
const caption = screen.getByText("No SSH-enabled machine is available. Enable SSH on a machine first.");
expect(caption.className).toContain("text-amber-600"); // gold: nothing chosen, not a refused value
await commitTyped("box.example");
const button = screen.getByRole("button", { name: /^Connect$/ }) as HTMLButtonElement;
const button = screen.getByRole("button", { name: /^Start SSH subshell$/ }) as HTMLButtonElement;
expect(button.disabled).toBe(true); // the machine is in the disabled condition
await clickConnect();
expect(calls.filter((c) => c.url === "/api/ssh/launch")).toEqual([]);
Expand Down Expand Up @@ -370,10 +370,10 @@ describe("destination field (contract 2)", () => {
}
});

it("accepts a host typed that no list carries: the mirror row commits it", async () => {
it("accepts a typed host directly without requiring a suggestion click", async () => {
const { calls, restore } = await renderPanel({ nodes: [HOST_A] });
try {
await commitTyped("box.example");
fireEvent.change(destinationInput(), { target: { value: "box.example" } });
expect(destinationInput().value).toBe("box.example"); // the held echo is what will launch
await clickConnect();
const post = calls.find((c) => c.url === "/api/ssh/launch");
Expand Down Expand Up @@ -437,7 +437,7 @@ describe("connect and the ledger (contracts 3, 6)", () => {
await settle();
try {
await commitTyped("box.example");
fireEvent.click(screen.getByRole("button", { name: /^Connect$/ }));
fireEvent.click(screen.getByRole("button", { name: /^Start SSH subshell$/ }));
await settle();
const button = screen.getByRole("button", { name: /Connecting/ }); // no spinner: the button owns the wait
expect((button as HTMLButtonElement).disabled).toBe(true);
Expand Down Expand Up @@ -530,15 +530,15 @@ describe("refusals and disclosure (contracts 4, 5)", () => {
}
});

it("ships the two-sentence disclosure under the destination field, wired as its description", async () => {
it("explains the destination format and discloses SSH config execution before launch", async () => {
const { restore } = await renderPanel({ nodes: [HOST_A] });
try {
const line = await screen.findByText(SSH_DISCLOSURE_COPY);
expect(line.className).toContain("text-detail");
expect(destinationInput().getAttribute("aria-describedby")).toBe("connect-destination-disclosure");
// The exact sentences (decision 6), dash-free.
expect(SSH_DISCLOSURE_COPY).toBe(
"Resolving asks the connecting machine to read its SSH config. A hidden Match exec in that config can run a local command while it resolves.",
"Connecting uses this machine’s SSH configuration, including any local commands configured with Match exec.",
);
} finally {
restore();
Expand Down Expand Up @@ -590,10 +590,7 @@ describe("relay launch and approval recovery", () => {
);
try {
await clickConnect();
const link = screen.getByRole("link", { name: "Review SSH approval" }) as HTMLAnchorElement;
expect(new URL(link.href).searchParams.get("requestId")).toBe("req1");
expect(link.href).toContain("/settings/ssh");
expect(link.href).toContain("keyHome");
expect(screen.getByRole("button", { name: "Review SSH approval" })).toBeTruthy();
expect(screen.queryByText(/has no live connection/)).toBeNull();
} finally {
restore();
Expand All @@ -605,7 +602,7 @@ describe("relay launch and approval recovery", () => {
{ node: HOST_A.id, keyHome: HOST_C.id, destination: "work" },
);
try {
expect((screen.getByRole("button", { name: "Connect" }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "Start SSH subshell" }) as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText(/selected key machine is unavailable/)).toBeTruthy();
expect(calls.some((c) => c.url === "/api/ssh/launch")).toBe(false);
} finally {
Expand Down
Loading
Loading