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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10545-stop-a-shell-or-stop-all-idle-ones-from → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/10545-stop-a-shell-or-stop-all-idle-ones-from was merged into main as 14e55dc.

Review

Sign in to review this change.
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.
66
77 Try it:
88 - 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.
1010 - MCP: `sessions` with `kind: "shells"` returns the same rows.
1111
1212 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
1414 settings page's first-view budget has no word to spare).

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.

1515
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 @@
11 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";
33 import { good, bad, type Tool } from "../shared.ts";
44
55 export const tool: Tool = {
66 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",
88 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
1212 username: { type: "string", maxLength: 64 },
13 action: { type: "string", enum: ["list", "end"] },
13 action: { type: "string", enum: ["list", "end", "end_idle"] },
1414 id: { type: "string", maxLength: 64 },
1515 kind: { type: "string", enum: ["signins", "shells"] },
1616 },
@@ -24,8 +24,14 @@ export const tool: Tool = {
2424 const kind = args["kind"] ?? "signins";
2525 if (kind !== "signins" && kind !== "shells") return bad("bad_kind", "kind is signins or shells");
2626 if (kind === "shells") {
27 if (action !== "list") return bad("bad_action", "shells are listed, not ended, here");
2827 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");
2935 const shells = myShells(ctx.app, user.id);
3036 return good({ shells, says: `${shells.length} shell${shells.length === 1 ? "" : "s"}.` });
3137 }
src/http/settings_routes.ts +4
@@ -89,6 +89,8 @@ import {
8989 import {
9090 accountPage,
9191 shellsPage,
92 shellStop,
93 shellsStopIdle,
9294 notifyUpdate,
9395 edgeLink,
9496 edgeUnlink,
@@ -148,6 +150,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
148150 router.add("POST", "/settings/everyday/switch", everydaySwitch);
149151 router.add("POST", "/settings/everyday/keep", everydayKeep);
150152 router.add("GET", "/settings/shells", shellsPage);
153 router.add("POST", "/settings/shells/stop-idle", shellsStopIdle);
154 router.add("POST", "/settings/shells/:id/stop", shellStop);
151155 router.add("GET", "/settings/tokens", tokensPage);
152156 router.add("POST", "/settings/tokens", tokenCreate);
153157 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
631631 return [...liveShells(app, userId), ...gone];
632632 }
633633
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
634661 /** End every session (the forge is stopping). */
635662 export function stopShells(app: App): void {
636663 for (const session of [...sessionsOf(app).values()]) end(app, session, "stopped");
src/tui/screens/shells.ts +32 −6
@@ -1,10 +1,12 @@
11 /**
22 * Your shells in the terminal (#10544): the machine shells you hold, live
33 * 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.
57 */
68
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";
810 import type { Ctx, Screen } from "../app/app.ts";
911 import { binding } from "../app/keys.ts";
1012 import { style } from "../term/style.ts";
@@ -21,10 +23,12 @@ export function shellLine(s: ShellRow): string {
2123 }
2224
2325 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;
2529 return {
2630 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")],
2832 render(area, s) {
2933 const c = ctx.theme.c;
3034 if (rows.length === 0) {
@@ -32,13 +36,35 @@ export function shellsScreen(f: Session, ctx: Ctx): Screen {
3236 return;
3337 }
3438 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);
3640 });
3741 },
3842 key(e) {
3943 if (e.ctrl || e.alt) return false;
4044 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();
4268 return true;
4369 }
4470 if (e.key === "esc" || e.key === "q") {
src/web/pages/account.ts +26 −3
@@ -56,6 +56,7 @@ import {
5656 signInFirst,
5757 type SettingsSection,
5858 forbiddenPage,
59 notFoundPage,
5960 } from "../chrome.ts";
6061 import { accountSections } from "./origins.ts";
6162 import { field, readForm } from "../form.ts";
@@ -74,7 +75,7 @@ import {
7475 field as uiField,
7576 icon,
7677 } from "../ui.ts";
77 import { myShells } from "../../machines/shell.ts";
78 import { STOPPED_ONE, myShells, stopIdleShells, stopShell, stoppedIdleSays } from "../../machines/shell.ts";
7879 import { everydayLine } from "./everyday.ts";
7980 import { passkeyJs } from "../passkey-client.ts";
8081
@@ -401,13 +402,17 @@ const reading = (n: number | null, unit: string): string => (n === null ? "–"
401402 export function shellsView(ctx: Ctx, user: User): Html {
402403 const shells = myShells(ctx.app, user.id);
403404 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">
405409 ${shells.map((s) =>
406410 s.endedAt === null
407411 ? row({
408412 title: html`${s.repository} · ${s.machine}`,
409413 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>`,
411416 facts: [
412417 { label: "Last input", value: s.lastInputAt ? time(s.lastInputAt) : "–" },
413418 { label: "Last output", value: s.lastOutputAt ? time(s.lastOutputAt) : "–" },
@@ -434,6 +439,24 @@ export function shellsPage(ctx: Ctx): Response {
434439 return timePhase(ctx.request, "render", () => accountShell(ctx, "shells", "Shells", body));
435440 }
436441
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
437460 // GET /settings
438461 export function accountPage(ctx: Ctx): Response {
439462 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";
1212 import { createSession } from "../src/core/sessions.ts";
1313 import { createUser } from "../src/core/users.ts";
1414 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";
1619 import { tokenFor } from "./helpers/tokens.ts";
1720 import { quietLogs } from "./helpers/globals.ts";
1821
@@ -115,3 +118,149 @@ test("a live shell is read with its real times; ended ones follow, escaped, on t
115118

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.

116119 ws.close();
117120 });
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
6262 expect(theirs).toContain("You have opened no shell on a machine yet.");
6363 expect(theirs).not.toContain("alice/lab");
6464 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs