Public: anyone can read this; the crew can change it.
Your account lists the machine shells you hold, live and recent, and the sessions MCP tool lists them too #10609
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10544-your-account-lists-the-machine-shells-yo → main 5 commits fork 4 threads · 4 unresolved
14 files
changed +412
−6
docs/design/audit.md +1
| @@ -133,6 +133,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 133 | 133 | - `/settings` — `pages/account.ts` · my account · the rest of settings · colour 1. |
| 134 | 134 | - `/settings/password` — `pages/account.ts` · change password · `/settings/passkeys` · colour 1. |
| 135 | 135 | - `/settings/tokens` — `pages/account.ts` · my API tokens · `/settings/keys` (credentials of two kinds on two pages) · colour 1. |
| 136 | - `/settings/shells` — `pages/account.ts` · the machine shells I hold, live then ended · `/settings/keys` · colour 1. | |
| 136 | 137 | - `/settings/keys` — `pages/account.ts` · my SSH keys · `/settings/tokens` · colour 1. |
| 137 | 138 | - `/settings/passkeys` — `pages/passkeys.ts` · my passkeys · `/settings/password` · none seen. |
| 138 | 139 | - `/settings/confirms` — `pages/confirms.ts` · what the forge's agent asked to do and waits for my yes · the terminal's Confirms (y) · none seen. |
docs/features/my-shells.md added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | # Your shells | |
| 2 | ||
| 3 | The machine shells you hold: the live ones first (started, last input, last output, how many are attached, CPU and | |
| 4 | memory when the cell reports them, a dash when it does not), then the last ten that ended and why. Read only, your | |
| 5 | own account only; another person's shells are never listed. | |
| 6 | ||
| 7 | Try it: | |
| 8 | - Web: open `/settings/shells`, or press the palette and choose **Your shells**. With none, it says so in one line. | |
| 9 | - Terminal: open the palette (`/`) and choose **Your shells**; `r` reads again, `esc` closes. | |
| 10 | - MCP: `sessions` with `kind: "shells"` returns the same rows. | |
| 11 | ||
| 12 | Both doors read `myShells` in `src/machines/shell.ts`. The terminal screen is `src/tui/screens/shells.ts`; the page is | |
| 13 | `shellsPage` in `src/web/pages/account.ts`. Left out: ending a shell from the list, and a Settings sidebar row (the | |
| 14 | settings page's first-view budget has no word to spare). | |
| 15 | ||
| 16 | Pinned by `test/my-shells.test.ts`. |
src/http/mcp/tools/sessions.ts +11 −1
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import { accountSignIns, endAccountSession } from "../../../actions/signins.ts"; |
| 2 | import { myShells } from "../../../machines/shell.ts"; | |
| 2 | 3 | import { good, bad, type Tool } from "../shared.ts"; |
| 3 | 4 | |
| 4 | 5 | export const tool: Tool = { |
| 5 | 6 | name: "sessions", |
| 6 | doc: "`{ username?, action?, id? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: \"end\"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; an admin-scope token, the route's refusals", | |
| 7 | doc: "`{ username?, action?, id?, kind? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: \"end\"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; `kind: \"shells\"` lists your machine shells, live then ended; an admin-scope token, the route's refusals", | |
| 7 | 8 | description: "Where you are signed in: your tokens and browsers, never a secret; `username` for a person as owner; action end + id signs a browser out.", |
| 8 | 9 | inputSchema: { |
| 9 | 10 | type: "object", |
| @@ -11,6 +12,7 @@ export const tool: Tool = { | ||
| 11 | 12 | username: { type: "string", maxLength: 64 }, |
| 12 | 13 | action: { type: "string", enum: ["list", "end"] }, |
| 13 | 14 | id: { type: "string", maxLength: 64 }, |
| 15 | kind: { type: "string", enum: ["signins", "shells"] }, | |
| 14 | 16 | }, |
| 15 | 17 | }, |
| 16 | 18 | writes: true, |
| @@ -19,6 +21,14 @@ export const tool: Tool = { | ||
| 19 | 21 | run(ctx, user, args) { |
| 20 | 22 | const name = typeof args["username"] === "string" && args["username"] !== "" ? args["username"] : user.username; |
| 21 | 23 | const action = args["action"] ?? "list"; |
| 24 | const kind = args["kind"] ?? "signins"; | |
| 25 | if (kind !== "signins" && kind !== "shells") return bad("bad_kind", "kind is signins or shells"); | |
| 26 | if (kind === "shells") { | |
| 27 | if (action !== "list") return bad("bad_action", "shells are listed, not ended, here"); | |
| 28 | if (name !== user.username) return bad("not_found", "shells are your own"); | |
| 29 | const shells = myShells(ctx.app, user.id); | |
| 30 | return good({ shells, says: `${shells.length} shell${shells.length === 1 ? "" : "s"}.` }); | |
| 31 | } | |
| 22 | 32 | if (action !== "list" && action !== "end") return bad("bad_action", "action is list or end"); |
| 23 | 33 | if (action === "end") { |
| 24 | 34 | const id = args["id"]; |
src/http/settings_routes.ts +2
| @@ -88,6 +88,7 @@ import { | ||
| 88 | 88 | } from "../web/pages/crew.ts"; |
| 89 | 89 | import { |
| 90 | 90 | accountPage, |
| 91 | shellsPage, | |
| 91 | 92 | notifyUpdate, |
| 92 | 93 | edgeLink, |
| 93 | 94 | edgeUnlink, |
| @@ -146,6 +147,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 146 | 147 | router.add("POST", "/settings/everyday/options", everydayOptions); |
| 147 | 148 | router.add("POST", "/settings/everyday/switch", everydaySwitch); |
| 148 | 149 | router.add("POST", "/settings/everyday/keep", everydayKeep); |
| 150 | router.add("GET", "/settings/shells", shellsPage); | |
| 149 | 151 | router.add("GET", "/settings/tokens", tokensPage); |
| 150 | 152 | router.add("POST", "/settings/tokens", tokenCreate); |
| 151 | 153 | router.add("POST", "/settings/tokens/:id/delete", tokenDelete); |
src/machines/shell.ts +93 −2
| @@ -26,7 +26,7 @@ import { cellHides } from "../spawn/hosted-mark.ts"; | ||
| 26 | 26 | import { join } from "node:path"; |
| 27 | 27 | import type { Database } from "bun:sqlite"; |
| 28 | 28 | import type { App } from "../app.ts"; |
| 29 | import { openCell, resolveLimits, type Cell, type CellSpec } from "../apps/contain.ts"; | |
| 29 | import { cellCounters, openCell, resolveLimits, type Cell, type CellSpec } from "../apps/contain.ts"; | |
| 30 | 30 | import { startTerminal, type TerminalProgram } from "../apps/tty.ts"; |
| 31 | 31 | import { holdOpen } from "../http/caller.ts"; |
| 32 | 32 | import { canWrite, getRepo, getRepoById, type Repo } from "../core/repos.ts"; |
| @@ -440,7 +440,7 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro | ||
| 440 | 440 | app, |
| 441 | 441 | dir, |
| 442 | 442 | grant.machine, |
| 443 | `machine:${grant.repo.id}/${grant.machine.name}`, | |
| 443 | cellLabel(grant.repo.id, grant.machine.name, grant.user.id), | |
| 444 | 444 | ownAppUrls(ownApp(app, grant.repo)), |
| 445 | 445 | ), |
| 446 | 446 | ); |
| @@ -501,6 +501,97 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro | ||
| 501 | 501 | } |
| 502 | 502 | } |
| 503 | 503 | |
| 504 | /** The label a shell's cell is watched under: one per person, so its counters are theirs alone. */ | |
| 505 | export const cellLabel = (repoId: string, machine: string, userId: string): string => | |
| 506 | `machine:${repoId}/${machine}/${userId}`; | |
| 507 | ||
| 508 | /** One shell a person holds or held: plain data for the account page and the `sessions` tool. */ | |
| 509 | export interface ShellRow { | |
| 510 | readonly id: string; | |
| 511 | /** `owner/name`. */ | |
| 512 | readonly repository: string; | |
| 513 | readonly machine: string; | |
| 514 | readonly startedAt: string; | |
| 515 | /** Null when the shell has ended: its last keystroke is not kept. */ | |
| 516 | readonly lastInputAt: string | null; | |
| 517 | readonly lastOutputAt: string | null; | |
| 518 | /** Connections attached now; 0 is detached. */ | |
| 519 | readonly peers: number; | |
| 520 | /** Cores in use and resident MiB, null where the cell does not report them. */ | |
| 521 | readonly cpuCores: number | null; | |
| 522 | readonly memoryMiB: number | null; | |
| 523 | readonly endedAt: string | null; | |
| 524 | readonly endReason: EndReason | null; | |
| 525 | /** The end in words, null while live. */ | |
| 526 | readonly endedBecause: string | null; | |
| 527 | } | |
| 528 | ||
| 529 | const iso = (ms: number): string => new Date(ms).toISOString(); | |
| 530 | ||
| 531 | /** The shells running now for one person, newest first. */ | |
| 532 | export function liveShells(app: App, userId: string): ShellRow[] { | |
| 533 | const counters = new Map(cellCounters().map((c) => [c.label, c])); | |
| 534 | const rows: ShellRow[] = []; | |
| 535 | for (const s of sessionsOf(app).values()) { | |
| 536 | if (s.userId !== userId) continue; | |
| 537 | const repo = getRepoById(app.db, s.repoId); | |
| 538 | const started = app.db | |
| 539 | .query<{ machine: string; started_at: string }, [string]>("SELECT machine, started_at FROM machine_sessions WHERE id = ?") | |
| 540 | .get(s.row); | |
| 541 | if (repo === undefined || started === null) continue; | |
| 542 | const counter = counters.get(cellLabel(s.repoId, started.machine, userId)); | |
| 543 | rows.push({ | |
| 544 | id: s.row, | |
| 545 | repository: `${repo.ownerName}/${repo.name}`, | |
| 546 | machine: started.machine, | |
| 547 | startedAt: started.started_at, | |
| 548 | lastInputAt: iso(s.lastInput), | |
| 549 | lastOutputAt: iso(s.lastOutput), | |
| 550 | peers: s.peers.size, | |
| 551 | cpuCores: counter?.cores ?? null, | |
| 552 | memoryMiB: counter === undefined ? null : Math.round(counter.memoryBytes / 1048576), | |
| 553 | endedAt: null, | |
| 554 | endReason: null, | |
| 555 | endedBecause: null, | |
| 556 | }); | |
| 557 | } | |
| 558 | return rows.sort((a, b) => b.startedAt.localeCompare(a.startedAt)); | |
| 559 | } | |
| 560 | ||
| 561 | /** How many ended shells `myShells` adds after the live ones. */ | |
| 562 | export const RECENT_ENDED = 10; | |
| 563 | ||
| 564 | /** A person's shells: the live ones, newest first, then the last ended ones, newest first. */ | |
| 565 | export function myShells(app: App, userId: string, ended = RECENT_ENDED): ShellRow[] { | |
| 566 | const gone = app.db | |
| 567 | .query< | |
| 568 | { id: string; machine: string; started_at: string; ended_at: string; end_reason: EndReason; owner_name: string; name: string }, | |
| 569 | [string, number] | |
| 570 | >( | |
| 571 | `SELECT s.id, s.machine, s.started_at, s.ended_at, s.end_reason, o.username AS owner_name, r.name | |
| 572 | FROM machine_sessions s JOIN repo r ON r.id = s.repo_id JOIN user o ON o.id = r.owner_id | |
| 573 | WHERE s.owner_id = ? AND s.ended_at IS NOT NULL ORDER BY s.started_at DESC LIMIT ?`, | |
| 574 | ) | |
| 575 | .all(userId, ended) | |
| 576 | .map( | |
| 577 | (r): ShellRow => ({ | |
| 578 | id: r.id, | |
| 579 | repository: `${r.owner_name}/${r.name}`, | |
| 580 | machine: r.machine, | |
| 581 | startedAt: r.started_at, | |
| 582 | lastInputAt: null, | |
| 583 | lastOutputAt: null, | |
| 584 | peers: 0, | |
| 585 | cpuCores: null, | |
| 586 | memoryMiB: null, | |
| 587 | endedAt: r.ended_at, | |
| 588 | endReason: r.end_reason, | |
| 589 | endedBecause: ENDED[r.end_reason] ?? r.end_reason, | |
| 590 | }), | |
| 591 | ); | |
| 592 | return [...liveShells(app, userId), ...gone]; | |
| 593 | } | |
| 594 | ||
| 504 | 595 | /** End every session (the forge is stopping). */ |
| 505 | 596 | export function stopShells(app: App): void { |
| 506 | 597 | for (const session of [...sessionsOf(app).values()]) end(app, session, "stopped"); |
src/tui/forge.ts +9
| @@ -17,6 +17,7 @@ import { sourceFor } from "../view/source.ts"; | ||
| 17 | 17 | import { fleetCommand } from "./screens/fleet.ts"; |
| 18 | 18 | import { dataCommand } from "./screens/data.ts"; |
| 19 | 19 | import { packsCommand } from "./screens/packs.ts"; |
| 20 | import { shellsScreen } from "./screens/shells.ts"; | |
| 20 | 21 | import { forkTreeScreen } from "./screens/fork_tree.ts"; |
| 21 | 22 | import { roadmapScreen } from "./screens/roadmap.ts"; |
| 22 | 23 | import { App, type Ctx, type Screen } from "./app/app.ts"; |
| @@ -342,6 +343,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 342 | 343 | hint: "what it closed, deferred and asked you about today", |
| 343 | 344 | run: () => ctx.push(deciderScreen(session, ctx)), |
| 344 | 345 | }); |
| 346 | // The machine shells this person holds (#10544); an agent holds none. | |
| 347 | if (session.signedIn && session.user?.kind !== "agent") | |
| 348 | commands.push({ | |
| 349 | id: "shells", | |
| 350 | title: "Your shells", | |
| 351 | hint: "the machine shells you hold, live then ended", | |
| 352 | run: () => ctx.push(shellsScreen(session, ctx)), | |
| 353 | }); | |
| 345 | 354 | // Where a decision finds the owner; an agent is never offered it. |
| 346 | 355 | if (session.signedIn && session.user?.kind !== "agent") |
| 347 | 356 | commands.push({ |
src/tui/screens/shells.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * Your shells in the terminal (#10544): the machine shells you hold, live | |
| 3 | * first, then the last ended ones. Read only; the rows are `myShells`, the | |
| 4 | * same the account page and MCP `sessions` (kind: shells) print. | |
| 5 | */ | |
| 6 | ||
| 7 | import { myShells, type ShellRow } from "../../machines/shell.ts"; | |
| 8 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 9 | import { binding } from "../app/keys.ts"; | |
| 10 | import { style } from "../term/style.ts"; | |
| 11 | import { truncate } from "../term/width.ts"; | |
| 12 | import type { Session } from "../session.ts"; | |
| 13 | ||
| 14 | const reading = (n: number | null, unit: string): string => (n === null ? "-" : `${n}${unit}`); | |
| 15 | ||
| 16 | /** One shell as a line of plain words. */ | |
| 17 | export function shellLine(s: ShellRow): string { | |
| 18 | if (s.endedAt !== null) return `${s.repository} · ${s.machine} ended ${s.endedAt.slice(0, 16).replace("T", " ")}, ${s.endedBecause}`; | |
| 19 | const cpu = reading(s.cpuCores === null ? null : Math.round(s.cpuCores * 100), "%"); | |
| 20 | return `${s.repository} · ${s.machine} running, ${s.peers === 0 ? "nobody attached" : `${s.peers} attached`}, CPU ${cpu}, memory ${reading(s.memoryMiB, " MiB")}`; | |
| 21 | } | |
| 22 | ||
| 23 | export function shellsScreen(f: Session, ctx: Ctx): Screen { | |
| 24 | let rows: ShellRow[] = f.user === undefined ? [] : myShells(f.app, f.user.id); | |
| 25 | return { | |
| 26 | title: "Your shells", | |
| 27 | keys: [binding(["r"], "read again"), binding(["esc"], "close")], | |
| 28 | render(area, s) { | |
| 29 | const c = ctx.theme.c; | |
| 30 | if (rows.length === 0) { | |
| 31 | s.text(area.x + 1, area.y, "You have opened no shell on a machine yet.", style(c.mutedFg, c.bg), area.x + area.w); | |
| 32 | return; | |
| 33 | } | |
| 34 | rows.forEach((row, i) => { | |
| 35 | if (i < area.h) s.text(area.x + 1, area.y + i, truncate(shellLine(row), area.w - 2), style(row.endedAt === null ? c.fg : c.mutedFg, c.bg), area.x + area.w); | |
| 36 | }); | |
| 37 | }, | |
| 38 | key(e) { | |
| 39 | if (e.ctrl || e.alt) return false; | |
| 40 | if (e.key === "r") { | |
| 41 | rows = f.user === undefined ? [] : myShells(f.app, f.user.id); | |
| 42 | return true; | |
| 43 | } | |
| 44 | if (e.key === "esc" || e.key === "q") { | |
| 45 | ctx.pop(); | |
| 46 | return true; | |
| 47 | } | |
| 48 | return false; | |
| 49 | }, | |
| 50 | }; | |
| 51 | } |
src/web/layout.ts +1
| @@ -398,6 +398,7 @@ const STATIC_PALETTE: PaletteItem[] = [ | ||
| 398 | 398 | { t: "Access tokens", h: "/settings/tokens", c: "Settings" }, |
| 399 | 399 | { t: "SSH keys", h: "/settings/keys", c: "Settings" }, |
| 400 | 400 | { t: "Origins", h: "/settings/origins", c: "Settings" }, |
| 401 | { t: "Your shells", h: "/settings/shells", c: "Settings" }, | |
| 401 | 402 | ]; |
| 402 | 403 | |
| 403 | 404 | /** What a signed-out visitor can reach: places to go, not Create and Settings rows that end at a sign-in wall. */ |
src/web/pages/account.ts +42 −1
| @@ -74,6 +74,7 @@ import { | ||
| 74 | 74 | field as uiField, |
| 75 | 75 | icon, |
| 76 | 76 | } from "../ui.ts"; |
| 77 | import { myShells } from "../../machines/shell.ts"; | |
| 77 | 78 | import { everydayLine } from "./everyday.ts"; |
| 78 | 79 | import { passkeyJs } from "../passkey-client.ts"; |
| 79 | 80 | |
| @@ -97,7 +98,7 @@ const help = (notes: readonly (readonly [string, string | Html])[]): Html => | ||
| 97 | 98 | </dl> |
| 98 | 99 | </details>`; |
| 99 | 100 | |
| 100 | type Section = "profile" | "password" | "tokens" | "keys" | "passkeys"; | |
| 101 | type Section = "profile" | "password" | "tokens" | "keys" | "passkeys" | "shells"; | |
| 101 | 102 | |
| 102 | 103 | // The section list lives beside the newest page that joins it, so adding one |
| 103 | 104 | // cannot leave the others pointing at a nav that has forgotten it. |
| @@ -393,6 +394,46 @@ function profileView(ctx: Ctx, values: ProfileForm): Html { | ||
| 393 | 394 | )}`; |
| 394 | 395 | } |
| 395 | 396 | |
| 397 | /** A reading the cell may not give: a dash, never a zero that says "idle". */ | |
| 398 | const reading = (n: number | null, unit: string): string => (n === null ? "–" : `${n}${unit}`); | |
| 399 | ||
| 400 | /** The machine shells this person holds and last held (#10544). Read only; the live row says its few facts in a `dl`. */ | |
| 401 | export function shellsView(ctx: Ctx, user: User): Html { | |
| 402 | const shells = myShells(ctx.app, user.id); | |
| 403 | if (shells.length === 0) return html`<p class="muted" id="shells">You have opened no shell on a machine yet.</p>`; | |
| 404 | return html`<ul class="list setting-list" id="shells"> | |
outdated
Box 2 asks for an empty line that says so. Returning nothing leaves the palette link '/settings#shells' pointing at no anchor. Render one plain line, and fit the budget (e.g. a separate /settings/shells page). |
||
| 405 | ${shells.map((s) => | |
| 406 | s.endedAt === null | |
| 407 | ? row({ | |
| 408 | title: html`${s.repository} · ${s.machine}`, | |
| 409 | meta: html`Started ${time(s.startedAt)}`, | |
| 410 | trail: badge("running", { tone: "success" }), | |
outdated
Principle 1: the ● glyph and the 'running' badge say the same thing. Keep one. |
||
| 411 | facts: [ | |
| 412 | { label: "Last input", value: s.lastInputAt ? time(s.lastInputAt) : "–" }, | |
| 413 | { label: "Last output", value: s.lastOutputAt ? time(s.lastOutputAt) : "–" }, | |
| 414 | { label: "Attached", value: s.peers === 0 ? "nobody" : String(s.peers) }, | |
outdated
Principles 7 and 8: seven facts joined by dots in one line is hard to read. Show the few that matter in the row and put the rest in a dl row; use plain words. |
||
| 415 | { label: "CPU", value: reading(s.cpuCores === null ? null : Math.round(s.cpuCores * 100), "%") }, | |
| 416 | { label: "Memory", value: reading(s.memoryMiB, " MiB") }, | |
| 417 | ], | |
| 418 | data: { session: s.id }, | |
| 419 | }) | |
| 420 | : row({ | |
| 421 | title: html`${s.repository} · ${s.machine}`, | |
| 422 | meta: html`Ended ${time(s.endedAt)} · ${s.endedBecause}`, | |
| 423 | data: { session: s.id }, | |
| 424 | }), | |
| 425 | )} | |
| 426 | </ul>`; | |
| 427 | } | |
| 428 | ||
| 429 | // GET /settings/shells | |
| 430 | export function shellsPage(ctx: Ctx): Response { | |
| 431 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 432 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 433 | const body = shellsView(ctx, ctx.auth.user); | |
| 434 | return timePhase(ctx.request, "render", () => accountShell(ctx, "shells", "Shells", body)); | |
| 435 | } | |
| 436 | ||
| 396 | 437 | // GET /settings |
| 397 | 438 | export function accountPage(ctx: Ctx): Response { |
| 398 | 439 | if (!ctx.auth.user) return signInFirst(ctx); |
src/web/pages/origins.ts +1
| @@ -93,6 +93,7 @@ export type AccountSection = | ||
| 93 | 93 | | "confirms" |
| 94 | 94 | | "password" |
| 95 | 95 | | "tokens" |
| 96 | | "shells" | |
| 96 | 97 | | "keys" |
| 97 | 98 | | "model" |
| 98 | 99 | | "passkeys" |
test/my-shells.test.ts added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | /** | |
| 2 | * Your shells (#10544): the read returns a live session with its real start | |
| 3 | * and last-input times; the account page lists it, escaped, with the ended | |
| 4 | * ones after it; `sessions` with `kind: "shells"` returns the same rows. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo, type Repo } from "../src/core/repos.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { liveShells, myShells, recordEnd, recordStart, setShellOptions, stopShells } from "../src/machines/shell.ts"; | |
| 16 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let repo: Repo; | |
| 24 | let aliceId: string; | |
| 25 | let cookie: string; | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "my-shells-")), port: 0 }); | |
| 29 | setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 1000 }); | |
| 30 | const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "correct horse battery", origin: "seed", isAdmin: true, trusted: true }); | |
| 31 | if (!made.ok) throw new Error(String(made.error)); | |
| 32 | aliceId = made.value.id; | |
| 33 | cookie = `forge_session=${createSession(app.db, aliceId).token}`; | |
| 34 | const r = await createRepo(app.db, app.reposRoot, { ownerId: aliceId, name: "lab", visibility: "public" }); | |
| 35 | if (!r.ok) throw new Error(String(r.error)); | |
| 36 | repo = r.value; | |
| 37 | servers = await serve(app); | |
| 38 | }); | |
| 39 | ||
| 40 | afterAll(() => { | |
| 41 | stopShells(app); | |
| 42 | servers.stop(); | |
| 43 | app.close(); | |
| 44 | }); | |
| 45 | ||
| 46 | const page = async () => (await fetch(`${servers.url}/settings/shells`, { headers: { cookie } })).text(); | |
| 47 | ||
| 48 | async function mcpShells(): Promise<any> { | |
| 49 | const res = await fetch(`${servers.url}/mcp`, { | |
| 50 | method: "POST", | |
| 51 | headers: { | |
| 52 | "content-type": "application/json", | |
| 53 | accept: "application/json, text/event-stream", | |
| 54 | authorization: `Bearer ${tokenFor(app, "alice", "admin")}`, | |
| 55 | }, | |
| 56 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sessions", arguments: { kind: "shells" } } }), | |
| 57 | }); | |
| 58 | const body = (await res.json()) as any; | |
| 59 | return JSON.parse(body.result.content[0].text); | |
| 60 | } | |
| 61 | ||
| 62 | test("with no shell the page says so in one line", async () => { | |
| 63 | expect(myShells(app, aliceId)).toEqual([]); | |
| 64 | const html = await page(); | |
| 65 | expect(html).toContain("You have opened no shell on a machine yet."); | |
| 66 | expect((await mcpShells()).shells).toEqual([]); | |
| 67 | }); | |
| 68 | ||
| 69 | test("a live shell is read with its real times; ended ones follow, escaped, on the page and in the tool", async () => { | |
| 70 | const before = Date.now(); | |
| 71 | const ws = new WebSocket(`${servers.url.replace(/^http/, "ws")}/alice/lab/machines/standard/shell`, { | |
| 72 | headers: { cookie, origin: new URL(servers.url).origin }, | |
| 73 | } as unknown as string[]); | |
| 74 | await new Promise((ok, no) => { | |
| 75 | ws.addEventListener("open", ok); | |
| 76 | ws.addEventListener("error", () => no(new Error("refused"))); | |
| 77 | }); | |
| 78 | ws.send(new TextEncoder().encode("echo hi\n")); | |
| 79 | await Bun.sleep(200); | |
| 80 | ||
| 81 | // An ended shell, its machine name hostile: the page must escape it. | |
| 82 | const old = recordStart(app.db, repo.id, "<b>x</b>", aliceId); | |
| 83 | recordEnd(app.db, old, "idle"); | |
| 84 | ||
| 85 | const live = liveShells(app, aliceId); | |
| 86 | expect(live).toHaveLength(1); | |
| 87 | const s = live[0]!; | |
| 88 | expect(s).toMatchObject({ repository: "alice/lab", machine: "standard", peers: 1, endedAt: null }); | |
| 89 | expect(Date.parse(s.startedAt)).toBeGreaterThanOrEqual(before - 1000); | |
| 90 | expect(Date.parse(s.lastInputAt!)).toBeGreaterThanOrEqual(Date.parse(s.startedAt)); | |
| 91 | // No cell here, so no reading: a dash, not a zero. | |
| 92 | expect(s.cpuCores).toBeNull(); | |
| 93 | expect(s.memoryMiB).toBeNull(); | |
| 94 | ||
| 95 | const rows = myShells(app, aliceId); | |
| 96 | expect(rows.map((r) => r.endedAt === null)).toEqual([true, false]); | |
| 97 | expect(rows[1]).toMatchObject({ endReason: "idle", endedBecause: "idle too long" }); | |
| 98 | ||
| 99 | const html = await page(); | |
| 100 | expect(html).toContain("alice/lab · standard"); | |
| 101 | expect(html).toMatch(/<dt>Attached<\/dt><dd>1<\/dd>/); | |
| 102 | expect(html).toMatch(/<dt>CPU<\/dt><dd>–<\/dd>/); | |
| 103 | // One marker for "running", not a glyph and a badge. | |
| 104 | expect(html.match(/running/g)?.length).toBe(1); | |
| 105 | expect(html).toContain("idle too long"); | |
| 106 | expect(html).not.toContain("<b>x</b>"); | |
| 107 | expect(html).toContain("<b>x</b>"); | |
| 108 | expect(html.indexOf("alice/lab · standard")).toBeLessThan(html.indexOf("idle too long")); | |
| 109 | ||
| 110 | expect((await mcpShells()).shells).toEqual(JSON.parse(JSON.stringify(rows))); | |
| 111 | ||
| 112 | // The palette reaches it. | |
| 113 | expect(html).toContain("Your shells"); | |
| 114 | expect(html).toContain("/settings/shells"); | |
| 115 | ||
| 116 | ws.close(); | |
| 117 | }); |
test/palette-roster.test.ts +2
| @@ -80,6 +80,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 80 | 80 | ["asked", "Asked", "your thoughts and what became of them"], |
| 81 | 81 | ["thought-notes", "Notes", "write an idea and watch it become shipped work"], |
| 82 | 82 | ["decider", "Decider", "what it closed, deferred and asked you about today"], |
| 83 | ["shells", "Your shells", "the machine shells you hold, live then ended"], | |
| 83 | 84 | ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"], |
| 84 | 85 | ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"], |
| 85 | 86 | ["trusted", "Trusted sources", "who folds in without review; a trusts, x removes, s stops all, v reverts"], |
| @@ -275,6 +276,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 275 | 276 | ["asked", "Asked", "your thoughts and what became of them"], |
| 276 | 277 | ["thought-notes", "Notes", "write an idea and watch it become shipped work"], |
| 277 | 278 | ["decider", "Decider", "what it closed, deferred and asked you about today"], |
| 279 | ["shells", "Your shells", "the machine shells you hold, live then ended"], | |
| 278 | 280 | ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"], |
| 279 | 281 | ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"], |
| 280 | 282 | ["conversations", "Conversations", "your talks with the forge's agent; n starts one, r renames, x archives"], |
test/pull-quiet.test.ts +2 −2
| @@ -66,8 +66,8 @@ afterAll(async () => { | ||
| 66 | 66 | const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); |
| 67 | 67 | const bytes = (t: string) => new TextEncoder().encode(t).length; |
| 68 | 68 | |
| 69 | // Measured 31,084 (pull) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. | |
| 70 | const BUDGET = { pull: 31_250, list: 25_230 }; | |
| 69 | // Measured 31,130 (pull; 31,084 before the Your shells palette entry) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. | |
| 70 | const BUDGET = { pull: 31_400, list: 25_400 }; | |
| 71 | 71 | |
| 72 | 72 | test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => { |
| 73 | 73 | const text = await get(`/alice/r/pulls/${number}`); |
test/tui-my-shells.test.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** Your shells in the terminal (#10544): the palette opens it; own rows only; a person with none is told so. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 9 | import { recordEnd, recordStart } from "../src/machines/shell.ts"; | |
| 10 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 11 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | import { settleApp } from "./helpers/tui.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let dir: string; | |
| 18 | let app: App; | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | dir = await mkdtemp(join(tmpdir(), "tui-my-shells-")); | |
| 22 | app = createApp({ dataDir: dir, port: 0 }); | |
| 23 | for (const name of ["alice", "bob"]) { | |
| 24 | const u = await createUser(app.db, { username: name, email: `${name}@x.test`, password: "correct horse battery", origin: "seed", isAdmin: false, trusted: true }); | |
| 25 | if (!u.ok) throw new Error(String(u.error)); | |
| 26 | } | |
| 27 | const alice = getUserByName(app.db, "alice")!; | |
| 28 | const r = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "lab", visibility: "public" }); | |
| 29 | if (!r.ok) throw new Error(String(r.error)); | |
| 30 | recordEnd(app.db, recordStart(app.db, r.value.id, "standard", alice.id), "idle"); | |
| 31 | }); | |
| 32 | ||
| 33 | afterAll(async () => { | |
| 34 | app.close(); | |
| 35 | await rm(dir, { recursive: true, force: true }); | |
| 36 | }); | |
| 37 | ||
| 38 | async function shellsScreenText(username: string): Promise<string> { | |
| 39 | const { app: tui } = createForgeApp( | |
| 40 | app, | |
| 41 | { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, username)! }, | |
| 42 | { terminal: headlessTerminal(140, 32), exitHooks: false }, | |
| 43 | ); | |
| 44 | tui.start(); | |
| 45 | const key = async (k: string, ctrl = false) => { | |
| 46 | tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 47 | await settleApp(tui); | |
| 48 | }; | |
| 49 | await key("k", true); | |
| 50 | for (const ch of "Your shells") await key(ch); | |
| 51 | await key("enter"); | |
| 52 | const text = tui.screen.toText(); | |
| 53 | tui.stop(); | |
| 54 | return text; | |
| 55 | } | |
| 56 | ||
| 57 | test("the owner sees the ended shell and why; another person is told they have none", async () => { | |
| 58 | const mine = await shellsScreenText("alice"); | |
| 59 | expect(mine).toContain("alice/lab · standard"); | |
| 60 | expect(mine).toContain("idle too long"); | |
| 61 | const theirs = await shellsScreenText("bob"); | |
| 62 | expect(theirs).toContain("You have opened no shell on a machine yet."); | |
| 63 | expect(theirs).not.toContain("alice/lab"); | |
| 64 | }); |
This link is dead for anyone with no shells (see account.ts). Fix with the empty state.