diff --git a/CHANGELOG.md b/CHANGELOG.md index c4428cf..e110c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve ### Added +- `shell --name ` labels a session as it starts. The name + appears on the start card, in `shell list`, and in the web app, and is kept + when a persistent session restarts without one. +- `shell ls` lists the sessions in your account from every linked machine, + with name, status, uptime, and machine. Ended sessions are counted and + hidden unless `--all` is given; `--json` prints the full records without + passwords. +- Sessions can be renamed from their page in the web app with the pencil next + to the name, by the session's owner, its assignees, or a team admin. A blank + name falls back to the command. - A Download my data action on Account exports the signed-in account, membership, linked machines, owned or assigned sessions, and encrypted vault record as a local JSON file. @@ -70,6 +80,20 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve ### Fixed +- Every member of a team can now read the audit log. A member who opened the + copy of the team's audit key a teammate sealed for them re-sealed it to + themselves by deleting it and adding it back, which the service refuses, + because only a member holding a copy may store one. The copy is now replaced + in one step, so it is no longer lost on the next check and the key reaches + everyone, automatically, with nothing to paste. +- The vault on the Account page lists teammates still waiting for the audit + key, and asks before sealing it to a teammate whose vault key has changed. +- The audit log says when a locked vault, or no vault at all, is what stands + between the reader and the log, and offers to unlock it there. +- The session terminal is drawn directly on the app background, with no + bordered panel, and its colors follow the light and dark themes. The + renderer choice is a tab hanging from the tab line over the terminal's + corner instead of a control inside the tab strip. - Widened the vault password fields on the vault setup, unlock, and Account pages to twice their previous width, so a password is no longer typed into a box sized for the four-letter recovery-key confirmation. diff --git a/README.md b/README.md index 4d37b83..a85c072 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ installer, and test caveats. shell # share a command shell # share a new shell shell --read-only # disable browser input +shell --name "web app" # label it in lists and the web app shell --foreground # also show it locally shell --auto-close 5m # set an earlier deadline shell --persistent # reuse a URL and password @@ -75,6 +76,7 @@ shell --files # opt in working-directory files shell --files-root # opt in a different file root shell list # list local sessions (adapts to terminal width) +shell ls # list your account's sessions on every machine shell password # retrieve an active password locally shell password rotate # revoke it without restarting the process shell attach # attach locally diff --git a/app/server/app.test.ts b/app/server/app.test.ts index f4e76b8..beaced1 100644 --- a/app/server/app.test.ts +++ b/app/server/app.test.ts @@ -336,6 +336,77 @@ describe("session registry", () => { }); }); +describe("GET /api/cli/sessions", () => { + const mine = { + id: "qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t", + share_url: "https://shell.online/s/qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t#salt=AAAAAAAAAAAAAAAAAAAAAA", + command: "npm run dev", + name: "web app", + host: "ana-mbp", + encrypted: true, + }; + + it("lists every session this account published, with its name", async () => { + const tokens = await login(); + await call("POST", "/api/sessions", { auth: tokens.access_token, body: mine }); + await call("POST", "/api/sessions", { + auth: tokens.access_token, + body: { ...mine, id: "Zm9vYmFyYmF6cXV4cXV1eDEyMzQ1Njc4", name: undefined, command: "htop" }, + }); + await call("PATCH", `/api/sessions/Zm9vYmFyYmF6cXV4cXV1eDEyMzQ1Njc4`, { + auth: tokens.access_token, + body: { exit_code: 0 }, + }); + + const listed = await call("GET", "/api/cli/sessions", { auth: tokens.access_token }); + expect(listed.status).toBe(200); + const byId = Object.fromEntries( + (listed.body.sessions as { id: string; closedAt?: number }[]).map((entry) => [entry.id, entry]), + ); + expect(byId[mine.id]).toMatchObject({ name: "web app", command: "npm run dev", host: "ana-mbp" }); + expect(byId.Zm9vYmFyYmF6cXV4cXV1eDEyMzQ1Njc4).toMatchObject({ command: "htop", exitCode: 0 }); + expect(byId.Zm9vYmFyYmF6cXV4cXV1eDEyMzQ1Njc4.closedAt).toBeTypeOf("number"); + expect(JSON.stringify(listed.body)).not.toContain("keyShares"); + }); + + it("does not list another account's sessions", async () => { + const tokens = await login(); + await call("POST", "/api/sessions", { auth: tokens.access_token, body: mine }); + const other = await login({}, "uid-2"); + const listed = await call("GET", "/api/cli/sessions", { auth: other.access_token }); + expect(listed.body.sessions).toEqual([]); + }); + + it("hands back the newest sessions only, so a long-lived account still gets a reply", async () => { + const tokens = await login(); + const { orgId } = (await call("GET", "/api/team-key", { auth: await idToken() })).body.you; + for (let index = 0; index < 505; index += 1) { + await store.upsertSession({ + id: `s${String(index).padStart(30, "0")}`, + uid: "uid-1", + orgId, + ownerUid: "uid-1", + shareUrl: "https://shell.online/s/qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t", + command: "htop", + readOnly: false, + encrypted: true, + persistent: false, + host: "ana-mbp", + startedAt: 1000 + index, + }); + } + const listed = await call("GET", "/api/cli/sessions", { auth: tokens.access_token }); + expect(listed.body.sessions).toHaveLength(500); + /* Newest first, so the ones cut are the oldest. */ + expect(listed.body.sessions[0].startedAt).toBe(1504); + }); + + it("needs a machine token, not a browser sign-in", async () => { + expect((await call("GET", "/api/cli/sessions")).status).toBe(401); + expect((await call("GET", "/api/cli/sessions", { auth: await idToken() })).status).toBe(401); + }); +}); + describe("refresh", () => { it("issues a working access token from the refresh token", async () => { const tokens = await login(); @@ -831,6 +902,26 @@ describe("driving a machine from the browser", () => { }); }); +describe("a name chosen in the browser", () => { + it("is cleaned before it is queued for the machine", async () => { + const tokens = await login(); + const device = (await devices())[0]; + await call("GET", "/api/agent/commands", { auth: tokens.access_token }); + const queued = await call("POST", "/api/commands", { + auth: await idToken(), + body: { device_id: device.id, kind: "start", command: "claude", name: "deploy\nnow\u202Egnuf" }, + }); + /* 202 when the machine has not polled since it was queued; either accepts it. */ + expect([201, 202]).toContain(queued.status); + const claimed = await call("GET", "/api/agent/commands", { auth: tokens.access_token }); + /* + * The CLI turns this into an environment variable and refuses to start + * with a name it cannot print, so a browser must not be able to send one. + */ + expect(claimed.body.commands[0].name).toBe("deploy now gnuf"); + }); +}); + describe("starting on a machine that is not reachable", () => { async function deviceWithoutAgent() { await login(); @@ -1283,6 +1374,45 @@ describe("session ownership and handoff", () => { expect(colleagueView.body.session.sharedWith).toBeUndefined(); }); + it("lets the owner rename a session, and a blank name clear it", async () => { + await orgWithColleague(); + const renamed = await call("PUT", `/api/sessions/${session.id}/name`, { + auth: await idToken(), + body: { name: " nightly build " }, + }); + expect(renamed.status).toBe(200); + expect(renamed.body.session.name).toBe("nightly build"); + expect(renamed.body.session.keyShares).toBeUndefined(); + + const detail = await call("GET", `/api/sessions/${session.id}`, { auth: await idToken() }); + expect(detail.body.session.name).toBe("nightly build"); + + const cleared = await call("PUT", `/api/sessions/${session.id}/name`, { + auth: await idToken(), + body: { name: "" }, + }); + expect(cleared.status).toBe(200); + expect(cleared.body.session.name).toBeUndefined(); + }); + + it("lets an assignee rename a session, and not a colleague who is not one", async () => { + const { colleague } = await orgWithColleague(); + const path = `/api/sessions/${session.id}/name`; + expect((await call("PUT", path, { auth: colleague, body: { name: "mine" } })).status).toBe(403); + await call("PUT", `/api/sessions/${session.id}/assignee`, { auth: await idToken(), body: { uids: ["uid-2"] } }); + const renamed = await call("PUT", path, { auth: colleague, body: { name: "handed over" } }); + expect(renamed.status).toBe(200); + expect(renamed.body.session.name).toBe("handed over"); + }); + + it("keeps a name given in the browser when the machine re-registers", async () => { + const { tokens } = await orgWithColleague(); + await call("PUT", `/api/sessions/${session.id}/name`, { auth: await idToken(), body: { name: "renamed" } }); + await call("POST", "/api/sessions", { auth: tokens.access_token, body: session }); + const detail = await call("GET", `/api/sessions/${session.id}`, { auth: await idToken() }); + expect(detail.body.session.name).toBe("renamed"); + }); + it("allows a session to be left unassigned", async () => { await orgWithColleague(); const handed = await call("PUT", `/api/sessions/${session.id}/assignee`, { @@ -2448,6 +2578,35 @@ describe("team audit key", () => { expect(stale.status).toBe(409); }); + /* + * A member re-seals the copy a teammate sent them to themselves. Deleting it + * first and adding it back is refused, since by then they hold nothing, and + * that is how every member but the key's maker kept losing their copy. + */ + it("lets a member replace only their own copy, and only while they hold one", async () => { + const colleague = await withColleague(); + await makeKey([{ uid: "uid-1", sealed: sealedCopy() }, { uid: "uid-2", sealed: sealedCopy() }]); + const mine = sealedCopy(); + const replaced = await call("PUT", "/api/team-key/share", { auth: colleague, body: { version: 1, sealed: mine } }); + expect(replaced.status).toBe(200); + const view = (await call("GET", "/api/team-key", { auth: colleague })).body.share; + expect(view).toEqual({ senderUid: "uid-2", sealed: mine, version: 1 }); + /* The owner's copy is untouched. */ + expect((await call("GET", "/api/team-key", { auth: await idToken() })).body.share.senderUid).toBe("uid-1"); + + await call("DELETE", "/api/team-key/share", { auth: colleague }); + const afterDelete = await call("PUT", "/api/team-key/share", { auth: colleague, body: { version: 1, sealed: sealedCopy() } }); + expect(afterDelete.status).toBe(403); + expect((await call("GET", "/api/team-key", { auth: colleague })).body.share).toBeNull(); + }); + + it("refuses a replacement for a stale version or in the wrong shape", async () => { + const colleague = await withColleague(); + await makeKey([{ uid: "uid-1", sealed: sealedCopy() }, { uid: "uid-2", sealed: sealedCopy() }]); + expect((await call("PUT", "/api/team-key/share", { auth: colleague, body: { version: 2, sealed: sealedCopy() } })).status).toBe(409); + expect((await call("PUT", "/api/team-key/share", { auth: colleague, body: { version: 1, sealed: "plain" } })).status).toBe(400); + }); + it("lets a member delete only their own copy", async () => { const colleague = await withColleague(); await makeKey([{ uid: "uid-1", sealed: sealedCopy() }, { uid: "uid-2", sealed: sealedCopy() }]); diff --git a/app/server/app.ts b/app/server/app.ts index c0e388b..61f15d0 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -18,7 +18,9 @@ import { closeSession, listSessions, registerSession, + renameSession, sessionForApi, + sessionName, sessionSource, } from "./lib/sessions"; import { mintSecret } from "./lib/tokens"; @@ -106,6 +108,13 @@ export interface AppOptions { const MAX_BODY_BYTES = 64 * 1024; +/** + * How many sessions `shell ls` is given. Newest first, so an account with + * years of them still gets the ones it is asking about, in a reply the CLI + * can read. + */ +const CLI_SESSION_LIMIT = 500; + /** * An error the caller caused, safe to describe back to them. * @@ -533,6 +542,29 @@ export function createApp(options: AppOptions) { }); } + /* + * Every session this account has published, from any of its machines, + * for `shell ls`. Scoped to sessions the caller started: a colleague's + * sessions are theirs to list. No password copy is included. + */ + if (route === "GET /api/cli/sessions") { + const token = await requireCli(request); + if (!token) return send(response, 401, { error: "not signed in" }); + /* + * Newest first, and bounded. Nothing prunes this table, so an account + * that has been running sessions for a year would otherwise answer + * with megabytes; the CLI reads a bounded body and would fail to + * decode a reply that outgrew it, permanently and without saying why. + */ + const sessions = (await store.listSessions(token.uid)).slice(0, CLI_SESSION_LIMIT); + const states = options.sessionLiveness + ? await options.sessionLiveness.many(sessions) + : new Map(); + return send(response, 200, { + sessions: sessions.map((session) => ({ ...sessionForApi(session), ...states.get(session.id) })), + }); + } + /* ---- Organization ---- */ if (route === "GET /api/org") { @@ -771,6 +803,37 @@ export function createApp(options: AppOptions) { return send(response, 200, { shared }); } + /* + * A member replacing their own copy with one they sealed to themselves, + * so it stops depending on the teammate who sent it. Only a member who + * holds a copy of the current key can replace it, and only their own: + * the adding route is insert-only, and deleting first and adding after + * is refused, because by then the caller holds no copy. + */ + if (route === "PUT /api/team-key/share") { + const membership = await requireMember(request); + if (!membership) return send(response, 401, { error: "sign in first" }); + const body = (await readBody(request)) as Record; + const key = await store.teamKey(membership.orgId); + if (!key) return send(response, 404, { error: "this team has no audit key yet" }); + if (body.version !== key.version) { + return send(response, 409, { error: "the team's audit key has changed; reload and try again" }); + } + if (!isTeamKeyShare(body.sealed)) return send(response, 400, { error: "invalid key share" }); + const replaced = await store.replaceOwnTeamKeyShare({ + orgId: membership.orgId, + uid: membership.uid, + version: key.version, + senderUid: membership.uid, + sealed: body.sealed as string, + createdAt: Date.now(), + }); + if (!replaced) { + return send(response, 403, { error: "open your own copy of the team key before replacing it" }); + } + return send(response, 200, { replaced: true }); + } + /* * Only ever the caller's own copy: for when it no longer opens, such as * after a vault reset, so that a teammate can seal a fresh one. @@ -1236,6 +1299,16 @@ export function createApp(options: AppOptions) { return send(response, 200, { session: sessionForMember(membership, result.session) }); } + const nameRoute = url.pathname.match(/^\/api\/sessions\/([A-Za-z0-9_-]{6,64})\/name$/); + if (request.method === "PUT" && nameRoute) { + const membership = await requireMember(request); + if (!membership) return send(response, 401, { error: "sign in first" }); + const body = (await readBody(request)) as Record; + const result = await renameSession(store, membership, nameRoute[1], body.name); + if (!result.ok) return send(response, result.status, { error: result.error }); + return send(response, 200, { session: sessionForMember(membership, result.session) }); + } + /* ---- Driving a machine from the browser ---- */ /* @@ -1279,7 +1352,13 @@ export function createApp(options: AppOptions) { const command = String(body.command ?? "").trim(); if (!command) return send(response, 400, { error: "give a command to run" }); if (command.length > 500) return send(response, 400, { error: "that command is too long" }); - const name = String(body.name ?? "").trim().slice(0, 120); + /* + * Cleaned here, the same way a published name is. What the browser + * sends becomes SHELL_ONLINE_SESSION_NAME on the machine, so a name + * carrying a newline or a direction override would reach the CLI as + * something it should never have to make sense of. + */ + const name = sessionName(body.name) ?? ""; /* * Relayed verbatim. This service has no key for it and must not * pretend to validate what it cannot read. diff --git a/app/server/lib/sessions.test.ts b/app/server/lib/sessions.test.ts index a8f52e0..7725e6e 100644 --- a/app/server/lib/sessions.test.ts +++ b/app/server/lib/sessions.test.ts @@ -1,7 +1,17 @@ import { beforeEach, describe, expect, it } from "vitest"; import { MemoryStore } from "./store-memory"; import type { Store } from "./store"; -import { closeSession, listSessions, registerSession, sessionForApi, sessionSource } from "./sessions"; +import { + closeSession, + listSessions, + mayRenameSession, + registerSession, + renameSession, + sessionForApi, + sessionName, + sessionSource, +} from "./sessions"; +import type { Membership } from "./orgs"; const valid = { id: "qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t", @@ -110,3 +120,67 @@ describe("closeSession", () => { expect(await closeSession(store, "uid-1", "NOPEnopeNOPEnopeNOPEnopeNOPEnope", 0)).toBeNull(); }); }); + +describe("sessionName", () => { + it("trims, and treats blank as no name", () => { + expect(sessionName(" deploy ")).toBe("deploy"); + expect(sessionName(" ")).toBeUndefined(); + expect(sessionName(undefined)).toBeUndefined(); + expect(sessionName(42)).toBeUndefined(); + }); + + it("keeps a label on one line", () => { + expect(sessionName("fix\nthe\tbuild\u001b[31m")).toBe("fix the build [31m"); + }); + + it("removes the controls that would rewrite a row around it", () => { + /* U+202E prints everything after it backwards; U+0085 is a C1 newline. */ + expect(sessionName("build\u202Etxt.gnuf")).toBe("build txt.gnuf"); + expect(sessionName("one\u0085two")).toBe("one two"); + expect(sessionName("\u202A\u202C")).toBeUndefined(); + }); + + it("cuts an over-long label without splitting a character", () => { + const name = sessionName("\u{1F680}".repeat(200)); + expect([...(name ?? "")]).toHaveLength(120); + }); +}); + +describe("renameSession", () => { + function member(over: Partial = {}): Membership { + return { orgId: "org_1", uid: "uid-1", email: "ana@example.com", name: "Ana", role: "member", joinedAt: 1, ...over }; + } + + async function registered() { + const result = await registerSession(store, "uid-1", { ...valid, orgId: "org_1", ownerUid: "uid-1" }); + if (!result.ok) throw new Error("expected ok"); + return result.session; + } + + it("lets the owner, an assignee and a team admin rename, and nobody else", async () => { + const session = await registered(); + expect(mayRenameSession(member(), session)).toBe(true); + expect(mayRenameSession(member({ uid: "uid-2", role: "admin" }), session)).toBe(true); + expect(mayRenameSession(member({ uid: "uid-2" }), session)).toBe(false); + expect(mayRenameSession(member({ uid: "uid-2" }), { ...session, assigneeUids: ["uid-2"] })).toBe(true); + }); + + it("stores the cleaned name, and clears it when blank", async () => { + await registered(); + const renamed = await renameSession(store, member(), valid.id, " deploy "); + expect(renamed).toMatchObject({ ok: true, session: { name: "deploy" } }); + const cleared = await renameSession(store, member(), valid.id, ""); + expect(cleared.ok && cleared.session.name).toBeUndefined(); + }); + + it("refuses a colleague who is not responsible for it", async () => { + await registered(); + expect(await renameSession(store, member({ uid: "uid-2" }), valid.id, "mine")).toMatchObject({ ok: false, status: 403 }); + }); + + it("refuses a name that is not text, and an unknown session", async () => { + await registered(); + expect(await renameSession(store, member(), valid.id, 7)).toMatchObject({ ok: false, status: 400 }); + expect(await renameSession(store, member(), "unknownSession01", "x")).toMatchObject({ ok: false, status: 404 }); + }); +}); diff --git a/app/server/lib/sessions.ts b/app/server/lib/sessions.ts index 563138c..09b473c 100644 --- a/app/server/lib/sessions.ts +++ b/app/server/lib/sessions.ts @@ -1,3 +1,4 @@ +import type { Membership } from "./orgs"; import type { SessionRecord, Store } from "./store"; export interface SessionInput { @@ -57,6 +58,67 @@ export function sessionForApi(session: SessionRecord) { return { ...safe, origin: source.origin, deviceId: source.deviceId }; } +/** The longest label kept. Longer ones are cut, not refused. */ +export const SESSION_NAME_LIMIT = 120; + +/** + * A session label as it is stored: one line, trimmed, and bounded. Blank means + * no name at all, so the command is shown in its place. + */ +export function sessionName(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + /* + * A newline or escape in a label would break every list it appears in, and + * a direction override would rewrite the rest of the row around it: a name + * ending in U+202E makes a terminal print the columns after it backwards, + * so a finished session can be dressed up as something else in `shell ls`. + * C0, DEL, C1 and the bidi controls all go. + */ + const line = value + .replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]+/gu, " ") + .replace(/\s+/gu, " ") + .trim(); + return line ? [...line].slice(0, SESSION_NAME_LIMIT).join("") : undefined; +} + +/** + * Naming is housekeeping, not control over the machine, so it is open to the + * people responsible for the session: whoever started it, whoever it is + * assigned to, and whoever runs the team. + */ +export function mayRenameSession(membership: Membership, session: SessionRecord): boolean { + if ((session.ownerUid ?? session.uid) === membership.uid) return true; + if (membership.role === "owner" || membership.role === "admin") return true; + const assignees = session.assigneeUids?.length + ? session.assigneeUids + : session.assigneeUid + ? [session.assigneeUid] + : []; + return assignees.includes(membership.uid); +} + +export async function renameSession( + store: Store, + membership: Membership, + sessionId: string, + name: unknown, +): Promise< + | { ok: true; session: SessionRecord } + | { ok: false; status: number; error: string } +> { + if (name !== null && name !== undefined && typeof name !== "string") { + return { ok: false, status: 400, error: "a name must be text" }; + } + const session = await store.sessionInOrg(membership.orgId, sessionId); + if (!session) return { ok: false, status: 404, error: "no such session" }; + if (!mayRenameSession(membership, session)) { + return { ok: false, status: 403, error: "only the session's owner, assignees or a team admin can rename it" }; + } + const updated = await store.renameSession(membership.orgId, sessionId, sessionName(name)); + if (!updated) return { ok: false, status: 404, error: "no such session" }; + return { ok: true, session: updated }; +} + export type RegisterResult = | { ok: true; session: SessionRecord; isNew: boolean } | { ok: false; reason: string }; @@ -91,9 +153,7 @@ export async function registerSession( assigneeUids: [input.ownerUid ?? uid], shareUrl: input.shareUrl, command: input.command.slice(0, 300), - name: typeof input.name === "string" && input.name.trim() - ? input.name.trim().slice(0, 120) - : undefined, + name: sessionName(input.name), /* Reuse the existing source column so this upgrade needs no schema race. */ origin: packSource(input.origin, input.deviceId), readOnly: Boolean(input.readOnly), diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts index 2c924c4..60e867e 100644 --- a/app/server/lib/store-conformance.test.ts +++ b/app/server/lib/store-conformance.test.ts @@ -399,6 +399,23 @@ for (const implementation of implementations) { expect((await store.sessionInOrg("org_1", "s1"))?.assigneeUid).toBe("uid-2"); }); + it("keeps a name when a re-register sends none, and takes one it does send", async () => { + await store.upsertSession(session({ name: "nightly build" })); + await store.upsertSession(session()); + expect((await store.sessionInOrg("org_1", "s1"))?.name).toBe("nightly build"); + await store.upsertSession(session({ name: "release build" })); + expect((await store.sessionInOrg("org_1", "s1"))?.name).toBe("release build"); + }); + + it("renames a session, and clears the name when given none", async () => { + await store.upsertSession(session()); + expect((await store.renameSession("org_1", "s1", "deploy"))?.name).toBe("deploy"); + expect((await store.sessionInOrg("org_1", "s1"))?.name).toBe("deploy"); + expect((await store.renameSession("org_1", "s1", undefined))?.name).toBeUndefined(); + expect((await store.sessionInOrg("org_1", "s1"))?.name).toBeUndefined(); + expect(await store.renameSession("org_2", "s1", "elsewhere")).toBeNull(); + }); + it("keeps multiple assignees, including an intentional empty set", async () => { await store.upsertSession(session()); await store.assignSession("org_1", "s1", ["uid-1", "uid-2"]); @@ -604,6 +621,19 @@ for (const implementation of implementations) { expect((await store.teamKeyShares("org_1")).map((entry) => entry.uid)).toEqual(["uid-2"]); }); + it("replaces only an existing copy of the same version", async () => { + await store.putTeamKey({ orgId: "org_1", publicKey: "pk", version: 1, createdBy: "uid-1", createdAt: 1000 }); + await store.putTeamKeyShares([ + { orgId: "org_1", uid: "uid-2", version: 1, senderUid: "uid-1", sealed: "t1.first", createdAt: 1000 }, + ]); + const own = { orgId: "org_1", uid: "uid-2", version: 1, senderUid: "uid-2", sealed: "t1.own", createdAt: 2000 }; + expect(await store.replaceOwnTeamKeyShare(own)).toBe(true); + expect((await store.teamKeyShares("org_1"))[0]).toMatchObject({ senderUid: "uid-2", sealed: "t1.own" }); + expect(await store.replaceOwnTeamKeyShare({ ...own, version: 2 })).toBe(false); + expect(await store.replaceOwnTeamKeyShare({ ...own, uid: "uid-3" })).toBe(false); + expect((await store.teamKeyShares("org_1")).map((entry) => entry.uid)).toEqual(["uid-2"]); + }); + describe("typed input from before the key", () => { beforeEach(async () => { await store.putAudit(auditEvent({ id: "p1", kind: "input", text: "ls -la", at: 1000 })); diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts index 98fe30a..060ef63 100644 --- a/app/server/lib/store-memory.ts +++ b/app/server/lib/store-memory.ts @@ -427,6 +427,11 @@ export class MemoryStore implements Store { this.data.sessions[index] = { ...existing, ...session, + /* + * A restart that names nothing keeps the name somebody gave it, in + * the terminal or in the browser. Only a name it does send replaces it. + */ + name: session.name ?? existing.name, /* * A persistent session re-registers on every restart, carrying the * owner as assignee. Letting that through would silently undo a @@ -491,6 +496,15 @@ export class MemoryStore implements Store { return session; } + async renameSession(orgId: string, id: string, name: string | undefined): Promise { + const session = await this.sessionInOrg(orgId, id); + if (!session) return null; + if (name === undefined) delete session.name; + else session.name = name; + this.flush(); + return session; + } + async deleteSession(orgId: string, id: string): Promise { const before = this.data.sessions.length; this.data.sessions = this.data.sessions.filter( @@ -733,6 +747,16 @@ export class MemoryStore implements Store { return written; } + async replaceOwnTeamKeyShare(share: TeamKeyShare): Promise { + const index = this.data.teamKeyShares.findIndex( + (entry) => entry.orgId === share.orgId && entry.uid === share.uid && entry.version === share.version, + ); + if (index < 0) return false; + this.data.teamKeyShares[index] = { ...share }; + this.flush(); + return true; + } + async deleteTeamKeyShare(orgId: string, uid: string): Promise { const before = this.data.teamKeyShares.length; this.data.teamKeyShares = this.data.teamKeyShares.filter( diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts index 0d2fa99..fc7a8f7 100644 --- a/app/server/lib/store-postgres.ts +++ b/app/server/lib/store-postgres.ts @@ -709,6 +709,9 @@ export class PostgresStore implements Store { * A persistent session re-registers on every restart, carrying the owner * as assignee. Letting that through would silently undo a handoff, so an * assignment already made stands. + * + * A restart that names nothing keeps the name somebody gave it, in the + * terminal or in the browser. Only a name it does send replaces it. */ const row = await this.row( `INSERT INTO sessions @@ -723,7 +726,7 @@ export class PostgresStore implements Store { share_url = EXCLUDED.share_url, command = EXCLUDED.command, origin = EXCLUDED.origin, - name = EXCLUDED.name, + name = COALESCE(EXCLUDED.name, sessions.name), read_only = EXCLUDED.read_only, encrypted = EXCLUDED.encrypted, persistent = EXCLUDED.persistent, @@ -854,6 +857,18 @@ export class PostgresStore implements Store { return row ? (await this.hydrate([row]))[0] : null; } + async renameSession( + orgId: string, + id: string, + name: string | undefined, + ): Promise { + const row = await this.row( + `UPDATE sessions SET name = $3 WHERE org_id = $1 AND id = $2 RETURNING *`, + [orgId, id, name ?? null], + ); + return row ? (await this.hydrate([row]))[0] : null; + } + /* * The row only. Sealed password copies go with it because they are useless * without it, and the audit trail deliberately does not: removing a session @@ -1376,6 +1391,17 @@ export class PostgresStore implements Store { return written; } + /* One conditional update, so a copy that is not there is never created. */ + async replaceOwnTeamKeyShare(share: TeamKeyShare): Promise { + const result = await this.pool.query( + `UPDATE team_key_shares + SET sender_uid = $4, sealed = $5, created_at = $6 + WHERE org_id = $1 AND uid = $2 AND version = $3`, + [share.orgId, share.uid, share.version, share.senderUid, share.sealed, share.createdAt], + ); + return (result.rowCount ?? 0) > 0; + } + async deleteTeamKeyShare(orgId: string, uid: string): Promise { const result = await this.pool.query("DELETE FROM team_key_shares WHERE org_id = $1 AND uid = $2", [ orgId, diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts index 97e1d49..2ff83c8 100644 --- a/app/server/lib/store.ts +++ b/app/server/lib/store.ts @@ -125,6 +125,8 @@ export interface Store { listOrgSessions(orgId: string): Promise; sessionInOrg(orgId: string, id: string): Promise; assignSession(orgId: string, id: string, assigneeUids: string[]): Promise; + /** Sets the label a session is shown by. Undefined clears it, so the command shows instead. */ + renameSession(orgId: string, id: string, name: string | undefined): Promise; /** * Removes a session's record from an organization. * @@ -184,6 +186,12 @@ export interface Store { */ putTeamKeyShares(shares: TeamKeyShare[]): Promise; deleteTeamKeyShare(orgId: string, uid: string): Promise; + /** + * Replaces one member's own copy of the current key with one they sealed to + * themselves. Only an existing copy of the same version is replaced, so it + * cannot be used to take a copy nobody gave them. False when there is none. + */ + replaceOwnTeamKeyShare(share: TeamKeyShare): Promise; /** Typed input still stored as plaintext, oldest first, for a team member to seal. */ plaintextAudit(orgId: string, limit: number): Promise; /** diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index cf8d40a..9637849 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -173,6 +173,14 @@ export function assignSession(sessionId: string, uids: string[]) { ); } +/** A blank name clears it, and the session is shown by its command again. */ +export function renameSession(sessionId: string, name: string) { + return request<{ session: SessionRecord }>( + `/api/sessions/${encodeURIComponent(sessionId)}/name`, + { method: "PUT", body: JSON.stringify({ name }) }, + ); +} + export interface Comment { id: string; sessionId: string; @@ -462,6 +470,14 @@ export function putTeamKeyShares(version: number, shares: { uid: string; sealed: } /** Removes this person's own copy, so a teammate can seal a fresh one. */ +/** Replaces this member's own copy with one they sealed to themselves. */ +export function replaceMyTeamKeyShare(version: number, sealed: string) { + return request<{ replaced: boolean }>("/api/team-key/share", { + method: "PUT", + body: JSON.stringify({ version, sealed }), + }); +} + export function dropMyTeamKeyShare() { return request<{ deleted: boolean }>("/api/team-key/share", { method: "DELETE" }); } diff --git a/app/src/lib/session-view.test.ts b/app/src/lib/session-view.test.ts index 35faa22..7dd75c4 100644 --- a/app/src/lib/session-view.test.ts +++ b/app/src/lib/session-view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { canEdit, canRemove, canStop, cleanupCandidates, matches } from "./session-view"; +import { canEdit, canRemove, canRename, canStop, cleanupCandidates, matches } from "./session-view"; import type { Member, SessionRecord } from "./api"; function session(over: Partial = {}): SessionRecord { @@ -135,3 +135,17 @@ describe("bulk cleanup", () => { .toEqual(["own", "theirs"]); }); }); + +describe("who may rename a session", () => { + it("is the owner, an assignee, or whoever runs the team", () => { + expect(canRename(session({ ownerUid: "uid-1" }), member())).toBe(true); + expect(canRename(session({ ownerUid: "uid-2", assigneeUids: ["uid-1"] }), member())).toBe(true); + expect(canRename(session({ ownerUid: "uid-2" }), member({ role: "admin" }))).toBe(true); + expect(canRename(session({ ownerUid: "uid-2" }), member({ role: "owner" }))).toBe(true); + }); + + it("is not a colleague with no part in it, or nobody at all", () => { + expect(canRename(session({ ownerUid: "uid-2", assigneeUids: ["uid-3"] }), member())).toBe(false); + expect(canRename(session(), null)).toBe(false); + }); +}); diff --git a/app/src/lib/session-view.ts b/app/src/lib/session-view.ts index 221ee09..ce84f47 100644 --- a/app/src/lib/session-view.ts +++ b/app/src/lib/session-view.ts @@ -37,6 +37,21 @@ export function canHandOff(session: SessionRecord, you: Member | null): boolean return session.ownerUid === you.uid || you.role === "owner" || you.role === "admin"; } +/* + * Naming a session is housekeeping, not control over the machine, so the + * people responsible for it may do it: whoever started it, whoever it is + * assigned to, and whoever runs the team. The service applies the same rule. + */ +export function canRename(session: SessionRecord, you: Member | null): boolean { + if (!you) return false; + return ( + session.ownerUid === you.uid || + assigneeIds(session).includes(you.uid) || + you.role === "owner" || + you.role === "admin" + ); +} + /* * Removing the row is not stopping the process, so it is not tied to owning * the machine. The person whose session it is can tidy their own list, and diff --git a/app/src/routes/Audit.tsx b/app/src/routes/Audit.tsx index 5d48f2c..dcc7662 100644 --- a/app/src/routes/Audit.tsx +++ b/app/src/routes/Audit.tsx @@ -41,6 +41,8 @@ import { SearchSelect } from "../components/SearchSelect"; import { sessionStateLabel } from "../lib/session-liveness"; import type { SearchSelectOption } from "../lib/search-options"; import { useTeamKey } from "../vault/TeamKeyProvider"; +import { useVault } from "../vault/VaultProvider"; +import { VaultUnlock } from "../vault/VaultGate"; const PAGE_SIZE = 40; /* The service pages at most 100 at a time. */ @@ -174,6 +176,7 @@ function RankChart({ export function Audit() { usePageTitle("Audit log"); const team = useTeamKey(); + const vault = useVault(); const [params, setParams] = useSearchParams(); const [events, setEvents] = useState(null); const [sessions, setSessions] = useState([]); @@ -380,6 +383,13 @@ export function Audit() { What protects this page, said where it is read. The team's audit key opens it; the service stores it sealed. */} + {/* + The log is opened by the team's audit key, which is kept in the vault + and opens on its own once the vault is unlocked. When the vault is not, + this said "Opening your team's audit key." for as long as anyone waited: + the one thing standing between the reader and the log went unmentioned, + on the page where they had come to read it. + */}

@@ -389,10 +399,25 @@ export function Audit() { ? "This browser does not have your team's audit key yet. A teammate's browser seals it to you the next time they open shell.online; until then, what was typed shows as sealed." : team.status === "error" ? team.error - : "Opening your team's audit key."} + : vault.status === "locked" + ? "Your team's audit key is in your vault. Unlock it to read the log; no other key is asked for." + : vault.status === "setup" + ? "Your team's audit key is kept in your vault, and this account has none yet. Set one up and the log opens by itself from then on." + : "Opening your team's audit key."}

+ {team.status === "idle" && vault.status === "locked" && ( +
+ +
+ )} + {team.status === "idle" && vault.status === "setup" && ( +

+ Set up your vault on the Account page +

+ )} + {error && (
diff --git a/app/src/routes/Session.tsx b/app/src/routes/Session.tsx index 339810a..9963e5f 100644 --- a/app/src/routes/Session.tsx +++ b/app/src/routes/Session.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate, useParams } from "react-router-dom"; import { ArrowLeft, PaperPlaneTilt, + PencilSimple, Stop as StopIcon, Terminal as TerminalIcon, Trash, @@ -21,6 +22,7 @@ import { deleteSession, fetchSession, postComment, + renameSession, stopSession, type Comment, type Member, @@ -29,7 +31,7 @@ import { import { splitMentions } from "../lib/mentions"; import { displayName, findPerson } from "../lib/people"; import { usePageTitle } from "../lib/page-title"; -import { assigneeIds, canRemove, canStop } from "../lib/session-view"; +import { assigneeIds, canRemove, canRename, canStop } from "../lib/session-view"; import { ago, elapsed } from "../lib/time"; import { useVault } from "../vault/VaultProvider"; import { shareWith } from "../vault/share-with"; @@ -77,6 +79,8 @@ export function Session() { const [draft, setDraft] = useState(""); const [posting, setPosting] = useState(false); const [working, setWorking] = useState(""); + const [renaming, setRenaming] = useState(false); + const [draftName, setDraftName] = useState(""); const composer = useRef(null); const navigate = useNavigate(); const assignmentRevision = useRef(0); @@ -174,6 +178,37 @@ export function Session() { } } + function startRename() { + if (!detail) return; + setDraftName(detail.session.name ?? ""); + setRenaming(true); + } + + /* + * Saved as typed. A blank name clears it, and the session is shown by its + * command again, which is what every session without one already does. + */ + async function handleRename(event: FormEvent) { + event.preventDefault(); + if (!detail) return; + const name = draftName.trim(); + if (name === (detail.session.name ?? "")) { + setRenaming(false); + return; + } + setWorking("rename"); + setError(""); + try { + const { session: updated } = await renameSession(detail.session.id, name); + setDetail((current) => current ? { ...current, session: { ...current.session, name: updated.name } } : current); + setRenaming(false); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Could not rename that session."); + } finally { + setWorking(""); + } + } + async function handleComment(event: FormEvent) { event.preventDefault(); const body = draft.trim(); @@ -247,6 +282,43 @@ export function Session() { >
+ {renaming ? ( +
+ setDraftName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") setRenaming(false); + }} + placeholder={session.command} + maxLength={120} + aria-label="Session name" + autoFocus + /> + + +
+ ) : ( +
+

{session.name || session.command}

+ {canRename(session, you) && ( + + )} +
+ )} +
{sessionStateLabel(session)} diff --git a/app/src/routes/Workspace.tsx b/app/src/routes/Workspace.tsx index 1fc1f86..0b79250 100644 --- a/app/src/routes/Workspace.tsx +++ b/app/src/routes/Workspace.tsx @@ -747,6 +747,12 @@ export function Workspace() { ))}
+ {/* + * Hangs from the tab line over the corner of the terminal, where + * it belongs to the pane in front rather than to the tab list. The + * grid starts below it, so it covers padding and never text. + */} + {!showingList && ( + )}
)} diff --git a/app/src/styles/audit.css b/app/src/styles/audit.css index 0c0f679..8bd7efb 100644 --- a/app/src/styles/audit.css +++ b/app/src/styles/audit.css @@ -862,3 +862,9 @@ padding: 9px 7px; } } + +/* The vault unlock, shown on the audit log when that is what is in the way. */ +.audit-vault-gate { + max-width: 460px; + margin: 0 0 22px; +} diff --git a/app/src/styles/collab.css b/app/src/styles/collab.css index 427104a..8760467 100644 --- a/app/src/styles/collab.css +++ b/app/src/styles/collab.css @@ -207,6 +207,78 @@ align-items: start; } +.detail-title { + display: flex; + min-width: 0; + margin-bottom: 12px; + align-items: center; + gap: 8px; +} + +.detail-name { + overflow: hidden; + margin: 0; + color: var(--ink); + font-size: 22px; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-name-edit { + display: inline-grid; + width: 32px; + height: 32px; + flex: none; + place-items: center; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.detail-name-edit:hover, +.detail-name-edit:focus-visible { + background: color-mix(in srgb, var(--ink) 7%, transparent); + color: var(--ink); +} + +.detail-rename { + display: flex; + margin-bottom: 12px; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.detail-rename input { + min-width: 0; + height: 44px; + padding: 0 14px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + outline: 0; + background: var(--white); + color: var(--ink); + flex: 1 1 220px; + font-family: var(--sans); + font-size: 16px; +} + +.detail-rename input:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px var(--blue-wash); +} + +.detail-rename .btn { + width: auto; + height: 44px; + min-width: 96px; +} + .detail-head { display: flex; padding-bottom: 16px; diff --git a/app/src/styles/terminal.css b/app/src/styles/terminal.css index fb5510c..c5b47d0 100644 --- a/app/src/styles/terminal.css +++ b/app/src/styles/terminal.css @@ -3,6 +3,7 @@ --------------------------------------------------------------- */ .terminal-bar { + position: relative; display: flex; padding-bottom: 1px; border-bottom: 1px solid var(--line); @@ -23,26 +24,41 @@ display: none; } +/* + * A tab hanging from the tab line, in front of the terminal's top-right + * corner. It starts on the line itself and paints over it, so the line opens + * into the tab instead of running across its top: the active tab's join, + * turned upside down. + */ .tab-renderer { + position: absolute; + z-index: 9; + top: 100%; + right: 0; display: inline-flex; - min-height: 40px; - padding-left: 12px; - border-left: 1px solid var(--line); - margin-left: 8px; + /* Short enough, with the pane's top padding, to clear the first row. */ + height: 34px; + padding: 0 5px 0 12px; + border: 1px solid var(--line); + border-top: 0; + border-radius: 0 0 var(--radius-control) var(--radius-control); + background: var(--paper); color: var(--faint); - flex: none; font-size: 11px; align-items: center; - gap: 7px; + gap: 6px; } .tab-renderer select { - min-width: 104px; - height: 32px; - padding: 0 28px 0 9px; - border: 1px solid var(--line-strong); - border-radius: var(--radius-control); - background-color: var(--paper); + /* As wide as the chosen renderer, not the longest name in the list. */ + field-sizing: content; + max-width: 190px; + min-width: 0; + height: 26px; + padding: 0 26px 0 8px; + border: 0; + border-radius: var(--radius-chip); + background-color: transparent; color: var(--ink); cursor: pointer; font-family: inherit; @@ -51,6 +67,10 @@ line-height: 1; } +.tab-renderer select:hover { + background-color: color-mix(in srgb, var(--ink) 5%, transparent); +} + .tab-renderer select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; @@ -63,6 +83,8 @@ border: 1px solid color-mix(in srgb, var(--blue) 28%, var(--line)); border-radius: var(--radius-control); margin: -10px 0 12px; + /* Clears the renderer tab hanging over this row's right end. */ + margin-right: 250px; background: var(--blue-wash); color: var(--ink-soft); font-size: 12px; @@ -171,15 +193,24 @@ button.tab { flex: 1; } +/* + * No panel: the terminal is drawn straight onto the page, in the page's own + * colors, so a session reads as part of the app rather than a window inside + * it. The emulator's canvas is clear and its palette follows the app theme; + * see TerminalPane. + */ .pane { position: absolute; inset: 0; overflow: hidden; - padding: 14px 6px 14px 16px; - border: 1px solid #2a2e25; - border-radius: var(--radius-panel); - background: var(--terminal); - box-shadow: 0 20px 50px rgb(26 31 22 / 16%); + /* + * The top padding clears the renderer tab hanging over this corner, so the + * tab covers padding and never a row of output. Measured, not assumed: the + * tab is 34px from the tab line's own border, of which 20px is this + * element's margin from it. + */ + padding: 18px 0 14px; + background: transparent; } /* @@ -194,21 +225,23 @@ button.tab { } /* - * The grid is drawn as large as it fits, and what the fit could not use is - * split between the two sides rather than left as a gap down one of them. + * The grid is drawn as large as it fits and starts at the page's own left + * edge. With no panel around it, centring left a margin that lined up with + * nothing else on the page. */ .pane-screen { display: flex; width: 100%; height: 100%; - align-items: center; - justify-content: center; + align-items: flex-start; + justify-content: flex-start; } +/* Below the renderer tab, which hangs over the same corner. */ .pane-tools { position: absolute; z-index: 7; - top: 10px; + top: 26px; right: 12px; display: flex; max-width: calc(100% - 24px); @@ -222,27 +255,26 @@ button.tab { .pane-refstream-toolbar .terminal-tools { padding: 3px; - border: 1px solid var(--terminal-ui-line, #343c4b); + border: 1px solid var(--terminal-ui-line, var(--line)); border-radius: 8px; - background: var(--terminal-ui-surface, #171b23); - box-shadow: 0 8px 24px rgb(0 0 0 / 22%); + background: var(--terminal-ui-surface, var(--white)); + box-shadow: 0 8px 24px rgb(26 31 22 / 10%); } /* * Refstream ships neutral translucent defaults because it can be embedded on - * any page. The app is always a dark terminal surface, so make every layer - * opaque and use the app's terminal palette instead of letting page colors - * bleed through the controls and text. + * any page. Here it is drawn on the app's own surface, so every layer is + * opaque and takes the app palette, which already follows light and dark. */ .pane[data-renderer="refstream"] { - --terminal-ui-bg: #10130f; - --terminal-ui-surface: #181d17; - --terminal-ui-line: #343c30; - --terminal-ui-hover: #272e24; - --terminal-ui-fg: #edf0e7; - --terminal-ui-muted: #9ca695; - --terminal-ui-accent: #d4ff72; - --terminal-ui-selection: #3b472f; + --terminal-ui-bg: var(--paper); + --terminal-ui-surface: var(--white); + --terminal-ui-line: var(--line); + --terminal-ui-hover: color-mix(in srgb, var(--ink) 6%, var(--white)); + --terminal-ui-fg: var(--ink); + --terminal-ui-muted: var(--muted); + --terminal-ui-accent: var(--blue); + --terminal-ui-selection: var(--blue-wash); --terminal-ui-font: var(--sans); } @@ -254,16 +286,16 @@ button.tab { } .pane[data-renderer="refstream"] .shell-terminal { - background: var(--terminal); + background: var(--paper); } .pane-tools:has(.pane-refstream-toolbar:not(:empty), .pane-files-toolbar:not(:empty)) ~ .pane-banner { - top: 58px; + top: 74px; } @media (max-width: 640px) { .pane-tools { - top: 6px; + top: 26px; right: 6px; max-width: calc(100% - 12px); gap: 5px; @@ -296,14 +328,14 @@ button.tab { .pane-banner { position: absolute; - top: 14px; - right: 20px; + top: 30px; + right: 12px; display: inline-flex; padding: 6px 11px; - border: 1px solid var(--terminal-line); + border: 1px solid var(--line); border-radius: 999px; - background: rgb(16 19 9 / 90%); - color: var(--terminal-muted); + background: color-mix(in srgb, var(--white) 92%, transparent); + color: var(--muted); font-size: 12px; align-items: center; gap: 7px; @@ -319,7 +351,7 @@ button.tab { position: absolute; inset: 0; display: grid; - background: rgb(16 19 9 / 82%); + background: color-mix(in srgb, var(--paper) 80%, transparent); backdrop-filter: blur(3px); place-items: center; padding: 20px; @@ -331,7 +363,7 @@ button.tab { border: 1px solid var(--line); border-radius: var(--radius-panel); background: var(--white); - box-shadow: 0 22px 60px rgb(0 0 0 / 30%); + box-shadow: 0 22px 60px rgb(26 31 22 / 14%); } .pane-gate-mark { @@ -460,7 +492,12 @@ button.tab { } .pane { - padding: 12px 4px 12px 12px; + padding: 18px 0 12px; + } + + .renderer-warning { + margin-right: 0; + margin-top: 30px; } .tab, diff --git a/app/src/styles/vault.css b/app/src/styles/vault.css index 0fd7281..01e9166 100644 --- a/app/src/styles/vault.css +++ b/app/src/styles/vault.css @@ -490,3 +490,56 @@ grid-template-columns: repeat(2, max-content); } } + +/* Teammates the team audit key has not reached, on the Account vault panel. */ +.vault-team { + display: grid; + gap: 10px; +} + +.vault-team-list { + display: grid; + margin: 0; + padding: 0; + gap: 8px; + list-style: none; +} + +.vault-team-list li { + display: grid; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + gap: 4px; +} + +.vault-team-name { + color: var(--ink); + font-size: 0.9rem; + font-weight: 540; +} + +.vault-team-confirm { + display: flex; + color: var(--muted); + font-size: 0.85rem; + align-items: center; + flex-wrap: wrap; + gap: 6px 0; +} + +.vault-team-confirm code { + font-family: var(--mono); + color: var(--ink-soft); +} + +.vault-team-actions { + display: flex; + width: 100%; + margin-top: 6px; + gap: 8px; +} + +.vault-team-actions .btn { + width: auto; +} diff --git a/app/src/terminal/TerminalPane.tsx b/app/src/terminal/TerminalPane.tsx index dc8687c..9d39112 100644 --- a/app/src/terminal/TerminalPane.tsx +++ b/app/src/terminal/TerminalPane.tsx @@ -51,22 +51,63 @@ interface Attempt { password: string; } -const THEME = { +/* + * The terminal is drawn on the page itself, so its palette is the app's: ink + * on paper in the light theme, and shell.online's terminal colors in the dark. + * The canvas stays clear either way and the page shows through. + */ +const DARK_THEME = { background: "#00000000", foreground: "#dfe2d6", cursor: "#c8ff4d", + cursorAccent: "#161914", selectionBackground: "#3a3f33", }; -/* Refstream paints its own surface, so it needs an opaque theme rather than xterm's clear canvas. */ -const REFSTREAM_THEME = { - ...THEME, - background: "#1d201b", - foreground: "#edf0e7", - cursor: "#d4ff72", - selectionBackground: "#3b472f", +/* + * The standard ANSI colors assume a dark background: "white" and the bright + * yellows and cyans vanish on paper. Each is darkened to the app's own ink + * weights so programs that pick colors themselves stay legible. + */ +const LIGHT_THEME = { + background: "#00000000", + foreground: "#191b18", + cursor: "#191b18", + cursorAccent: "#f3f1e9", + selectionBackground: "#d4dbf8", + black: "#191b18", + red: "#b3261e", + green: "#2f6d29", + yellow: "#855d00", + blue: "#294ec8", + magenta: "#8a3aa3", + cyan: "#17707a", + white: "#686c63", + brightBlack: "#5d6158", + brightRed: "#c9402f", + brightGreen: "#3b8233", + brightYellow: "#9a6c00", + brightBlue: "#4267f5", + brightMagenta: "#a04dba", + brightCyan: "#1f8591", + brightWhite: "#3a3e37", }; +/* Refstream paints its own surface, so it needs an opaque theme rather than xterm's clear canvas. */ +function terminalTheme(renderer: TerminalRenderer, dark: boolean) { + const base = dark ? DARK_THEME : LIGHT_THEME; + if (renderer !== "refstream") return base; + return { ...base, background: dark ? "#161914" : "#f3f1e9" }; +} + +/* Mirrors tokens.css: an explicit data-theme wins, otherwise the system's. */ +function appPrefersDark(): boolean { + const forced = document.documentElement.dataset.theme; + if (forced === "dark") return true; + if (forced === "light") return false; + return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false; +} + const FONT_FAMILY = 'ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace'; /* Only until the first fit, which is one frame later. Nothing else reads it. */ @@ -105,6 +146,21 @@ export function TerminalPane({ const toolsMount = useRef(null); const filesMount = useRef(null); const terminal = useRef(null); + + /* The palette follows the app theme, including a change while the pane is open. */ + useEffect(() => { + const media = window.matchMedia?.("(prefers-color-scheme: dark)"); + const apply = () => { + if (terminal.current) terminal.current.options.theme = terminalTheme(renderer, appPrefersDark()); + }; + media?.addEventListener("change", apply); + const observer = new MutationObserver(apply); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + return () => { + media?.removeEventListener("change", apply); + observer.disconnect(); + }; + }, [renderer]); const measure = useRef<((fontSize: number) => TerminalCell) | null>(null); const connection = useRef(null); @@ -229,7 +285,7 @@ export function TerminalPane({ cursorBlink: true, convertEol: false, scrollback: 5000, - theme: renderer === "refstream" ? REFSTREAM_THEME : THEME, + theme: terminalTheme(renderer, appPrefersDark()), }); term.open(node); terminal.current = term; diff --git a/app/src/terminal/renderer.ts b/app/src/terminal/renderer.ts index 7b71331..d41d6fa 100644 --- a/app/src/terminal/renderer.ts +++ b/app/src/terminal/renderer.ts @@ -17,6 +17,7 @@ export interface TerminalSurface { lineHeight?: number; disableStdin?: boolean; fileLinks?: unknown; + theme?: Record; }; open(element: HTMLElement): void; write(data: string | Uint8Array, callback?: () => void): void; diff --git a/app/src/vault/TeamKeyProvider.tsx b/app/src/vault/TeamKeyProvider.tsx index 82d47f7..7a52cac 100644 --- a/app/src/vault/TeamKeyProvider.tsx +++ b/app/src/vault/TeamKeyProvider.tsx @@ -15,6 +15,7 @@ import { fetchSessions, fetchTeamKey, putTeamKeyShares, + replaceMyTeamKeyShare, sealAuditEntries, type AuditEvent, } from "../lib/api"; @@ -49,6 +50,21 @@ interface Held { privateKey: Key; } +/** + * A teammate with a vault and no copy of the key, as a browser holding it sees + * them. `changed` means their vault key is not the one this browser sealed to + * before: a vault reset, or a key that is not theirs. Nothing is sealed to it + * until the person at this browser has checked and said so. + */ +export interface PendingTeammate { + uid: string; + changed: boolean; + /** Fingerprint of the vault key they now report, to compare with them. */ + fingerprint: string; + /** The key that fingerprint belongs to, so accepting cannot trust another. */ + accountKey: string; +} + interface TeamKeyValue { status: TeamKeyStatus; error: string; @@ -64,6 +80,10 @@ interface TeamKeyValue { */ openAudit(event: Pick): Promise; refresh(): void; + /** Teammates still without a copy. Known only to a browser that holds the key. */ + pending: PendingTeammate[]; + /** Trusts a teammate's changed vault key after the person here has checked it, and seals their copy. */ + acceptTeammateKey(uid: string): void; } /* How often a browser holding the key looks for teammates who need a copy. */ @@ -83,6 +103,7 @@ export function TeamKeyProvider({ children }: { children: ReactNode }) { const [print, setPrint] = useState(""); const [createdBy, setCreatedBy] = useState(null); const [attempt, setAttempt] = useState(0); + const [pending, setPending] = useState([]); const held = useRef(null); const waiting = useRef<((key: Held) => void)[]>([]); /* Read by the refresh loop without restarting it whenever the vault re-renders. */ @@ -95,6 +116,7 @@ export function TeamKeyProvider({ children }: { children: ReactNode }) { if (vault.status !== "unlocked") { held.current = null; setStatus("idle"); + setPending([]); return; } let live = true; @@ -214,12 +236,14 @@ export function TeamKeyProvider({ children }: { children: ReactNode }) { * Sealed again to this person, by this person, so the copy no * longer depends on the teammate who sent it staying in the team: * a copy from someone who has left can no longer be checked. + * + * Replaced in one step. Deleting the copy and adding it back was + * refused, because the service only takes copies from someone who + * holds one, so every member but the key's maker lost theirs on the + * next refresh and stopped passing the key on. */ const mine = await own.sealTeamKey({ uid, accountKey: own.publicKey }, team, pkcs8); - if (mine) { - await dropMyTeamKeyShare().catch(() => undefined); - await putTeamKeyShares(team.version, [{ uid, sealed: mine }]).catch(() => undefined); - } + if (mine) await replaceMyTeamKeyShare(team.version, mine).catch(() => undefined); } /* Teammates with a vault and no copy get one, sealed by this vault. */ @@ -231,11 +255,30 @@ export function TeamKeyProvider({ children }: { children: ReactNode }) { const sealed = await own.sealTeamKey(member, team, pkcs8); if (sealed) shares.push({ uid: member.uid, sealed }); } + let sealedFor = new Set(); if (shares.length > 0) { - await putTeamKeyShares(team.version, shares).catch(() => undefined); - for (const member of needing) trustKey(uid, member.uid, member.accountKey); + const sent = await putTeamKeyShares(team.version, shares).then(() => true, () => false); + if (sent) { + sealedFor = new Set(shares.map((share) => share.uid)); + for (const member of needing) { + if (sealedFor.has(member.uid)) trustKey(uid, member.uid, member.accountKey); + } + } } + /* Whoever is still without a copy, so the vault can say who and why. */ + const still = view.missing.filter((member) => member.uid !== uid && !sealedFor.has(member.uid)); + const nextPending: PendingTeammate[] = []; + for (const member of still) { + nextPending.push({ + uid: member.uid, + changed: keyTrust(uid, member.uid, member.accountKey) === "changed", + fingerprint: await fingerprint(member.accountKey), + accountKey: member.accountKey, + }); + } + if (live) setPending(nextPending); + /* * Recorded now that a copy has proved to be the private half of the * key the team published. From here on, this browser refuses a @@ -305,9 +348,30 @@ export function TeamKeyProvider({ children }: { children: ReactNode }) { const refresh = useCallback(() => setAttempt((value) => value + 1), []); + /* + * The person at this browser has compared the new key with their teammate. + * Only then is it pinned, and the next check seals the teammate's copy to it. + */ + const acceptTeammateKey = useCallback( + (teammate: string) => { + const self = held.current?.uid; + /* + * The key from the entry the person was looking at, not whatever the + * service reports now. The refresh loop replaces this list every minute, + * and pinning a key that arrived after they read the fingerprint would + * skip the one check this confirmation exists to make. + */ + const entry = pending.find((candidate) => candidate.uid === teammate); + if (!self || !entry) return; + trustKey(self, teammate, entry.accountKey); + refresh(); + }, + [pending, refresh], + ); + const value = useMemo( - () => ({ status, error, fingerprint: print, createdBy, sealAudit, openAudit, refresh }), - [status, error, print, createdBy, sealAudit, openAudit, refresh], + () => ({ status, error, fingerprint: print, createdBy, sealAudit, openAudit, refresh, pending, acceptTeammateKey }), + [status, error, print, createdBy, sealAudit, openAudit, refresh, pending, acceptTeammateKey], ); return {children}; diff --git a/app/src/vault/VaultPanel.tsx b/app/src/vault/VaultPanel.tsx index 18270ef..d010156 100644 --- a/app/src/vault/VaultPanel.tsx +++ b/app/src/vault/VaultPanel.tsx @@ -156,9 +156,9 @@ function VaultContents() { Your copy of the team's audit key {team.status === "ready" - ? `Held. It lets you read your team's audit log, which is sealed to it. Key ${team.fingerprint}.` + ? `Held. It opens your team's audit log automatically, here and in any browser where this vault is unlocked. Key ${team.fingerprint}.` : team.status === "waiting" - ? "On its way: a teammate's browser seals it to you the next time they open shell.online." + ? "On its way. Any teammate who holds it seals it to your vault automatically while they have shell.online open, and the audit log opens as soon as it arrives." : team.status === "error" ? team.error : "Checking."} @@ -167,6 +167,8 @@ function VaultContents() { + + {error &&

{error}

} @@ -220,6 +222,75 @@ function VaultContents() { ); } +/** + * Teammates this browser could give a copy of the team key to, and has not. + * + * Copies are sealed automatically to every teammate whose vault key this + * browser has not seen change. A key that changed is shown here instead, with + * its fingerprint, because sealing the team's key to a swapped key would hand + * the whole log to whoever swapped it: the person here checks with their + * teammate first. + */ +function TeamKeyTeammates({ members }: { members: Member[] }) { + const team = useTeamKey(); + const [confirming, setConfirming] = useState(null); + if (team.status !== "ready" || team.pending.length === 0) return null; + + return ( +
+

Teammates waiting for the audit key

+
    + {team.pending.map((teammate) => { + const person = findPerson(members, teammate.uid); + return ( +
  • + {displayName(person)} + {teammate.changed ? ( + confirming === teammate.uid ? ( + + + Their vault key is now {teammate.fingerprint}. Ask them to read + the key on their Account page and check it matches before you go on. + + + + + + + ) : ( + + Their vault key changed, so nothing was sealed to it. + + + ) + ) : ( + + Not sealed yet. + + + )} +
  • + ); + })} +
+
+ ); +} + function VaultAccessMethods() { const vault = useVault(); const [recovery, setRecovery] = useState(""); diff --git a/cmd/shell/account_sessions.go b/cmd/shell/account_sessions.go new file mode 100644 index 0000000..354a222 --- /dev/null +++ b/cmd/shell/account_sessions.go @@ -0,0 +1,227 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "text/tabwriter" + "time" + + "shell.online/internal/account" +) + +// accountSessionsTimeout bounds `shell ls`, which is one request and a refresh. +const accountSessionsTimeout = 15 * time.Second + +// accountSessionJSON is the stable, script-facing shape of `shell ls --json`. +type accountSessionJSON struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Command string `json:"command"` + Host string `json:"host"` + ShareURL string `json:"share_url"` + ReadOnly bool `json:"read_only"` + Encrypted bool `json:"encrypted"` + Persistent bool `json:"persistent"` + StartedAt time.Time `json:"started_at"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Status string `json:"status"` + RelayStatus string `json:"relay_status,omitempty"` +} + +// runAccountSessionList prints the sessions in the linked account, from every +// machine it has, where `shell list` prints only the processes on this one. +func runAccountSessionList(arguments []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("shell ls", flag.ContinueOnError) + flags.SetOutput(stderr) + all := flags.Bool("all", false, "include sessions that have ended") + jsonOutput := flags.Bool("json", false, "emit sessions as JSON") + flags.Usage = func() { + fmt.Fprintln(stderr, "Usage: shell ls [--all] [--json]") + fmt.Fprintln(stderr, "Lists the sessions in your account, from every linked machine.") + } + if err := flags.Parse(arguments); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if flags.NArg() != 0 { + flags.Usage() + return 2 + } + + ctx, cancel := context.WithTimeout(context.Background(), accountSessionsTimeout) + defer cancel() + client, credentials, err := linkedAccountClient(ctx, stderr) + if errors.Is(err, account.ErrNotLinked) { + fmt.Fprintln(stderr, "shell: shell ls lists the sessions in your account, and this machine is not signed in.") + fmt.Fprintln(stderr, "Run 'shell login' to link it, or 'shell list' for the sessions running here.") + return 1 + } + if err != nil { + fmt.Fprintf(stderr, "shell: %v\n", err) + return 1 + } + sessions, err := client.ListSessions(ctx, credentials.AccessToken) + if err != nil { + fmt.Fprintf(stderr, "shell: list account sessions: %v\n", err) + return 1 + } + + shown := make([]account.AccountSession, 0, len(sessions)) + for _, session := range sessions { + if *all || !accountSessionEnded(session) { + shown = append(shown, session) + } + } + hidden := len(sessions) - len(shown) + + if *jsonOutput { + listed := make([]accountSessionJSON, 0, len(shown)) + for _, session := range shown { + listed = append(listed, accountSessionForJSON(session)) + } + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(listed); err != nil { + fmt.Fprintf(stderr, "shell: encode sessions: %v\n", err) + return 1 + } + return 0 + } + + now := time.Now() + if len(shown) == 0 { + if *all { + fmt.Fprintln(stdout, "No sessions in your account.") + } else { + fmt.Fprintln(stdout, "No open sessions in your account.") + } + } else if compactSessionList(stdout) { + printCompactAccountSessions(stdout, shown, now) + } else { + printAccountSessionTable(stdout, shown, now) + } + if hidden > 0 { + fmt.Fprintf(stdout, "%d ended session%s hidden · shell ls --all\n", hidden, pluralSuffix(hidden)) + } + return 0 +} + +// linkedAccountClient loads this machine's account and renews a stale token, +// saving the renewed one so the next command need not. +func linkedAccountClient(ctx context.Context, warn io.Writer) (*account.Client, account.Credentials, error) { + path, err := account.DefaultPath() + if err != nil { + return nil, account.Credentials{}, err + } + credentials, err := account.Load(path) + if err != nil { + return nil, account.Credentials{}, err + } + client := account.NewClient(credentials.Server, "shell/"+version) + if credentials.Expired(time.Now()) { + refreshed, refreshErr := client.Refresh(ctx, credentials) + if refreshErr != nil { + return nil, account.Credentials{}, fmt.Errorf("renew this machine's sign-in: %w", refreshErr) + } + credentials = refreshed + if saveErr := account.Save(path, credentials); saveErr != nil { + fmt.Fprintf(warn, "shell: could not store the renewed token: %v\n", saveErr) + } + } + return client, credentials, nil +} + +// accountSessionEnded mirrors the web app: closed, or gone from the relay. +// A disconnected session may still come back, so it is not ended. +func accountSessionEnded(session account.AccountSession) bool { + return session.ClosedAt != nil || session.RelayStatus == "exited" || session.RelayStatus == "missing" +} + +// accountSessionStatus uses the words the web app shows for the same states. +func accountSessionStatus(session account.AccountSession) string { + switch { + case session.ClosedAt != nil || session.RelayStatus == "exited": + return "finished" + case session.RelayStatus == "missing": + return "unavailable" + case session.RelayStatus == "disconnected": + return "offline" + case session.RelayStatus == "waiting": + return "starting" + case session.RelayStatus == "unknown": + return "unknown" + default: + return "online" + } +} + +// accountSessionDuration is how long a session has run, or ran. +func accountSessionDuration(session account.AccountSession, now time.Time) string { + end := now + if session.ClosedAt != nil { + end = time.UnixMilli(*session.ClosedAt) + } + return compactDuration(end.Sub(time.UnixMilli(session.StartedAt))) +} + +func accountSessionForJSON(session account.AccountSession) accountSessionJSON { + listed := accountSessionJSON{ + ID: session.ID, + Name: session.Name, + Command: session.Command, + Host: session.Host, + ShareURL: session.ShareURL, + ReadOnly: session.ReadOnly, + Encrypted: session.Encrypted, + Persistent: session.Persistent, + StartedAt: time.UnixMilli(session.StartedAt).UTC(), + ExitCode: session.ExitCode, + Status: accountSessionStatus(session), + RelayStatus: session.RelayStatus, + } + if session.ClosedAt != nil { + closedAt := time.UnixMilli(*session.ClosedAt).UTC() + listed.ClosedAt = &closedAt + } + return listed +} + +func printAccountSessionTable(writer io.Writer, sessions []account.AccountSession, now time.Time) { + table := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + fmt.Fprintln(table, "ID\tNAME\tSTATUS\tUPTIME\tMACHINE\tCOMMAND") + for _, session := range sessions { + fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%s\n", + shortSessionID(session.ID), + sessionNameLabel(session.Name, 32), + accountSessionStatus(session), + accountSessionDuration(session, now), + truncateText(session.Host, 24), + truncateText(session.Command, 48), + ) + } + _ = table.Flush() +} + +func printCompactAccountSessions(writer io.Writer, sessions []account.AccountSession, now time.Time) { + for index, session := range sessions { + if index > 0 { + fmt.Fprintln(writer) + } + title := session.Name + if title == "" { + title = session.Command + } + fmt.Fprintf(writer, "%s %s\n", shortSessionID(session.ID), truncateText(title, 64)) + fmt.Fprintf(writer, " %s · %s · %s\n", accountSessionStatus(session), accountSessionDuration(session, now), session.Host) + if session.Name != "" { + fmt.Fprintf(writer, " Command %s\n", truncateText(session.Command, 64)) + } + } +} diff --git a/cmd/shell/account_sessions_test.go b/cmd/shell/account_sessions_test.go new file mode 100644 index 0000000..cc58027 --- /dev/null +++ b/cmd/shell/account_sessions_test.go @@ -0,0 +1,254 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + "unicode/utf8" + + "shell.online/internal/account" +) + +func accountSessionsService(t *testing.T, sessions []map[string]any) *httptest.Server { + t.Helper() + service := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/cli/sessions" || request.Method != http.MethodGet { + t.Errorf("unexpected request %s %s", request.Method, request.URL.Path) + } + if request.Header.Get("Authorization") != "Bearer sha_access" { + t.Errorf("Authorization = %q", request.Header.Get("Authorization")) + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{"sessions": sessions}) + })) + t.Cleanup(service.Close) + return service +} + +func sampleAccountSessions(now time.Time) []map[string]any { + return []map[string]any{ + { + "id": "qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t", "name": "web app", "command": "npm run dev", + "host": "ana-mbp", "shareUrl": "https://shell.online/s/qN7wKb3xTm9Ld2Ravh4YsPcE8UjZgF6t", + "startedAt": now.Add(-2 * time.Hour).UnixMilli(), "relayStatus": "connected", + }, + { + "id": "Zm9vYmFyYmF6cXV4cXV1eDEyMzQ1Njc4", "command": "pytest -x", + "host": "build-01", "shareUrl": "https://shell.online/s/Zm9vYmFyYmF6cXV4cXV1eDEyMzQ1Njc4", + "startedAt": now.Add(-3 * time.Hour).UnixMilli(), "closedAt": now.Add(-150 * time.Minute).UnixMilli(), + "exitCode": 1, + }, + } +} + +func TestAccountSessionListShowsOpenSessionsAndCountsEndedOnes(t *testing.T) { + service := accountSessionsService(t, sampleAccountSessions(time.Now())) + linkedAccount(t, service.URL) + + var stdout, stderr bytes.Buffer + if code := runAccountSessionList(nil, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{"NAME", "qN7wKb3xTm", "web app", "online", "ana-mbp", "npm run dev", "1 ended session hidden · shell ls --all"} { + if !strings.Contains(output, expected) { + t.Errorf("output does not contain %q\n%s", expected, output) + } + } + if strings.Contains(output, "pytest") { + t.Errorf("an ended session was listed without --all\n%s", output) + } +} + +func TestAccountSessionListAllIncludesEndedSessions(t *testing.T) { + service := accountSessionsService(t, sampleAccountSessions(time.Now())) + linkedAccount(t, service.URL) + + var stdout, stderr bytes.Buffer + if code := runAccountSessionList([]string{"--all"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr.String()) + } + for _, expected := range []string{"web app", "pytest -x", "finished", "build-01"} { + if !strings.Contains(stdout.String(), expected) { + t.Errorf("output does not contain %q\n%s", expected, stdout.String()) + } + } + if strings.Contains(stdout.String(), "hidden") { + t.Errorf("--all should hide nothing\n%s", stdout.String()) + } +} + +func TestAccountSessionListJSONIsStable(t *testing.T) { + now := time.Now() + service := accountSessionsService(t, sampleAccountSessions(now)) + linkedAccount(t, service.URL) + + var stdout, stderr bytes.Buffer + if code := runAccountSessionList([]string{"--json", "--all"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, stderr = %q", code, stderr.String()) + } + var listed []map[string]any + if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil { + t.Fatalf("decode %q: %v", stdout.String(), err) + } + if len(listed) != 2 { + t.Fatalf("listed %d sessions, want 2", len(listed)) + } + if listed[0]["name"] != "web app" || listed[0]["status"] != "online" || listed[0]["share_url"] == nil { + t.Errorf("first session = %+v", listed[0]) + } + if listed[1]["status"] != "finished" || listed[1]["closed_at"] == nil || listed[1]["exit_code"] != float64(1) { + t.Errorf("second session = %+v", listed[1]) + } + if _, present := listed[1]["name"]; present { + t.Errorf("an unnamed session should omit name: %+v", listed[1]) + } +} + +func TestAccountSessionListExplainsAnUnlinkedMachine(t *testing.T) { + t.Setenv("SHELL_ONLINE_CONFIG", filepath.Join(t.TempDir(), "absent.json")) + var stdout, stderr bytes.Buffer + if code := runAccountSessionList(nil, &stdout, &stderr); code != 1 { + t.Fatalf("exit = %d, want 1", code) + } + for _, expected := range []string{"not signed in", "shell login", "shell list"} { + if !strings.Contains(stderr.String(), expected) { + t.Errorf("stderr does not contain %q: %q", expected, stderr.String()) + } + } +} + +func TestAccountSessionListRejectsArguments(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runAccountSessionList([]string{"extra"}, &stdout, &stderr); code != 2 { + t.Fatalf("exit = %d, want 2", code) + } +} + +func TestRunSessionCommandRoutesLs(t *testing.T) { + var stdout, stderr bytes.Buffer + code, handled := runSessionCommand([]string{"ls", "--help"}, &stdout, &stderr) + if !handled || code != 0 { + t.Fatalf("ls --help = %d, handled %v", code, handled) + } + if !strings.Contains(stderr.String(), "shell ls") { + t.Fatalf("usage = %q", stderr.String()) + } +} + +func TestAccountSessionStatusMatchesTheWebApp(t *testing.T) { + closed := int64(1) + tests := []struct { + session account.AccountSession + status string + ended bool + }{ + {account.AccountSession{RelayStatus: "connected"}, "online", false}, + {account.AccountSession{}, "online", false}, + {account.AccountSession{RelayStatus: "waiting"}, "starting", false}, + {account.AccountSession{RelayStatus: "disconnected"}, "offline", false}, + {account.AccountSession{RelayStatus: "unknown"}, "unknown", false}, + {account.AccountSession{RelayStatus: "missing"}, "unavailable", true}, + {account.AccountSession{RelayStatus: "exited"}, "finished", true}, + {account.AccountSession{ClosedAt: &closed, RelayStatus: "connected"}, "finished", true}, + } + for _, test := range tests { + if got := accountSessionStatus(test.session); got != test.status { + t.Errorf("status(%+v) = %q, want %q", test.session, got, test.status) + } + if got := accountSessionEnded(test.session); got != test.ended { + t.Errorf("ended(%+v) = %v, want %v", test.session, got, test.ended) + } + } +} + +func TestValidateSessionName(t *testing.T) { + if err := validateSessionName(""); err != nil { + t.Errorf("empty name: %v", err) + } + if err := validateSessionName(strings.Repeat("é", sessionNameLimit)); err != nil { + t.Errorf("name at the limit: %v", err) + } + if err := validateSessionName(strings.Repeat("a", sessionNameLimit+1)); err == nil { + t.Error("an over-long name was accepted") + } + if err := validateSessionName("two\nlines"); err == nil { + t.Error("a multi-line name was accepted") + } +} + +func TestValidateSessionNameRefusesADirectionOverride(t *testing.T) { + if err := validateSessionName("build\u202etxt.gnuf"); err == nil { + t.Error("a name that reverses the rest of its row was accepted") + } +} + +func TestSanitizeSessionNameCleansRatherThanRefuses(t *testing.T) { + tests := []struct { + name string + want string + }{ + {name: "deploy\nnow", want: "deploy now"}, + {name: "build\u202etxt.gnuf", want: "build txt.gnuf"}, + {name: " spaced out ", want: "spaced out"}, + {name: "\u202a\u202c", want: ""}, + {name: "", want: ""}, + } + for _, test := range tests { + if got := sanitizeSessionName(test.name); got != test.want { + t.Errorf("sanitizeSessionName(%q) = %q, want %q", test.name, got, test.want) + } + } + long := sanitizeSessionName(strings.Repeat("e", sessionNameLimit+40)) + if utf8.RuneCountInString(long) != sessionNameLimit { + t.Errorf("an over-long name was not cut to %d runes", sessionNameLimit) + } + if err := validateSessionName(sanitizeSessionName("deploy\nnow\u202egnuf")); err != nil { + t.Errorf("a cleaned name should always be acceptable: %v", err) + } +} + +func TestNameFlagIsRejectedBeforeAnythingStarts(t *testing.T) { + // run() may try to bring the daemon up; with no account there is nothing to start. + t.Setenv("SHELL_ONLINE_CONFIG", filepath.Join(t.TempDir(), "absent.json")) + var stdout, stderr bytes.Buffer + code := run([]string{"--name", strings.Repeat("a", sessionNameLimit+1), "true"}, &stdout, &stderr) + if code != 2 || !strings.Contains(stderr.String(), "--name is limited") { + t.Fatalf("exit = %d, stderr = %q", code, stderr.String()) + } +} + +func TestAutoCloseNormalizationSkipsTheNameValue(t *testing.T) { + now := time.Date(2026, time.September, 11, 12, 0, 0, 0, time.UTC) + got, err := normalizeAutoCloseArguments([]string{"--name", "nightly", "--auto-close", "in", "5m", "make"}, now) + if err != nil { + t.Fatal(err) + } + want := []string{"--name", "nightly", "--auto-close=in 5m", "make"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("normalized = %q, want %q", got, want) + } +} + +func TestCompactSessionListLeadsWithTheName(t *testing.T) { + now := time.Date(2026, time.September, 11, 12, 0, 0, 0, time.UTC) + record := localSessionRecord{ + ID: "abcdefghijklmnopqrstuvwxyzABCDEF", + Name: "training run", + ShareURL: "https://shell.online/s/abcdefghijklmnopqrstuvwxyzABCDEF", + Command: "python train.py", + StartedAt: now.Add(-time.Minute), + } + var output bytes.Buffer + printCompactSessionList(&output, []localSessionRecord{record}, map[string]relaySessionStatus{}, now) + for _, expected := range []string{"abcdefghij training run", "Command python train.py"} { + if !strings.Contains(output.String(), expected) { + t.Errorf("card does not contain %q\n%s", expected, output.String()) + } + } +} diff --git a/cmd/shell/autoclose.go b/cmd/shell/autoclose.go index 1aa92db..5a41caf 100644 --- a/cmd/shell/autoclose.go +++ b/cmd/shell/autoclose.go @@ -50,7 +50,7 @@ func normalizeAutoCloseArguments(arguments []string, now time.Time) ([]string, e } if argument != "--auto-close" { normalized = append(normalized, argument) - if (argument == "--server" || argument == "--persistent") && index+1 < len(arguments) { + if (argument == "--server" || argument == "--persistent" || argument == "--name") && index+1 < len(arguments) { normalized = append(normalized, arguments[index+1]) index++ continue diff --git a/cmd/shell/background.go b/cmd/shell/background.go index 309d778..fda1ea2 100644 --- a/cmd/shell/background.go +++ b/cmd/shell/background.go @@ -19,6 +19,7 @@ type backgroundLaunchResult struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` ID string `json:"session_id,omitempty"` + Name string `json:"name,omitempty"` ShareURL string `json:"share_url,omitempty"` ReadOnly bool `json:"read_only,omitempty"` Encrypted bool `json:"encrypted,omitempty"` diff --git a/cmd/shell/background_unix.go b/cmd/shell/background_unix.go index 5144201..9e69372 100644 --- a/cmd/shell/background_unix.go +++ b/cmd/shell/background_unix.go @@ -126,6 +126,9 @@ func launchBackgroundProcess(arguments []string, jsonOutput bool, stdout, stderr "expires_at": result.ExpiresAt.Format(time.RFC3339), "background": true, } + if result.Name != "" { + event["name"] = result.Name + } if result.Password != "" { event["e2ee_password"] = result.Password } diff --git a/cmd/shell/background_windows.go b/cmd/shell/background_windows.go index 2cf5f05..0c732c8 100644 --- a/cmd/shell/background_windows.go +++ b/cmd/shell/background_windows.go @@ -117,6 +117,9 @@ func launchBackgroundProcess(arguments []string, jsonOutput bool, stdout, stderr "read_only": result.ReadOnly, "encrypted": result.Encrypted, "persistent": result.Persistent, "auto_close": "task", "expires_at": result.ExpiresAt.Format(time.RFC3339), "background": true, } + if result.Name != "" { + event["name"] = result.Name + } if result.Password != "" { event["e2ee_password"] = result.Password } diff --git a/cmd/shell/daemon.go b/cmd/shell/daemon.go index d877e55..84c5aac 100644 --- a/cmd/shell/daemon.go +++ b/cmd/shell/daemon.go @@ -10,6 +10,8 @@ import ( "net" "os" "os/signal" + "path/filepath" + "strings" "syscall" "time" @@ -311,6 +313,18 @@ func stopDaemon() { // is not signed in, or whose owner did not agree to remote starts, gets // nothing: this is the one place that decides a background process may exist, // and it says no by default. +// isGoTestBinary reports whether this process is a compiled Go test. +// +// Starting a daemon means running this executable again with "daemon". Under +// go test that executable is the test binary, which runs the whole suite +// instead, whose tests start daemons of their own: a fork bomb that took a +// development machine to a load average of 500. A test binary is never a +// daemon, so it never starts one. +func isGoTestBinary(executable string) bool { + name := strings.TrimSuffix(filepath.Base(executable), ".exe") + return strings.HasSuffix(name, ".test") +} + func ensureDaemon() { path, err := account.DefaultPath() if err != nil { @@ -327,6 +341,9 @@ func ensureDaemon() { if err != nil { return } + if isGoTestBinary(self) { + return + } startDetachedDaemon(self) } @@ -363,5 +380,8 @@ func restartDaemon() { if err != nil { return } + if isGoTestBinary(self) { + return + } startDetachedDaemon(self) } diff --git a/cmd/shell/help.go b/cmd/shell/help.go index 873c608..f577e02 100644 --- a/cmd/shell/help.go +++ b/cmd/shell/help.go @@ -12,6 +12,7 @@ Start shell Share it in the background shell --read-only Share it while browser input is blocked shell --files Add on-demand files from this directory + shell --name Label it in shell ls and the web app shell Share a fresh shell shell claude Share a fork of this conversation @@ -21,6 +22,7 @@ both. Shares are interactive by default and end-to-end encrypted. Then shell list See active shares and uptime shell list --json Give agents the complete machine-readable records + shell ls See every open session in your account, on any machine shell password Print an active share's password locally shell password rotate Revoke it and make a fresh password shell attach Rejoin locally; browser access stays live @@ -40,13 +42,14 @@ Machine services Common options --read-only View only + --name Label the session --foreground Stay attached locally --persistent Keep one encrypted URL across restarts --files Opt in the working directory for file access --files-root Opt in a different directory --auto-close