Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e745303
docs(plans): Plan 3 - the destination-first UI (access downgrade + Co…
theogravity Oct 8, 2026
d92f23b
feat(api): ssh panes read as view-only in subshell views for everyone…
theogravity Oct 8, 2026
80ef59f
feat(web): ssh surface plumbing - types, hooks, and the Connect entry
theogravity Oct 8, 2026
c43cb77
fix(node-admin): ApiError keeps the parsed failure body beside the sl…
theogravity Oct 8, 2026
0d695f1
feat(web): the destination-first Connect page
theogravity Oct 8, 2026
d45df99
fix(web): reviewer round 1 - the machine field's explanation lines ar…
theogravity Oct 8, 2026
db78242
feat(web): ssh panes label themselves and tell viewers why typing is off
theogravity Oct 8, 2026
8da407e
test(e2e): connect flow end to end - destination-first launch and the…
theogravity Oct 8, 2026
20edf35
chore(changeset): the destination-first UI (Milestone 1 complete)
theogravity Oct 8, 2026
50ef752
fix(web): reviewer round 1 - maintenance names itself, Connect cannot…
theogravity Oct 8, 2026
02c644a
test(e2e): the gate-off pin follows the kind-aware remedy copy
theogravity Oct 8, 2026
db86b7f
fix(web): reviewer round 2 - the local gate remedy points at the mach…
theogravity Oct 8, 2026
74a7c4a
test(web): raise the per-test budget knob 30s to 45s
theogravity Oct 8, 2026
feb17da
test(web): reset the global fetch to the delegator after every test
theogravity Oct 8, 2026
fe0197b
test(web): reviewer - attribute the budget history and the fetch leak…
theogravity Oct 8, 2026
389dff3
test(ssh): align fixtures with current main
theogravity Oct 11, 2026
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
18 changes: 18 additions & 0 deletions .changeset/ssh-destination-first-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@internal/server": minor
"@internal/server-web": minor
---

The Connect page: one question first - where do you want to work? Pick a
destination from your remembered hosts, your recent ones, or the connecting
machine's SSH config aliases, or type any host; one button opens the pane.
The connecting machine defaults to your chosen one, machines whose SSH is off
stay named with the remedy rather than silently skipped, and a config that
needs settings Subshell does not run shows which ones blocked. SSH-terminal
panes now read as view-only to everyone but their owner everywhere the pane
is shown (the server's view says so, so the terminal is honestly read-only),
and the pane's header labels itself SSH and tells a viewer why typing is off.

Milestone 1 of the SSH-anywhere design is complete with this: an operator on
an enabled machine opens an interactive session at any sshd-only host, and no
private key or plaintext challenge ever crosses the plane.
4 changes: 3 additions & 1 deletion .claude/rules/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ fields. Name-length limits and historical specs aren't exceptions. Follow
the [content guide](../../apps/docs/STYLE.md#product-copy-and-confirmations)
for action labels, consequences, and examples.
**A required input takes a gold `*` at rest and a gold caption once the caret
leaves it empty; a hard error (a refused value, a server refusal) stays
leaves it empty; a required control with no caret-leave moment (a picker whose
nothing-picked state disables the submit) wears the gold caption at rest once
its options settle; a hard error (a refused value, a server refusal) stays
`text-destructive` red** (ruling 2026-09-30: gold says "nothing typed yet",
red says "what you typed is wrong"; `fieldErrorToned` reads the split).
**There is no 12px**: `detail` (13) is the floor, and `text-xs` /
Expand Down
3 changes: 3 additions & 0 deletions apps/server/api/src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export const SubshellSchema = t.Object({
access: t.Union([t.Literal("owner"), t.Literal("edit"), t.Literal("view")], {
description: "Caller's effective access to this subshell (viewer-relative; never 'none' on a returned row)",
}),
ssh: t.Boolean({
description: "True when this pane is an SSH-terminal pane: only its owner can type into it.",
}),
nodeOffline: t.Boolean({
description:
"True when the subshell's node is disconnected. The subshell may still be running there. Always false for subshells on the server host.",
Expand Down
161 changes: 161 additions & 0 deletions apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { hashPassword } from "better-auth/crypto";
import { subshellRoutes } from "@/api/subshells/index.js";
import { db } from "@/db/index.js";
import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js";
import { SubshellsRepository } from "@/db/repositories/subshells.repository.js";
import { UsersRepository } from "@/db/repositories/users.repository.js";
import { effectiveViewerAccess } from "@/services/subshells.service.js";
import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js";

/**
* The ssh view truth (spec 2026-10-07 §5.4, plan 3 decision 1): an ssh pane
* reads as `view` + `ssh: true` to EVERY non-owner in both view paths (GET
* detail and GET list, the same two doors the MCP `get_subshell`/
* `list_subshells` read through), while enforcement itself stays where tier 2
* put it (`paneInputAllowed` at the input doors). Ordinary panes are
* untouched: an `edit` grantee still reads `edit`. Owner and `view` pass
* through unchanged. The downgrade must never widen anything: it keys on the
* snapshot column's PRESENCE, not its content.
*/
describe("ssh panes in subshell views: the non-owner access downgrade", () => {
const pw = "ssh-view-1";
const aliceEmail = `sv-alice-${crypto.randomUUID()}@subshell.local`;
const bobEmail = `sv-bob-${crypto.randomUUID()}@subshell.local`;
const carolEmail = `sv-carol-${crypto.randomUUID()}@subshell.local`;
const adminEmail = `sv-admin-${crypto.randomUUID()}@subshell.local`;
let aliceId: string;
let bobId: string;
let carolId: string;
let aliceCookie: string;
let bobCookie: string;
let carolCookie: string;
const created: string[] = [];

async function mkUser(email: string, role: "user" | "admin"): Promise<string> {
return await new UsersRepository(db).createUser({
email,
name: email,
passwordHash: await hashPassword(pw),
role,
});
}
/** Alice owns every fixture row; `ssh` rides the row exactly as launch writes it. */
async function ownSubshell(id: string, ssh: string | null = null): Promise<void> {
created.push(id);
await new SubshellsRepository(db).create({
id,
userId: aliceId,
presetId: "p",
harnessId: "ssh",
name: id,
workingDir: "/tmp",
tmuxSocket: null,
ssh,
});
}

beforeAll(async () => {
await setupAuthTables();
aliceId = await mkUser(aliceEmail, "user");
bobId = await mkUser(bobEmail, "user");
carolId = await mkUser(carolEmail, "user");
await mkUser(adminEmail, "admin"); // signed in per test (mirrors the sharing matrix file)
aliceCookie = await signIn(aliceEmail, pw);
bobCookie = await signIn(bobEmail, pw);
carolCookie = await signIn(carolEmail, pw);

// The snapshot content is irrelevant to the rule (presence is the kind
// fact); this one is valid JSON so the fixture also reads plausibly.
await ownSubshell("s_ssh", JSON.stringify({ destination: "dev.example.com", node: "local" }));
await ownSubshell("s_plain"); // ordinary pane: ssh NULL
const shares = new SubshellSharesRepository(db);
await shares.replaceForSubshell(
"s_ssh",
[
{ granteeUserId: bobId, permission: "edit" },
{ granteeUserId: carolId, permission: "view" },
],
aliceId,
);
await shares.replaceForSubshell("s_plain", [{ granteeUserId: bobId, permission: "edit" }], aliceId);
});

afterAll(async () => {
for (const id of created) await db.deleteFrom("subshells").where("id", "=", id).execute();
for (const email of [aliceEmail, bobEmail, carolEmail, adminEmail]) await deleteUserByEmailOrId(email);
});

function req(method: string, path: string, cookie: string) {
return subshellRoutes.fetch(
new Request(`http://localhost:3080/api/subshells${path}`, {
method,
headers: { cookie: `better-auth.session_token=${cookie}` },
}),
);
}
async function get(path: string, cookie: string) {
return (await (await req("GET", path, cookie)).json()) as { access: string; ssh: boolean };
}

it("an edit grantee GETs an ssh pane as view-only: access 'view', ssh true (detail path)", async () => {
const got = await get("/s_ssh", bobCookie);
expect(got.access).toBe("view");
expect(got.ssh).toBe(true);
});

it("the owner keeps access 'owner' and sees ssh true", async () => {
const got = await get("/s_ssh", aliceCookie);
expect(got.access).toBe("owner");
expect(got.ssh).toBe(true);
});

it("a view grantee is unchanged (already view), and still learns ssh", async () => {
const got = await get("/s_ssh", carolCookie);
expect(got.access).toBe("view");
expect(got.ssh).toBe(true);
});

it("an ordinary pane is untouched: the edit grantee still reads access 'edit', ssh false", async () => {
const got = await get("/s_plain", bobCookie);
expect(got.access).toBe("edit");
expect(got.ssh).toBe(false);
});

it("admins need a grant to see foreign SSH and ordinary panes", async () => {
const cookie = await signIn(adminEmail, pw);
expect((await req("GET", "/s_ssh", cookie)).status).toBe(404);
expect((await req("GET", "/s_plain", cookie)).status).toBe(404);
const rows = (await (await req("GET", "", cookie)).json()) as { id: string }[];
expect(rows.some((row) => row.id === "s_ssh" || row.id === "s_plain")).toBe(false);
});

it("the LIST path downgrades the same way (one helper, both doors; the MCP list rides this route)", async () => {
const rows = (await (await req("GET", "", bobCookie)).json()) as {
id: string;
access: string;
ssh: boolean;
}[];
const byId = new Map(rows.map((r) => [r.id, r]));
expect(byId.get("s_ssh")).toMatchObject({ access: "view", ssh: true });
expect(byId.get("s_plain")).toMatchObject({ access: "edit", ssh: false });
});
});

describe("effectiveViewerAccess (the downgrade helper, exported for test)", () => {
const sshRow = { ssh: "x", userId: "owner-1" }; // presence keys the rule: content never parsed
const plainRow = { ssh: null, userId: "owner-1" };

it("downgrades edit to view on an ssh row", () => {
expect(effectiveViewerAccess(sshRow, "edit")).toBe("view");
});
it("leaves owner and view as resolved on an ssh row", () => {
expect(effectiveViewerAccess(sshRow, "owner")).toBe("owner");
expect(effectiveViewerAccess(sshRow, "view")).toBe("view");
});
it("leaves every level as resolved on an ordinary row", () => {
expect(effectiveViewerAccess(plainRow, "edit")).toBe("edit");
expect(effectiveViewerAccess(plainRow, "owner")).toBe("owner");
expect(effectiveViewerAccess(plainRow, "view")).toBe("view");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function row(overrides: Partial<Parameters<typeof toSubshellView>[0]> = {}) {
waitingSince: null,
lastPushUrgency: null,
crossAgent: 0,
ssh: null,
...overrides,
} satisfies Parameters<typeof toSubshellView>[0];
}
Expand All @@ -51,6 +52,16 @@ describe("toSubshellView notification fields", () => {
expect(toSubshellView(row({ crossAgent: 0 }), "running").crossAgent).toBe(false);
});

it("maps the ssh snapshot column to a presence boolean and never echoes the snapshot (spec 2026-10-07)", () => {
// The rule keys on PRESENCE, not parseability: an unparseable string is
// still an ssh pane (validation belongs to the launch boundary).
expect(toSubshellView(row({ ssh: "x" }), "running").ssh).toBe(true);
const mapped = toSubshellView(row({ ssh: '{"destination":"dev.example.com"}' }), "running");
expect(mapped.ssh).toBe(true);
expect(JSON.stringify(mapped)).not.toContain("dev.example.com");
expect(toSubshellView(row(), "running").ssh).toBe(false);
});

it("defaults access to 'owner' and honours an explicit override (sharing, spec 2026-08-31)", () => {
// A returned view is always visible to someone, so it never carries "none".
expect(toSubshellView(row(), "running").access).toBe("owner");
Expand Down
5 changes: 5 additions & 0 deletions apps/server/api/src/services/subshell-manager.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2204,6 +2204,8 @@ export function toSubshellView(
waitingSince: string | null;
lastPushUrgency: number | null;
crossAgent: number;
/** The ssh snapshot column (migration 0048); read for presence only, never parsed here. */
ssh: string | null;
},
status: string,
/** The subshell's current screen, bottom-first-trimmed; empty when not running. */
Expand Down Expand Up @@ -2262,6 +2264,9 @@ export function toSubshellView(
// (spec 2026-09-23) — what turns the rail's dot into a bell.
unseenPush: row.lastPushUrgency !== null,
access,
// The pane's KIND fact: the ssh snapshot column's PRESENCE (migration
// 0048). The snapshot itself stays never-serialized (its db-types doc).
ssh: row.ssh !== null,
// Agent node unreachable right now (see the param doc) — the UI's
// "node offline" chip; false for every local subshell.
nodeOffline,
Expand Down
32 changes: 29 additions & 3 deletions apps/server/api/src/services/subshells.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ type SubshellView = NonNullable<Awaited<ReturnType<SubshellManagerService["getSu
sharedWithViewer?: boolean;
};

/**
* The view-layer downgrade for ssh panes (spec 2026-10-07 §5.4, plan 3
* decision 1): a resolved `edit` on an ssh row reads as `view` to its holder,
* so the client's single truth (`readOnly = access === "view"`) already says
* what enforcement has said since tier 2: the input doors refuse every
* non-owner on an ssh pane (`paneInputAllowed`, unchanged here). Ordinary
* panes are untouched; `owner` and `view` pass through as resolved. No
* `viewerId` parameter is needed because the resolver returns `edit` only to
* non-owners (an owner resolves to `owner` before the boost and grants are
* ever consulted). The rule keys on the snapshot column's PRESENCE, never its
* content: the pane's kind fact, same as the input door.
*
* Both view paths apply it, so REST list/detail, the live snapshot and the
* MCP `list_subshells`/`get_subshell` reads all ride the one answer.
* Exported for test; consumers should go through the service methods.
*/
export function effectiveViewerAccess(
row: Pick<SubshellTable, "ssh" | "userId">,
access: Exclude<Access, "none">,
): Exclude<Access, "none"> {
if (access === "edit" && row.ssh !== null) return "view";
return access;
}

/** A sharing grant as returned to the client, with the grantee label resolved. */
interface SubshellShareView {
/** Share row id */
Expand Down Expand Up @@ -671,7 +695,7 @@ export class SubshellsService extends BaseService {
const accessBy = new Map<string, Exclude<Access, "none">>();
for (const row of rows) {
const access = resolveSubshellAccess(viewerId, row.userId, sharesBy.get(row.id) ?? []);
accessBy.set(row.id, access === "none" ? "view" : access);
accessBy.set(row.id, effectiveViewerAccess(row, access === "none" ? "view" : access));
}
const views = await this.#manager.toViews(rows, opts);
return views.map((view) => ({
Expand Down Expand Up @@ -854,11 +878,13 @@ export class SubshellsService extends BaseService {
if (!subshell) throw new SubshellError("not_found", "Subshell not found");
// One extra read on a single-row path: the gate resolves access without
// handing back the grants it looked at, and the disclosure warning needs
// the audience, not just the caller's own level.
// the audience, not just the caller's own level. The gate's resolver hands
// back the RAW level; the ssh view downgrade (plan 3 decision 1) rides the
// same helper the list applies, so both doors answer alike.
const shares = (await this.repos.subshellShares.listForSubshells([id])).get(id) ?? [];
return {
...subshell,
access: access === "none" ? "view" : access,
access: effectiveViewerAccess(row, access === "none" ? "view" : access),
sharedWithViewer:
access !== "owner" && shares.some((share) => share.granteeUserId === null || share.granteeUserId === viewerId),
...shareExposure(shares),
Expand Down
17 changes: 17 additions & 0 deletions apps/server/web/docs/connect-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# The Connect page

Surface doc for `apps/server/web`, spec 2026-10-07 §7 (`/connect`, plan 3).

The page asks one question first: a destination, chosen from the caller's
saved rows, their recent rows, and the picked machine's SSH config aliases -
or typed free, which the list mirrors as its own row until it is committed.
One POST launches: the connecting machine resolves the token on the human's
own SSH config, and the panel renders the server's refusal (the blocked
`settings` list included, read from the parsed body) rather than guessing.

Two UI facts worth keeping deliberate: a machine whose SSH is off stays in
the disclosure greyed with its reason, never hidden (spec 2026-10-07 §4.3);
and the destination field is an input-plus-panel, not the shared combobox,
because Base UI's held input loses typed text when its items swap on every
keystroke (the posture is the working-directory field's, for the same
reason).
2 changes: 1 addition & 1 deletion apps/server/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"lint": "biome check --no-errors-on-unmatched --write --unsafe src",
"lint:check": "biome check --no-errors-on-unmatched src",
"lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src",
"test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 12 bun test --timeout 30000 --pass-with-no-tests",
"test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 12 bun test --timeout 45000 --pass-with-no-tests",
"verify-types": "tsc --noEmit"
},
"dependencies": {
Expand Down
8 changes: 7 additions & 1 deletion 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 { ServerCog, Settings, SlidersHorizontal } from "lucide-react";
import { Cable, 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,6 +20,12 @@ 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("no two visible icons are the same — group headers included", () => {
// Icons are how the collapsed rail names a page, so a duplicate there is
// two rows that read as one. The group's own icon counts: it sits in the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function makeSource(overrides: Partial<SubshellView> = {}): SubshellView {
waitingSince: null,
unseenPush: false,
access: "owner",
ssh: false,
...overrides,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function makeSubshell(overrides: Partial<SubshellView> = {}): SubshellView {
waitingSince: null,
unseenPush: false,
access: "owner",
ssh: false,
nodeOffline: false,
...overrides,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1355,10 +1355,12 @@ describe("NetworkPluginCard: the rules that are not about one state", () => {
// 7.6s wall for this act/NDJSON chain at 0.4 CPU (it passes in 25ms
// idle). bun's default 5s per-test timeout was firing mid-chain. The
// per-wait budgets stay honest; the load allowance is NOT here anymore -
// the package's script carries `--timeout 30000` (issue #261 round 2,
// after CI's serial run measured this file's sibling chain at 26.2 s
// against a 20 s per-test ceiling), and one knob per package is the rule
// the server package's testing-notes record for exactly this whack-a-mole.
// the package's script carries `--timeout 45000` (issue #261 round 2 set it
// to 30000 after CI's serial run measured this file's sibling chain at
// 26.2 s against a 20 s ceiling; the 2026-10-08 serial run timed out the
// fail-then-succeed test BELOW at 30.098 s on a larger suite, moving the
// knob one more notch), and one knob per package is the rule the server
// package's testing-notes record for exactly this whack-a-mole.
it("a failed act says so", async () => {
failedPublishThenLeave();
await renderCard(JOINED);
Expand Down Expand Up @@ -1399,10 +1401,11 @@ describe("NetworkPluginCard: the rules that are not about one state", () => {
const dialog = await screen.findByRole("dialog", {}, { timeout: 1200 });
fireEvent.click(within(dialog).getByRole("button", { name: "Disconnect" }));
await waitFor(() => expect(screen.queryByText(/the daemon went away/)).toBeNull(), { timeout: 1200 });
// No per-test ceiling: the package's 30 s script budget governs, per the
// No per-test ceiling: the package's 45 s script budget governs, per the
// rationale on the test above. This one runs the full fail-then-succeed
// chain - measured 12-16 s on CI's slice when the 20 s ceiling was set,
// 26.2 s on 2026-09-30's, which is what moved it onto the package knob.
// 26.2 s on 2026-09-30's (which moved the knob 20 to 30), and it crossed
// 30 s (timed out at 30.098 s) on 2026-10-08's larger suite (30 to 45).
});

it("uses the vendor's own words for the credential and for publishing", async () => {
Expand Down
Loading
Loading