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

Block SSH creation when no connecting machine is ready, with setup guidance and links to machine settings. Reject stale machine choices and explain loading failures separately.
6 changes: 6 additions & 0 deletions apps/server/web/docs/launch-form-picker.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,9 @@ 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.

The SSH terminal form requires an owned (or admin-managed server), online,
launchable machine with SSH enabled, outside maintenance and protocol hold.
When none is ready it shows setup guidance and machine settings links instead
of destination fields. It polls availability while open; a stale selection
blocks creation without silently switching machines. Failed reads offer Retry.
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const json = (body: unknown, status = 200) => new Response(JSON.stringify(body),

interface WireOpts {
nodes?: Node[];
nodesResponse?: () => Response;
settings?: unknown;
ledger?: unknown;
aliases?: string[];
launch?: () => Response;
Expand All @@ -81,7 +83,8 @@ function mockFetch(opts: WireOpts = {}) {
url: path + url.search,
...(typeof init?.body === "string" ? { body: JSON.parse(init.body) as unknown } : {}),
});
if (path === "/api/nodes") return Promise.resolve(json({ nodes: opts.nodes ?? [] }));
if (path === "/api/settings/public") return Promise.resolve(json(opts.settings ?? {}));
if (path === "/api/nodes") return Promise.resolve(opts.nodesResponse?.() ?? json({ nodes: opts.nodes ?? [] }));
if (path === "/api/ssh/saved-hosts" && method === "GET") {
return Promise.resolve(json(opts.ledger ?? { saved: [], recent: [], defaultNodeId: null }));
}
Expand Down Expand Up @@ -123,7 +126,13 @@ async function renderPanel(
});
const paneRoute = createRoute({ getParentRoute: () => rootRoute, path: "/subshells/$id", component: () => null });
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, paneRoute]),
routeTree: rootRoute.addChildren([
indexRoute,
paneRoute,
...["/nodes", "/nodes/$id"].map((path) =>
createRoute({ getParentRoute: () => rootRoute, path, component: () => null }),
),
]),
history: createMemoryHistory({ initialEntries: ["/"] }),
defaultPreload: false,
});
Expand Down Expand Up @@ -264,25 +273,95 @@ describe("machine disclosure (contract 1)", () => {
}
});

it("a stale default with NO enabled machine: the caption names the missing machine and Connect cannot fire", async () => {
// The honest dead end: the preference points at a row that is gone and
// nothing is SSH-enabled. The sentence names the ENVIRONMENT (gold, one
// line), and the disabled button means Connect can never be a silent
// no-op: a typed destination alone must not POST.
const { calls, restore } = await renderPanel({
for (const unavailable of [
[],
[HOST_B],
[{ ...HOST_A, status: "offline" as const }],
[{ ...HOST_A, maintenance: true }],
[{ ...HOST_A, canLaunch: false }],
[{ ...HOST_A, canManage: false, access: "edit" as const }],
]) {
it(`replaces the form when no machine can run SSH: ${JSON.stringify(unavailable)}`, async () => {
const { calls, restore } = await renderPanel({ nodes: unavailable }, { node: HOST_A.id, destination: "work" });
try {
expect(screen.getByText("No machine is ready for SSH")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Start SSH subshell" })).toBeNull();
expect(screen.queryByLabelText("SSH destination")).toBeNull();
expect(screen.getByRole("button", { name: "Add a node" })).toBeTruthy();
expect(calls.some((c) => c.url.startsWith("/api/ssh/aliases"))).toBe(false);
expect(calls.some((c) => c.url === "/api/ssh/launch")).toBe(false);
} finally {
restore();
}
});
}

it("shows a retry instead of an empty state when machines cannot be loaded", async () => {
let failed = true;
const { restore } = await renderPanel({
nodesResponse: () => (failed ? json({}, 503) : json({ nodes: [HOST_A] })),
});
try {
expect(screen.getByText("Could not load connecting machines.")).toBeTruthy();
expect(screen.queryByText("No machine is ready for SSH")).toBeNull();
failed = false;
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(machineInput().value).toBe("mac mini"));
} finally {
restore();
}
});

it("does not offer enrollment when the instance forbids it", async () => {
const { restore } = await renderPanel({
nodes: [],
ledger: { saved: [], recent: [], defaultNodeId: "vanished-node" },
settings: { viewerIsAdmin: false, allowNodeEnrollment: false },
});
try {
expect(machineInput().value).toBe("");
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: /^Start SSH subshell$/ }) as HTMLButtonElement;
expect(button.disabled).toBe(true); // the machine is in the disabled condition
expect(screen.queryByRole("button", { name: "Add a node" })).toBeNull();
expect(screen.getByText(/Adding nodes is turned off/)).toBeTruthy();
} finally {
restore();
}
});

it("omits server-specific guidance and settings actions", async () => {
const { restore } = await renderPanel({
nodes: [node({ id: "local", name: "Server", kind: "local", sshEnabled: false }), HOST_B],
settings: { viewerIsAdmin: true },
});
try {
expect(screen.getByText("No machine is ready for SSH")).toBeTruthy();
expect(screen.queryByText(/SSH is off here/)).toBeNull();
expect(screen.queryByRole("button", { name: "Open Server settings" })).toBeNull();
expect(screen.getByRole("button", { name: "Open studio settings" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Add a node" })).toBeTruthy();
} finally {
restore();
}
});

it("opens an owned machine's SSH settings", async () => {
const { restore, pathname } = await renderPanel({ nodes: [HOST_B] });
try {
expect(screen.getByText(/SSH is off on this machine/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Open studio settings" }));
await waitFor(() => expect(pathname()).toBe("/nodes/b1"));
} finally {
restore();
}
});

it("blocks a stale selected machine even when another machine is ready", async () => {
const { calls, restore } = await renderPanel(
{ nodes: [{ ...HOST_A, status: "offline" }, HOST_C] },
{ node: HOST_A.id, destination: "work" },
);
try {
expect((screen.getByRole("button", { name: "Start SSH subshell" }) as HTMLButtonElement).disabled).toBe(true);
await clickConnect();
expect(calls.filter((c) => c.url === "/api/ssh/launch")).toEqual([]);
expect(screen.getByText("No SSH-enabled machine is available. Enable SSH on a machine first.")).toBeDefined();
expect(screen.getByText(/Choose another connecting machine to continue/)).toBeTruthy();
expect(calls.some((c) => c.url === "/api/ssh/launch")).toBe(false);
} finally {
restore();
}
Expand Down
40 changes: 24 additions & 16 deletions apps/server/web/src/components/connect/connect-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { ChevronDown } from "lucide-react";
import { type JSX, useEffect, useState } from "react";
import { DestinationField } from "@/components/connect/destination-field";
import { buildDestinationOptions, type DestinationCandidate } from "@/components/connect/destination-options";
import { NoSshTargets } from "@/components/connect/no-ssh-targets";
import { type SshRefusalCopy, sshLaunchRefusal } from "@/components/connect/ssh-refusal";
import { PendingApprovals } from "@/components/ssh/pending-approvals";
import { SshQueryStatus } from "@/components/ssh/query-status";
import { type ComboboxOption, SearchableSelect } from "@/components/ui/combobox";
import { Field, FieldGroup } from "@/components/ui/field";
import { RequiredMark } from "@/components/ui/required-mark";
Expand All @@ -15,6 +17,7 @@ import { useLaunchSsh, useSaveSshHost, useSetDefaultNode, useSshAliases, useSshS
import { fieldErrorToned } from "@/lib/form";
import { nodeOptionLabel } from "@/lib/node-label";
import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone";
import { sshMachineBlocker } from "@/lib/ssh-machine-readiness";
import { launchableNodes } from "@/lib/subshell-compat";

/** Shared SSH launch form for new subshells, workspace additions and splits.
Expand Down Expand Up @@ -55,7 +58,8 @@ export function ConnectPanel({
onPendingChange?: (pending: boolean) => void;
}): JSX.Element {
const navigate = useNavigate();
const { data: nodeData } = useNodes();
const nodesQuery = useNodes({ polling: true });
const { data: nodeData } = nodesQuery;
const nodes = Array.isArray(nodeData?.nodes) ? nodeData.nodes : null;
const { data: ledger, isPending: ledgerPending } = useSshSavedHosts();
const launch = useLaunchSsh();
Expand Down Expand Up @@ -83,7 +87,9 @@ export function ConnectPanel({
const targets = launchableNodes(nodes ?? []);
const selectedNode: Node | null = (nodes ?? []).find((n) => n.id === nodeId) ?? null;
const defaultNodeId = ledger?.defaultNodeId ?? null;
const enabledCount = targets.filter((n) => n.sshEnabled).length;
const enabledCount = targets.filter((n) => sshMachineBlocker(n) === null).length;
const machineReady = !nodesQuery.isError && selectedNode !== null && sshMachineBlocker(selectedNode) === null;
const selectedBlocker = selectedNode ? sshMachineBlocker(selectedNode) : "Choose an available connecting machine.";
// "Settled" means both feeds have answered, so the pre-fill effect has had
// its say: a stored default or the only usable machine has landed, or there
// was nothing to land. From then on an empty machine IS the open question -
Expand All @@ -98,17 +104,13 @@ export function ConnectPanel({
const machineOptions: ComboboxOption[] = targets.map((n) => ({
value: n.id,
label: nodeOptionLabel(n),
disabled: !n.sshEnabled,
reason: n.sshEnabled
? undefined
: n.kind === "local"
? "SSH is off here. An admin can switch it on from this machine's settings."
: "SSH is off on this machine. Its owner can switch it on from this machine's settings.",
disabled: sshMachineBlocker(n) !== null,
reason: sshMachineBlocker(n) ?? undefined,
}));

// Aliases are the MACHINE's config, asked only while a gate-ON machine is
// picked (spec §7: discovery runs where it can answer).
const aliasesQ = useSshAliases(selectedNode?.sshEnabled ? selectedNode.id : null);
const aliasesQ = useSshAliases(machineReady && selectedNode ? selectedNode.id : null);
const aliases = aliasesQ.data?.aliases ?? [];

const { options: destinationOptions, candidates } = buildDestinationOptions({
Expand All @@ -126,7 +128,7 @@ export function ConnectPanel({
// array every render).
useEffect(() => {
if (nodeId !== "" || nodes === null) return;
const usable = launchableNodes(nodes).filter((n) => n.sshEnabled);
const usable = launchableNodes(nodes).filter((n) => sshMachineBlocker(n) === null);
if (defaultNodeId !== null && usable.some((n) => n.id === defaultNodeId)) {
setNodeId(defaultNodeId);
} else if (usable.length === 1) {
Expand Down Expand Up @@ -176,10 +178,12 @@ export function ConnectPanel({
setDest({ pick: candidate, pickId: option.value, typed: option.label });
}

const canSubmit = !submitting && machineReady && destination !== "" && keyReady;

async function submit(): Promise<void> {
// Belt over gate: the button's own disabled condition already holds both
// fields, so this can only fire on a render race, never as a silent no-op.
if (submitting || nodeId === "" || destination === "" || !keyReady) return;
if (!canSubmit) return;
setSubmitting(true);
setRefusal(null);
try {
Expand Down Expand Up @@ -230,6 +234,10 @@ export function ConnectPanel({
.filter((id): id is string => id !== null)
.join(" ") || undefined;

if (nodesQuery.isPending || nodesQuery.isError)
return <SshQueryStatus query={nodesQuery} label="connecting machines" />;
if (enabledCount === 0) return <NoSshTargets nodes={nodes ?? []} onLeave={onLeave} />;

if (reviewApproval && refusal?.requestId)
return (
<div className="flex flex-col gap-4">
Expand Down Expand Up @@ -296,10 +304,10 @@ export function ConnectPanel({
? `${selectedNode.name} runs SSH and must be able to reach the destination.`
: "Choose a Subshell machine that can reach the destination. It runs SSH for this terminal."}
</p>
{enabledCount === 0 && nodes !== null && (
<Link to="/nodes" onClick={onLeave} className="text-label underline">
Manage machines and enable SSH
</Link>
{nodeId !== "" && !machineReady && (
<p role="status" className="text-detail text-muted-foreground">
{selectedBlocker} Choose another connecting machine to continue.
</p>
)}
{machineGap && (
<p id={MACHINE_IDS.requirement} className={REQUIREMENT_CAPTION_CLASS}>
Expand Down Expand Up @@ -391,7 +399,7 @@ export function ConnectPanel({
</div>

<p className="text-detail text-muted-foreground">{SSH_DISCLOSURE_COPY}</p>
<Button onClick={() => void submit()} disabled={submitting || destination === "" || nodeId === "" || !keyReady}>
<Button onClick={() => void submit()} disabled={!canSubmit}>
{submitting ? "Connecting…" : "Start SSH subshell"}
</Button>
</FieldGroup>
Expand Down
56 changes: 56 additions & 0 deletions apps/server/web/src/components/connect/no-ssh-targets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Button, CardTitle, type Node } from "@internal/node-admin";
import { useNavigate } from "@tanstack/react-router";
import { usePublicSettings } from "@/hooks/use-public-settings";
import { canAddNode, NODE_ENROLLMENT_OFF_COPY } from "@/lib/node-enrollment";
import { sshMachineBlocker } from "@/lib/ssh-machine-readiness";

/** Keep setup reachable without asking for a destination that cannot be used. */
export function NoSshTargets({ nodes, onLeave }: { nodes: Node[]; onLeave?: () => void }) {
const navigate = useNavigate();
function leaveFor(go: () => void): void {
onLeave?.();
go();
}
const { data: settings } = usePublicSettings();
const mayAdd = canAddNode(settings);
const manageable = nodes.filter((node) => node.kind === "agent" && node.canManage);
return (
<div className="flex flex-col gap-4 rounded-lg border border-dashed p-6">
<CardTitle>No machine is ready for SSH</CardTitle>
<p className="text-detail text-muted-foreground">
An SSH terminal needs an online Subshell machine with SSH enabled to connect from.
{manageable.length === 0 &&
(mayAdd
? " Add a machine you own and enable SSH on it."
: " Ask an admin to allow node enrollment so you can add a machine you own.")}
</p>
{manageable.map((node) => (
<div key={node.id} className="flex flex-col gap-1">
<p className="text-detail">
{node.name}: {sshMachineBlocker(node)}
</p>
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => leaveFor(() => void navigate({ to: "/nodes/$id", params: { id: node.id } }))}
>
Open {node.name} settings
</Button>
</div>
))}
{mayAdd ? (
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => leaveFor(() => void navigate({ to: "/nodes" }))}
>
Add a node
</Button>
) : (
<p className="text-detail text-muted-foreground">{NODE_ENROLLMENT_OFF_COPY}</p>
)}
</div>
);
}
15 changes: 15 additions & 0 deletions apps/server/web/src/lib/ssh-machine-readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Node } from "@internal/node-admin";

/** SSH uses the machine owner's account, so a shared launch grant is insufficient. */
export function sshMachineBlocker(node: Node): string | null {
if (!node.canManage) return "SSH is available only to this machine’s owner, or an admin on the server.";
if (node.held) return "Update Subshell on this machine before using SSH.";
if (node.maintenance) return "End maintenance on this machine before using SSH.";
if (node.kind === "agent" && node.status !== "online") return "Start Subshell on this machine to bring it online.";
if (!node.canLaunch) return "Allow subshell launches on this machine before using SSH.";
if (!node.sshEnabled)
return node.kind === "local"
? "SSH is off here. An admin can switch it on from this machine's settings."
: "SSH is off on this machine. Its owner can switch it on from this machine's settings.";
return null;
}
Loading