Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve

### Added

- `shell --name <name> <command>` 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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@ installer, and test caveats.
shell <command> # share a command
shell # share a new shell
shell --read-only <command> # disable browser input
shell --name "web app" <command> # label it in lists and the web app
shell --foreground <command> # also show it locally
shell --auto-close 5m <command> # set an earlier deadline
shell --persistent <file> <command> # reuse a URL and password
shell --files <command> # opt in working-directory files
shell --files-root <dir> <command> # 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 <id> # retrieve an active password locally
shell password rotate <id> # revoke it without restarting the process
shell attach <id> # attach locally
Expand Down
159 changes: 159 additions & 0 deletions app/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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`, {
Expand Down Expand Up @@ -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() }]);
Expand Down
81 changes: 80 additions & 1 deletion app/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
closeSession,
listSessions,
registerSession,
renameSession,
sessionForApi,
sessionName,
sessionSource,
} from "./lib/sessions";
import { mintSecret } from "./lib/tokens";
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<string, SessionLiveness>();
return send(response, 200, {
sessions: sessions.map((session) => ({ ...sessionForApi(session), ...states.get(session.id) })),
});
}

/* ---- Organization ---- */

if (route === "GET /api/org") {
Expand Down Expand Up @@ -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<string, unknown>;
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.
Expand Down Expand Up @@ -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<string, unknown>;
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 ---- */

/*
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading