Public: anyone can read this; the crew can change it.
Stop a shell, or stop all idle ones, from your account and through MCP #10630
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10545-stop-a-shell-or-stop-all-idle-ones-from → main 3 commits fork 2 threads · 2 unresolved
8 files
changed +280
−17
docs/features/my-shells.md +8 −3
| @@ -6,11 +6,16 @@ own account only; another person's shells are never listed. | ||
| 6 | 6 | |
| 7 | 7 | Try it: |
| 8 | 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. | |
| 9 | - Terminal: open the palette (`/`) and choose **Your shells**; `j`/`k` pick a line, `s` stops the picked live shell, `i` stops all idle ones, `r` reads again, `esc` closes. | |
| 10 | 10 | - MCP: `sessions` with `kind: "shells"` returns the same rows. |
| 11 | 11 | |
| 12 | 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 | |
| 13 | `shellsPage` in `src/web/pages/account.ts`. Left out: a Settings sidebar row (the | |
| 14 | 14 | settings page's first-view budget has no word to spare). |
| 15 | 15 | |
| 16 | Pinned by `test/my-shells.test.ts`. | |
| 16 | Stopping (#10545), on all three doors with the same receipts: on the page each live line has **Stop**, and **Stop all idle** stops every shell of yours that nobody is | |
| 17 | attached to and that is past half the idle limit. Both are same-origin form posts (`/settings/shells/:id/stop`, | |
| 18 | `/settings/shells/stop-idle`); another person's id is a 404; each answers on the page with a one-line receipt and | |
| 19 | records `end_reason = 'stopped'`. MCP: `sessions` with `kind: "shells"` and `action: "end"` + `id`, or `"end_idle"`. Terminal: `s` and `i` as above, answering in a toast. | |
| 20 | ||
| 21 | Pinned by `test/my-shells.test.ts` and `test/tui-my-shells.test.ts`. |
src/http/mcp/tools/sessions.ts +10 −4
| @@ -1,16 +1,16 @@ | ||
| 1 | 1 | import { accountSignIns, endAccountSession } from "../../../actions/signins.ts"; |
| 2 | import { myShells } from "../../../machines/shell.ts"; | |
| 2 | import { NO_SUCH_SHELL, STOPPED_ONE, myShells, stopIdleShells, stopShell, stoppedIdleSays } from "../../../machines/shell.ts"; | |
| 3 | 3 | import { good, bad, type Tool } from "../shared.ts"; |
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "sessions", |
| 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 | 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, `action: \"end\"` with an `id` stops one and `\"end_idle\"` stops those detached and past half the idle limit; an admin-scope token, the route's refusals", | |
| 8 | 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.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
| 12 | 12 | username: { type: "string", maxLength: 64 }, |
| 13 | action: { type: "string", enum: ["list", "end"] }, | |
| 13 | action: { type: "string", enum: ["list", "end", "end_idle"] }, | |
| 14 | 14 | id: { type: "string", maxLength: 64 }, |
| 15 | 15 | kind: { type: "string", enum: ["signins", "shells"] }, |
| 16 | 16 | }, |
| @@ -24,8 +24,14 @@ export const tool: Tool = { | ||
| 24 | 24 | const kind = args["kind"] ?? "signins"; |
| 25 | 25 | if (kind !== "signins" && kind !== "shells") return bad("bad_kind", "kind is signins or shells"); |
| 26 | 26 | if (kind === "shells") { |
| 27 | if (action !== "list") return bad("bad_action", "shells are listed, not ended, here"); | |
| 28 | 27 | if (name !== user.username) return bad("not_found", "shells are your own"); |
| 28 | if (action === "end") { | |
| 29 | const id = args["id"]; | |
| 30 | if (typeof id !== "string" || id === "" || id.length > 64) return bad("bad_id", "id is the shell's id"); | |
| 31 | return stopShell(ctx.app, user.id, id) ? good({ says: STOPPED_ONE }) : bad("not_found", NO_SUCH_SHELL); | |
| 32 | } | |
| 33 | if (action === "end_idle") return good({ says: stoppedIdleSays(stopIdleShells(ctx.app, user.id)) }); | |
| 34 | if (action !== "list") return bad("bad_action", "action is list, end or end_idle"); | |
| 29 | 35 | const shells = myShells(ctx.app, user.id); |
| 30 | 36 | return good({ shells, says: `${shells.length} shell${shells.length === 1 ? "" : "s"}.` }); |
| 31 | 37 | } |
src/http/settings_routes.ts +4
| @@ -89,6 +89,8 @@ import { | ||
| 89 | 89 | import { |
| 90 | 90 | accountPage, |
| 91 | 91 | shellsPage, |
| 92 | shellStop, | |
| 93 | shellsStopIdle, | |
| 92 | 94 | notifyUpdate, |
| 93 | 95 | edgeLink, |
| 94 | 96 | edgeUnlink, |
| @@ -148,6 +150,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 148 | 150 | router.add("POST", "/settings/everyday/switch", everydaySwitch); |
| 149 | 151 | router.add("POST", "/settings/everyday/keep", everydayKeep); |
| 150 | 152 | router.add("GET", "/settings/shells", shellsPage); |
| 153 | router.add("POST", "/settings/shells/stop-idle", shellsStopIdle); | |
| 154 | router.add("POST", "/settings/shells/:id/stop", shellStop); | |
| 151 | 155 | router.add("GET", "/settings/tokens", tokensPage); |
| 152 | 156 | router.add("POST", "/settings/tokens", tokenCreate); |
| 153 | 157 | router.add("POST", "/settings/tokens/:id/delete", tokenDelete); |
src/machines/shell.ts +27
| @@ -631,6 +631,33 @@ export function myShells(app: App, userId: string, ended = RECENT_ENDED): ShellR | ||
| 631 | 631 | return [...liveShells(app, userId), ...gone]; |
| 632 | 632 | } |
| 633 | 633 | |
| 634 | /** Stop one shell a person holds, by its row id: false when they hold no such live shell (another person's is the same answer). */ | |
| 635 | export function stopShell(app: App, userId: string, id: string): boolean { | |
| 636 | for (const s of sessionsOf(app).values()) { | |
| 637 | if (s.userId !== userId || s.row !== id) continue; | |
| 638 | end(app, s, "stopped"); | |
| 639 | return true; | |
| 640 | } | |
| 641 | return false; | |
| 642 | } | |
| 643 | ||
| 644 | /** Stop every shell of a person's that is detached and past half its idle limit; returns how many. */ | |
| 645 | export function stopIdleShells(app: App, userId: string, idleMs = shellIdleMs(app.db)): number { | |
| 646 | let n = 0; | |
| 647 | for (const s of [...sessionsOf(app).values()]) { | |
| 648 | if (s.userId !== userId || s.peers.size > 0 || !idle(s, idleMs / 2)) continue; | |
| 649 | end(app, s, "stopped"); | |
| 650 | n++; | |
| 651 | } | |
| 652 | return n; | |
| 653 | } | |
| 654 | ||
| 655 | /** The receipts, one line each; the page and the `sessions` tool say them in the same words. */ | |
| 656 | export const STOPPED_ONE = "Shell stopped."; | |
| 657 | export const NO_SUCH_SHELL = "No such shell."; | |
| 658 | export const stoppedIdleSays = (n: number): string => | |
| 659 | n === 0 ? "No idle shell to stop." : `Stopped ${n} idle shell${n === 1 ? "" : "s"}.`; | |
| 660 | ||
| 634 | 661 | /** End every session (the forge is stopping). */ |
| 635 | 662 | export function stopShells(app: App): void { |
| 636 | 663 | for (const session of [...sessionsOf(app).values()]) end(app, session, "stopped"); |
src/tui/screens/shells.ts +32 −6
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Your shells in the terminal (#10544): the machine shells you hold, live |
| 3 | 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. | |
| 4 | * same the account page and MCP `sessions` (kind: shells) print. `j`/`k` | |
| 5 | * pick a line, `s` stops the picked live shell, `i` stops every idle one | |
| 6 | * (#10545), with the same receipts as the page and MCP. | |
| 5 | 7 | */ |
| 6 | 8 | |
| 7 | import { myShells, type ShellRow } from "../../machines/shell.ts"; | |
| 9 | import { NO_SUCH_SHELL, STOPPED_ONE, myShells, stopIdleShells, stopShell, stoppedIdleSays, type ShellRow } from "../../machines/shell.ts"; | |
| 8 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| 9 | 11 | import { binding } from "../app/keys.ts"; |
| 10 | 12 | import { style } from "../term/style.ts"; |
| @@ -21,10 +23,12 @@ export function shellLine(s: ShellRow): string { | ||
| 21 | 23 | } |
| 22 | 24 | |
| 23 | 25 | export function shellsScreen(f: Session, ctx: Ctx): Screen { |
| 24 | let rows: ShellRow[] = f.user === undefined ? [] : myShells(f.app, f.user.id); | |
| 26 | const read = (): ShellRow[] => (f.user === undefined ? [] : myShells(f.app, f.user.id)); | |
| 27 | let rows = read(); | |
| 28 | let sel = 0; | |
| 25 | 29 | return { |
| 26 | 30 | title: "Your shells", |
| 27 | keys: [binding(["r"], "read again"), binding(["esc"], "close")], | |
| 31 | keys: [binding(["j", "k"], "pick"), binding(["s"], "stop"), binding(["i"], "stop all idle"), binding(["r"], "read again"), binding(["esc"], "close")], | |
| 28 | 32 | render(area, s) { |
| 29 | 33 | const c = ctx.theme.c; |
| 30 | 34 | if (rows.length === 0) { |
| @@ -32,13 +36,35 @@ export function shellsScreen(f: Session, ctx: Ctx): Screen { | ||
| 32 | 36 | return; |
| 33 | 37 | } |
| 34 | 38 | 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); | |
| 39 | if (i < area.h) s.text(area.x + 1, area.y + i, truncate(shellLine(row), area.w - 2), style(i === sel ? c.accent : row.endedAt === null ? c.fg : c.mutedFg, c.bg), area.x + area.w); | |
| 36 | 40 | }); |
| 37 | 41 | }, |
| 38 | 42 | key(e) { |
| 39 | 43 | if (e.ctrl || e.alt) return false; |
| 40 | 44 | if (e.key === "r") { |
| 41 | rows = f.user === undefined ? [] : myShells(f.app, f.user.id); | |
| 45 | rows = read(); | |
| 46 | sel = Math.min(sel, Math.max(0, rows.length - 1)); | |
| 47 | return true; | |
| 48 | } | |
| 49 | if (e.key === "j" || e.key === "down") { | |
| 50 | sel = Math.min(sel + 1, Math.max(0, rows.length - 1)); | |
| 51 | return true; | |
| 52 | } | |
| 53 | if (e.key === "k" || e.key === "up") { | |
| 54 | sel = Math.max(sel - 1, 0); | |
| 55 | return true; | |
| 56 | } | |
| 57 | if (e.key === "s" && f.user !== undefined) { | |
| 58 | const row = rows[sel]; | |
| 59 | if (row === undefined || row.endedAt !== null) ctx.toast(NO_SUCH_SHELL, "danger"); | |
| 60 | else if (stopShell(f.app, f.user.id, row.id)) ctx.toast(STOPPED_ONE); | |
| 61 | else ctx.toast(NO_SUCH_SHELL, "danger"); | |
| 62 | rows = read(); | |
| 63 | return true; | |
| 64 | } | |
| 65 | if (e.key === "i" && f.user !== undefined) { | |
| 66 | ctx.toast(stoppedIdleSays(stopIdleShells(f.app, f.user.id))); | |
| 67 | rows = read(); | |
| 42 | 68 | return true; |
| 43 | 69 | } |
| 44 | 70 | if (e.key === "esc" || e.key === "q") { |
src/web/pages/account.ts +26 −3
| @@ -56,6 +56,7 @@ import { | ||
| 56 | 56 | signInFirst, |
| 57 | 57 | type SettingsSection, |
| 58 | 58 | forbiddenPage, |
| 59 | notFoundPage, | |
| 59 | 60 | } from "../chrome.ts"; |
| 60 | 61 | import { accountSections } from "./origins.ts"; |
| 61 | 62 | import { field, readForm } from "../form.ts"; |
| @@ -74,7 +75,7 @@ import { | ||
| 74 | 75 | field as uiField, |
| 75 | 76 | icon, |
| 76 | 77 | } from "../ui.ts"; |
| 77 | import { myShells } from "../../machines/shell.ts"; | |
| 78 | import { STOPPED_ONE, myShells, stopIdleShells, stopShell, stoppedIdleSays } from "../../machines/shell.ts"; | |
| 78 | 79 | import { everydayLine } from "./everyday.ts"; |
| 79 | 80 | import { passkeyJs } from "../passkey-client.ts"; |
| 80 | 81 | |
| @@ -401,13 +402,17 @@ const reading = (n: number | null, unit: string): string => (n === null ? "–" | ||
| 401 | 402 | export function shellsView(ctx: Ctx, user: User): Html { |
| 402 | 403 | const shells = myShells(ctx.app, user.id); |
| 403 | 404 | 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"> | |
| 405 | const anyLive = shells.some((s) => s.endedAt === null); | |
| 406 | return html`${anyLive | |
| 407 | ? html`<form method="post" action="/settings/shells/stop-idle">${btn("Stop all idle", { type: "submit" })}</form>` | |
| 408 | : null}<ul class="list setting-list" id="shells"> | |
| 405 | 409 | ${shells.map((s) => |
| 406 | 410 | s.endedAt === null |
| 407 | 411 | ? row({ |
| 408 | 412 | title: html`${s.repository} · ${s.machine}`, |
| 409 | 413 | meta: html`Started ${time(s.startedAt)}`, |
| 410 | trail: badge("running", { tone: "success" }), | |
| 414 | trail: html`${badge("running", { tone: "success" })} | |
| 415 | <form method="post" action="/settings/shells/${s.id}/stop">${btn("Stop", { type: "submit", size: "sm", label: `Stop ${s.repository} · ${s.machine}` })}</form>`, | |
| 411 | 416 | facts: [ |
| 412 | 417 | { label: "Last input", value: s.lastInputAt ? time(s.lastInputAt) : "–" }, |
| 413 | 418 | { label: "Last output", value: s.lastOutputAt ? time(s.lastOutputAt) : "–" }, |
| @@ -434,6 +439,24 @@ export function shellsPage(ctx: Ctx): Response { | ||
| 434 | 439 | return timePhase(ctx.request, "render", () => accountShell(ctx, "shells", "Shells", body)); |
| 435 | 440 | } |
| 436 | 441 | |
| 442 | // POST /settings/shells/:id/stop | |
| 443 | export async function shellStop(ctx: Ctx): Promise<Response> { | |
| 444 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 445 | const form = await readForm(ctx, "admin"); | |
| 446 | if (form instanceof Response) return form; | |
| 447 | const id = ctx.params.id ?? ""; | |
| 448 | if (!stopShell(ctx.app, ctx.auth.user.id, id)) return notFoundPage(ctx); | |
| 449 | return flashTo(ctx, "/settings/shells", { message: STOPPED_ONE }); | |
| 450 | } | |
| 451 | ||
| 452 | // POST /settings/shells/stop-idle | |
| 453 | export async function shellsStopIdle(ctx: Ctx): Promise<Response> { | |
| 454 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 455 | const form = await readForm(ctx, "admin"); | |
| 456 | if (form instanceof Response) return form; | |
| 457 | return flashTo(ctx, "/settings/shells", { message: stoppedIdleSays(stopIdleShells(ctx.app, ctx.auth.user.id)) }); | |
| 458 | } | |
| 459 | ||
| 437 | 460 | // GET /settings |
| 438 | 461 | export function accountPage(ctx: Ctx): Response { |
| 439 | 462 | if (!ctx.auth.user) return signInFirst(ctx); |
test/my-shells.test.ts +150 −1
| @@ -12,7 +12,10 @@ import { createRepo, type Repo } from "../src/core/repos.ts"; | ||
| 12 | 12 | import { createSession } from "../src/core/sessions.ts"; |
| 13 | 13 | import { createUser } from "../src/core/users.ts"; |
| 14 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 15 | import { liveShells, myShells, recordEnd, recordStart, setShellOptions, stopShells } from "../src/machines/shell.ts"; | |
| 15 | import { liveShells, myShells, recordEnd, recordStart, setShellOptions, stopIdleShells, stopShells } from "../src/machines/shell.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 16 | 19 | import { tokenFor } from "./helpers/tokens.ts"; |
| 17 | 20 | import { quietLogs } from "./helpers/globals.ts"; |
| 18 | 21 | |
| @@ -115,3 +118,149 @@ test("a live shell is read with its real times; ended ones follow, escaped, on t | ||
| 115 | 118 | |
Missing: a test that POSTs /settings/shells/stop-idle (303, receipt, 403 cross-origin, a peer-attached shell left alone), and an MCP test where a stranger's token gets not_found for your shell id. |
||
| 116 | 119 | ws.close(); |
| 117 | 120 | }); |
| 121 | ||
| 122 | // ---- stopping (#10545) ---- | |
| 123 | ||
| 124 | const openShell = async (c: string) => { | |
| 125 | const ws = new WebSocket(`${servers.url.replace(/^http/, "ws")}/alice/lab/machines/standard/shell`, { | |
| 126 | headers: { cookie: c, origin: new URL(servers.url).origin }, | |
| 127 | } as unknown as string[]); | |
| 128 | await new Promise((ok, no) => { | |
| 129 | ws.addEventListener("open", ok); | |
| 130 | ws.addEventListener("error", () => no(new Error("refused"))); | |
| 131 | }); | |
| 132 | return ws; | |
| 133 | }; | |
| 134 | const post = (path: string, c: string, origin = new URL(servers.url).origin) => | |
| 135 | fetch(`${servers.url}${path}`, { method: "POST", redirect: "manual", headers: { cookie: c, origin }, body: new URLSearchParams() }); | |
| 136 | const mcp = async (args: object): Promise<any> => { | |
| 137 | const res = await fetch(`${servers.url}/mcp`, { | |
| 138 | method: "POST", | |
| 139 | headers: { | |
| 140 | "content-type": "application/json", | |
| 141 | accept: "application/json, text/event-stream", | |
| 142 | authorization: `Bearer ${tokenFor(app, "alice", "admin")}`, | |
| 143 | }, | |
| 144 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sessions", arguments: args } }), | |
| 145 | }); | |
| 146 | const body = (await res.json()) as any; | |
| 147 | return body.result; | |
| 148 | }; | |
| 149 | ||
| 150 | test("stop ends the session and records 'stopped'; a stranger's id is refused; cross-origin is refused", async () => { | |
| 151 | const ws = await openShell(cookie); | |
| 152 | await Bun.sleep(100); | |
| 153 | const id = liveShells(app, aliceId)[0]!.id; | |
| 154 | expect(await page()).toContain(`/settings/shells/${id}/stop`); | |
| 155 | ||
| 156 | const bob = await createUser(app.db, { username: "bob", email: "[email protected]", password: "correct horse battery", origin: "seed", trusted: true }); | |
| 157 | if (!bob.ok) throw new Error("bob"); | |
| 158 | const bobCookie = `forge_session=${createSession(app.db, bob.value.id).token}`; | |
| 159 | expect((await post(`/settings/shells/${id}/stop`, bobCookie)).status).toBe(404); | |
| 160 | expect(liveShells(app, aliceId)).toHaveLength(1); | |
| 161 | expect((await post(`/settings/shells/${id}/stop`, cookie, "https://evil.test")).status).toBe(403); | |
| 162 | expect(liveShells(app, aliceId)).toHaveLength(1); | |
| 163 | ||
| 164 | const res = await post(`/settings/shells/${id}/stop`, cookie); | |
| 165 | expect(res.status).toBe(303); | |
| 166 | expect(res.headers.get("location")).toContain("/settings/shells"); | |
| 167 | expect(liveShells(app, aliceId)).toHaveLength(0); | |
| 168 | expect(myShells(app, aliceId).find((r) => r.id === id)?.endReason).toBe("stopped"); | |
| 169 | expect((await post(`/settings/shells/${id}/stop`, cookie)).status).toBe(404); | |
| 170 | ws.close(); | |
| 171 | }); | |
| 172 | ||
| 173 | test("stop all idle leaves a shell with a peer attached alone; MCP says the same words", async () => { | |
| 174 | const ws = await openShell(cookie); | |
| 175 | await Bun.sleep(100); | |
| 176 | // Limit 60s, half is 30s: nothing is that idle yet, and a peer is attached anyway. | |
| 177 | expect(stopIdleShells(app, aliceId, 60_000)).toBe(0); | |
| 178 | expect(stopIdleShells(app, aliceId, 0)).toBe(0); | |
| 179 | expect(liveShells(app, aliceId)).toHaveLength(1); | |
| 180 | ||
| 181 | ws.close(); | |
| 182 | await Bun.sleep(200); | |
| 183 | expect(liveShells(app, aliceId)[0]!.peers).toBe(0); | |
| 184 | expect(stopIdleShells(app, aliceId, 60_000)).toBe(0); | |
| 185 | expect(stopIdleShells(app, aliceId, 0)).toBe(1); | |
| 186 | expect(liveShells(app, aliceId)).toHaveLength(0); | |
| 187 | ||
| 188 | const again = await openShell(cookie); | |
| 189 | await Bun.sleep(100); | |
| 190 | const id = liveShells(app, aliceId)[0]!.id; | |
| 191 | const r = await mcp({ kind: "shells", action: "end", id }); | |
| 192 | expect(JSON.parse(r.content[0].text).says).toBe("Shell stopped."); | |
| 193 | const gone = await mcp({ kind: "shells", action: "end", id }); | |
| 194 | expect(gone.isError).toBe(true); | |
| 195 | const idle = await mcp({ kind: "shells", action: "end_idle" }); | |
| 196 | expect(JSON.parse(idle.content[0].text).says).toBe("No idle shell to stop."); | |
| 197 | again.close(); | |
| 198 | }); | |
| 199 | ||
| 200 | test("POST /settings/shells/stop-idle: 303 with a receipt, 403 cross-origin, a peer-attached shell left alone; the button shows with a live shell", async () => { | |
| 201 | const ws = await openShell(cookie); | |
| 202 | await Bun.sleep(100); | |
| 203 | expect(await page()).toContain('action="/settings/shells/stop-idle"'); | |
| 204 | expect(await page()).toContain("Stop all idle"); | |
| 205 | ||
| 206 | expect((await post("/settings/shells/stop-idle", cookie, "https://evil.test")).status).toBe(403); | |
| 207 | // A peer is attached: the post succeeds and stops nothing. | |
| 208 | const none = await post("/settings/shells/stop-idle", cookie); | |
| 209 | expect(none.status).toBe(303); | |
| 210 | expect(none.headers.get("location")).toContain("/settings/shells"); | |
| 211 | expect(liveShells(app, aliceId)).toHaveLength(1); | |
| 212 | ||
| 213 | // The receipt rides the flash cookie to the page. | |
| 214 | const flash = (none.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 215 | const follow = await fetch(`${servers.url}/settings/shells`, { headers: { cookie: `${cookie}; ${flash}` } }); | |
| 216 | expect(await follow.text()).toContain("No idle shell to stop."); | |
| 217 | ws.close(); | |
| 218 | await Bun.sleep(200); | |
| 219 | }); | |
| 220 | ||
| 221 | test("MCP: another person cannot end your shell id", async () => { | |
| 222 | const ws = await openShell(cookie); | |
| 223 | await Bun.sleep(100); | |
| 224 | const id = liveShells(app, aliceId)[0]!.id; | |
| 225 | const res = await fetch(`${servers.url}/mcp`, { | |
| 226 | method: "POST", | |
| 227 | headers: { | |
| 228 | "content-type": "application/json", | |
| 229 | accept: "application/json, text/event-stream", | |
| 230 | authorization: `Bearer ${tokenFor(app, "bob", "admin")}`, | |
| 231 | }, | |
| 232 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sessions", arguments: { kind: "shells", action: "end", id } } }), | |
| 233 | }); | |
| 234 | const out = ((await res.json()) as any).result; | |
| 235 | expect(out.isError).toBe(true); | |
| 236 | expect(out.content[0].text).toContain("not_found"); | |
| 237 | expect(liveShells(app, aliceId)).toHaveLength(1); | |
| 238 | ws.close(); | |
| 239 | }); | |
| 240 | ||
| 241 | test("terminal: s stops the picked live shell with the page's receipt", async () => { | |
| 242 | const ws = await openShell(cookie); | |
| 243 | await Bun.sleep(100); | |
| 244 | const { getUserByName } = await import("../src/core/users.ts"); | |
| 245 | const { app: tui } = createForgeApp( | |
| 246 | app, | |
| 247 | { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, "alice")! }, | |
| 248 | { terminal: headlessTerminal(140, 32), exitHooks: false }, | |
| 249 | ); | |
| 250 | tui.start(); | |
| 251 | const key = async (k: string, ctrl = false) => { | |
| 252 | tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 253 | await settleApp(tui); | |
| 254 | }; | |
| 255 | await key("k", true); | |
| 256 | for (const ch of "Your shells") await key(ch); | |
| 257 | await key("enter"); | |
| 258 | await key("i"); | |
| 259 | expect(tui.screen.toText()).toContain("No idle shell to stop."); | |
| 260 | expect(liveShells(app, aliceId)).toHaveLength(1); | |
| 261 | await key("s"); | |
| 262 | expect(tui.screen.toText()).toContain("Shell stopped."); | |
| 263 | expect(liveShells(app, aliceId)).toHaveLength(0); | |
| 264 | tui.stop(); | |
| 265 | ws.close(); | |
| 266 | }); |
test/tui-my-shells.test.ts +23
| @@ -62,3 +62,26 @@ test("the owner sees the ended shell and why; another person is told they have n | ||
| 62 | 62 | expect(theirs).toContain("You have opened no shell on a machine yet."); |
| 63 | 63 | expect(theirs).not.toContain("alice/lab"); |
| 64 | 64 | }); |
| 65 | ||
| 66 | test("s stops the picked live shell and i stops idle ones, with the page's receipts", async () => { | |
| 67 | const alice = getUserByName(app.db, "alice")!; | |
| 68 | const { app: tui } = createForgeApp( | |
| 69 | app, | |
| 70 | { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice }, | |
| 71 | { terminal: headlessTerminal(140, 32), exitHooks: false }, | |
| 72 | ); | |
| 73 | tui.start(); | |
| 74 | const key = async (k: string, ctrl = false) => { | |
| 75 | tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 76 | await settleApp(tui); | |
| 77 | }; | |
| 78 | await key("k", true); | |
| 79 | for (const ch of "Your shells") await key(ch); | |
| 80 | await key("enter"); | |
| 81 | // The first line is an ended shell: stop refuses it in the page's words. | |
| 82 | await key("s"); | |
| 83 | expect(tui.screen.toText()).toContain("No such shell."); | |
| 84 | await key("i"); | |
| 85 | expect(tui.screen.toText()).toContain("No idle shell to stop."); | |
| 86 | tui.stop(); | |
| 87 | }); |
Both doors: this documents the page and MCP only. The terminal screen needs Stop and Stop all idle too, and this how-to should say how to reach them there.