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-relay-browser-journey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@internal/server": patch
---

Choose another machine's SSH keys from Connect, return to the original connection after approval, and distinguish failed settings reads from empty lists. SSH destination setup now shows progress and can be closed and reopened without starting another installation.
22 changes: 22 additions & 0 deletions apps/server/api/src/api/ssh/__tests__/setup-here-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { UsersRepository } from "@/db/repositories/users.repository.js";
import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js";
import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js";
import { ensureLocalNode } from "@/services/nodes/seed-local.js";
import { sshSetupTracker } from "@/services/ssh-setup-progress.js";
import { issueSubshellToken } from "@/services/subshell-tokens.js";

/**
Expand Down Expand Up @@ -123,3 +124,24 @@ describe("POST /api/ssh/setup-here doors", () => {
expect(body.message).toContain("SSH-terminal pane");
});
});

it("setup status survives a new request and remains cookie-only and owner-scoped", async () => {
const paneId = crypto.randomUUID();
await sshSetupTracker.run(owner, paneId, async () => ({ ok: true, value: { nodeId: "new-node" } }));
const get = (cookie?: string, bearer?: string) =>
app.fetch(
new Request(`http://localhost:3099/${port}/${paneId}`, {
headers: {
...(cookie ? { cookie: `better-auth.session_token=${cookie}` } : {}),
...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
},
}),
);
expect((await get()).status).toBe(401);
const own = await get(ownerCookie);
expect(own.status).toBe(200);
expect(await own.json()).toMatchObject({ operation: { stage: "complete", nodeId: "new-node" } });
expect(await (await get(otherCookie)).json()).toEqual({ operation: null });
const token = await issueSubshellToken(await mkPane(owner), owner);
expect((await get(undefined, token)).status).toBe(403);
});
44 changes: 43 additions & 1 deletion apps/server/api/src/api/ssh/setup-here.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { throwCodedRefusal } from "@/api/ssh/ssh-views.js";
import { contextPlugin } from "@/plugins/context.plugin.js";
import { apiModels } from "@/schema/index.js";
import { setupHere } from "@/services/ssh-setup-here.service.js";
import { sshSetupTracker } from "@/services/ssh-setup-progress.js";

/**
* `POST /api/ssh/setup-here` `{paneId}` - "Set up Subshell here" (spec
Expand All @@ -23,11 +24,52 @@ export const sshSetupHereRoute = new Elysia()
.use(contextPlugin)
.use(authGuard)
.use(apiModels)
.get(
"/setup-here/:paneId",
({ user, actor, params }) => {
requireCookieActor(actor, "SSH setup status is restricted to browser sessions");
return { operation: sshSetupTracker.read(user.id, params.paneId) };
},
{
params: t.Object({
paneId: t.String({ minLength: 1, maxLength: 64, description: "Pane whose setup status to read" }),
}),
response: {
200: t.Object({
operation: t.Nullable(
t.Object({
stage: t.Union(
[
t.Literal("checking"),
t.Literal("installing"),
t.Literal("connecting"),
t.Literal("complete"),
t.Literal("failed"),
],
{ description: "Current server-observed setup stage" },
),
startedAt: t.String({ description: "ISO timestamp when setup began" }),
nodeId: t.Nullable(t.String({ description: "Enrolled node after it connects" })),
error: t.Nullable(t.String({ description: "Safe failure explanation, never installer output" })),
}),
),
}),
},
detail: {
operationId: "sshSetupHereStatus",
tags: ["ssh"],
description:
"Owner-scoped setup progress, retained for one hour after completion while this server process runs",
},
},
)
.post(
"/setup-here",
async ({ body, user, actor }) => {
requireCookieActor(actor, "SSH setup acts are restricted to browser sessions");
const answer = await setupHere({ viewerId: user.id, paneId: body.paneId });
const answer = await sshSetupTracker.run(user.id, body.paneId, (onProgress) =>
setupHere({ viewerId: user.id, paneId: body.paneId, onProgress }),
);
if (!answer.ok) {
const r = answer.refusal;
if (r.status === 422) {
Expand Down
3 changes: 3 additions & 0 deletions apps/server/api/src/api/ssh/ssh-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ export const SshAgentIdentityViewSchema = t.Object({

/** One first-use approval request as the queue reads it (spec 2026-10-08 §6.2). */
export const SshGrantRequestViewSchema = t.Object({
destination: t.Nullable(
t.String({ description: "Exact resolved account, host and port for reconnecting; null for older requests" }),
),
id: t.String({ description: "Request row id (uuid) - the opaque ref the refusal and the notification name" }),
keyHomeNodeId: t.String({ description: "The key home whose approval is asked" }),
resolvedSelector: t.String({ description: "The resolved destination hostname the launch would have dialed" }),
Expand Down
53 changes: 53 additions & 0 deletions apps/server/api/src/services/__tests__/ssh-setup-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test";
import { BackendErrorCodes } from "@internal/backend-errors";
import type { SshAnswer } from "@/services/ssh-launch.service.js";
import { createSshSetupTracker } from "@/services/ssh-setup-progress.js";

test("reopening or retrying an ongoing setup joins one operation, with owner-scoped stage and result", async () => {
const tracker = createSshSetupTracker();
let finish!: (answer: SshAnswer<{ nodeId: string }>) => void;
let calls = 0;
const run = () =>
tracker.run("owner", "pane", (stage) => {
calls++;
stage("installing");
return new Promise((resolve) => {
finish = resolve;
});
});
const first = run();
await Promise.resolve();
expect(tracker.read("owner", "pane")?.stage).toBe("installing");
expect(tracker.read("other", "pane")).toBeNull();
const second = run();
expect(calls).toBe(1);
finish({ ok: true, value: { nodeId: "new-node" } });
await Promise.all([first, second]);
expect(tracker.read("owner", "pane")).toMatchObject({ stage: "complete", nodeId: "new-node", error: null });
await run();
expect(calls).toBe(1);
});

test("failures may be retried; unexpected errors never disclose installer secrets", async () => {
const tracker = createSshSetupTracker();
await tracker.run("o", "p", async () => {
throw new Error("secret setup key");
});
expect(JSON.stringify(tracker.read("o", "p"))).not.toContain("secret setup key");
expect(tracker.read("o", "p")?.stage).toBe("failed");
await tracker.run("o", "p", async () => ({
ok: false,
refusal: { status: 409, code: BackendErrorCodes.SSH_UPGRADE_FAILED, message: "Install tmux first." },
}));
expect(tracker.read("o", "p")?.error).toBe("Install tmux first.");
await tracker.run("o", "p", async () => ({ ok: true, value: { nodeId: "n" } }));
expect(tracker.read("o", "p")?.stage).toBe("complete");
});

test("completed status expires after an hour", async () => {
let now = 0;
const tracker = createSshSetupTracker(() => now);
await tracker.run("o", "p", async () => ({ ok: true, value: { nodeId: "n" } }));
now = 3_600_001;
expect(tracker.read("o", "p")).toBeNull();
});
3 changes: 3 additions & 0 deletions apps/server/api/src/services/ssh-grants.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ export interface SshGrantView {

/** One first-use request row as the approvals screen reads it. */
export interface SshGrantRequestView {
/** Exact destination account and port; null for pre-migration requests. */
destination: string | null;
/** Request row id (uuid) - the opaque ref the launch refusal and the notification name */
id: string;
/** Machine A asked to sign (the key home whose owner must answer) */
Expand Down Expand Up @@ -627,6 +629,7 @@ export async function listGrantRequests(args: {
id: row.id,
keyHomeNodeId: row.keyHomeNodeId,
resolvedSelector: row.resolvedSelector,
destination: row.destination,
requestedFingerprints: row.requestedFingerprints === null ? null : parseFingerprintArray(row.requestedFingerprints),
paneId: row.paneId,
bNodeId: row.bNodeId,
Expand Down
10 changes: 9 additions & 1 deletion apps/server/api/src/services/ssh-setup-here.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
targetDataDir,
} from "@/services/ssh-launch.service.js";
import { getRelayBroker, type RelayBroker } from "@/services/ssh-relay.service.js";
import type { SshSetupStage } from "@/services/ssh-setup-progress.js";
import { logger } from "@/utils/logger.js";

/**
Expand Down Expand Up @@ -153,7 +154,11 @@ export interface SetupHereResult {
* a foreign or absent pane is one 404, the ordinary invisibility). Refusals
* name their stage; the key never rides one.
*/
export async function setupHere(args: { viewerId: string; paneId: string }): Promise<SshAnswer<SetupHereResult>> {
export async function setupHere(args: {
viewerId: string;
paneId: string;
onProgress?: (stage: SshSetupStage) => void;
}): Promise<SshAnswer<SetupHereResult>> {
const deps = setupDeps();
const pane = await db.selectFrom("subshells").selectAll().where("id", "=", args.paneId).executeTakeFirst();
if (!pane || pane.userId !== args.viewerId) {
Expand Down Expand Up @@ -322,6 +327,8 @@ export async function setupHere(args: { viewerId: string; paneId: string }): Pro
}
};

args.onProgress?.("installing");

// KICK. A refusal here has minted a key and opened a relay and must leave
// both as it found them (revoke the unspent key, cut the fresh session).
try {
Expand Down Expand Up @@ -472,6 +479,7 @@ export async function setupHere(args: { viewerId: string; paneId: string }): Pro
// Success is the new node's `ready`, not the installer's cheer. The setup
// key's row learns the node id enrollment spent it for; the registry's
// agent facts appear exactly when that node's `ready` frame lands.
args.onProgress?.("connecting");
const readyDeadline = deps.nowMs() + deps.readyBudgetMs;
for (;;) {
const spent = await keys.findById(keyRow.id);
Expand Down
95 changes: 95 additions & 0 deletions apps/server/api/src/services/ssh-setup-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { BackendErrorCodes } from "@internal/backend-errors";
import type { SshAnswer } from "@/services/ssh-launch.service.js";
import type { SetupHereResult } from "@/services/ssh-setup-here.service.js";

/** Only host-selected stages cross the status endpoint; installer output never does. */
export type SshSetupStage = "checking" | "installing" | "connecting" | "complete" | "failed";
export interface SshSetupProgress {
/** Server-observed stage; no installer output is exposed. */
stage: SshSetupStage;
/** ISO timestamp for elapsed-time display. */
startedAt: string;
/** Enrolled destination once its connection is confirmed. */
nodeId: string | null;
/** Safe failure explanation. */
error: string | null;
}

/** Owner-scoped, bounded status for installs that outlive a browser request or dialog. */
export function createSshSetupTracker(now = Date.now) {
const entries = new Map<
string,
{ progress: SshSetupProgress; settledAt: number | null; result: Promise<SshAnswer<SetupHereResult>> }
>();
const key = (owner: string, pane: string) => JSON.stringify([owner, pane]);
const prune = () => {
for (const [id, entry] of entries)
if (entry.settledAt !== null && now() - entry.settledAt > 3_600_000) entries.delete(id);
};
return {
read(owner: string, pane: string): SshSetupProgress | null {
prune();
const value = entries.get(key(owner, pane))?.progress;
return value ? { ...value } : null;
},
run(
owner: string,
pane: string,
work: (stage: (value: SshSetupStage) => void) => Promise<SshAnswer<SetupHereResult>>,
): Promise<SshAnswer<SetupHereResult>> {
prune();
const id = key(owner, pane);
const existing = entries.get(id);
if (existing && existing.progress.stage !== "failed") return existing.result;
if (!existing && entries.size >= 500)
return Promise.resolve({
ok: false,
refusal: {
status: 409,
code: BackendErrorCodes.SSH_UPGRADE_FAILED,
message: "Too many recent setup operations. Try again later.",
},
});
const progress: SshSetupProgress = {
stage: "checking",
startedAt: new Date(now()).toISOString(),
nodeId: null,
error: null,
};
const entry = {
progress,
settledAt: null as number | null,
result: Promise.resolve().then(() =>
work((stage) => {
progress.stage = stage;
}),
),
};
entries.set(id, entry);
entry.result = entry.result.then(
(answer) => {
entry.settledAt = now();
progress.stage = answer.ok ? "complete" : "failed";
if (answer.ok) progress.nodeId = answer.value.nodeId;
else
progress.error =
answer.refusal.status === 422 ? "The destination could not be set up." : answer.refusal.message;
return answer;
},
() => {
entry.settledAt = now();
progress.stage = "failed";
progress.error = "Setup status could not be confirmed. Check the destination and Nodes before retrying.";
return {
ok: false,
refusal: { status: 502, code: BackendErrorCodes.SSH_NODE_REFUSED, message: progress.error },
};
},
);
return entry.result;
},
};
}

/** No timers, credentials, sockets or disk access at import time. */
export const sshSetupTracker = createSshSetupTracker();
18 changes: 18 additions & 0 deletions apps/server/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,3 +385,21 @@ tap-only strip never takes draggable tabs off a window holding a mouse.
**Working on the terminal, dockview, touch/swipe handling, or shared-grid
sizing, and before ANY `dockview-react` upgrade (which a standing hand-run
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
`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.
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.

“Set up Subshell here” can be dismissed while running. Its owner-scoped status
read observes stages selected by the server, never installer output. Reopening
or refreshing joins the same running operation; a duplicate POST also joins it.
Completed results remain for one hour in the running server process. This is
not a durable job across server restarts: after a restart, check Nodes and the
destination before retrying an interrupted install. Keep that limitation honest
in any future recovery UI.
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ describe("SetupHereDialog", () => {
globalThis.fetch = ((input: unknown, init?: RequestInit) => {
const url = new URL(String(input), "http://localhost");
calls.push({ url: url.pathname, body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined });
return Promise.resolve(new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 }));
return Promise.resolve(
url.pathname.startsWith("/api/ssh/setup-here/")
? new Response(JSON.stringify({ operation: null }))
: new Response(JSON.stringify(answer.body), { status: answer.status ?? 200 }),
);
}) as typeof fetch;
return { calls, restore: () => (globalThis.fetch = original) };
}
Expand Down Expand Up @@ -146,4 +150,40 @@ describe("SetupHereDialog", () => {
restore();
}
});
it("can close and reopen an install without another POST, including after a page remount", async () => {
const original = globalThis.fetch;
let posts = 0;
let operation: { stage: string; startedAt: string; nodeId: string | null; error: null } | null = null;
let finish!: (value: Response) => void;
globalThis.fetch = ((_input: unknown, init?: RequestInit) => {
if (init?.method === "POST") {
posts++;
operation = { stage: "installing", startedAt: new Date().toISOString(), nodeId: null, error: null };
return new Promise<Response>((resolve) => {
finish = resolve;
});
}
return Promise.resolve(new Response(JSON.stringify({ operation })));
}) as typeof fetch;
try {
let closed = false;
await renderDialog(makeSshPane(), () => {
closed = true;
});
fireEvent.click(screen.getByRole("button", { name: "Set up Subshell" }));
fireEvent.click(await screen.findByRole("button", { name: "Continue working" }));
expect(closed).toBe(true);
cleanup();
await renderDialog(makeSshPane());
expect(screen.getByText(/Installing on the destination/)).toBeTruthy();
expect((screen.getByRole("button", { name: "Setting up…" }) as HTMLButtonElement).disabled).toBe(true);
expect(posts).toBe(1);
operation = { stage: "complete", startedAt: new Date().toISOString(), nodeId: "new-node", error: null };
finish(new Response(JSON.stringify({ nodeId: "new-node" })));
expect(await screen.findByRole("link", { name: "Open its page" }, { timeout: 3000 })).toBeTruthy();
expect(posts).toBe(1);
} finally {
globalThis.fetch = original;
}
});
});
Loading
Loading