diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f534b6..077a8a0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,16 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve the oldest written. - Starting a session on a machine that cannot receive a password is refused, instead of producing a session nobody can open. +- The README's Homebrew command taps this repository and includes the one-time + `brew trust` step Homebrew 6 asks for. It used to name a tap that does not + exist. +- A request that cannot reach the service says so plainly, instead of "Could + not reach the accounts service at . Is it running?", and an outage page from + the edge no longer surfaces as a JSON parse error. +- The `shell login` approval page says what a linked machine shares with + your team: the full command line, machine name, session name and timings, + and what is typed from a browser. It used to say only the command name and + timing were published. ## [0.11.3] — 2026-09-11 diff --git a/README.md b/README.md index 3c16f9cc..703b60f2 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,17 @@ Windows PowerShell: irm https://shell.online/install.ps1 | iex ``` -Homebrew: +Homebrew (the tap lives in this repository): ```sh -brew install TeoSlayer/shell-online/shell-online +brew tap teoslayer/shell-online https://github.com/TeoSlayer/shell.online +brew trust --tap teoslayer/shell-online +brew install shell-online ``` +Homebrew 6 asks you to trust a third-party tap once. Older versions have no +`brew trust` and can skip that line. + Installers verify checksums. Release binaries and `SHA256SUMS` are available on the [releases page](https://github.com/TeoSlayer/shell.online/releases). diff --git a/app/src/lib/api.test.ts b/app/src/lib/api.test.ts new file mode 100644 index 00000000..c017a0cc --- /dev/null +++ b/app/src/lib/api.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./firebase", () => ({ + auth: { currentUser: { getIdToken: async () => "id-token" } }, +})); + +import { fetchDevices, NETWORK_FAILURE, SERVER_FAILURE } from "./api"; + +function respond(status: number, text: string) { + vi.stubGlobal("fetch", vi.fn(async () => new Response(text, { status }))); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("request errors", () => { + it("says shell.online could not be reached, naming no URL", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }), + ); + await expect(fetchDevices()).rejects.toThrow(NETWORK_FAILURE); + }); + + it("does not pass on a parser error when the edge answers with HTML", async () => { + respond(502, "Bad gateway"); + await expect(fetchDevices()).rejects.toThrow(SERVER_FAILURE); + }); + + it("treats an unreadable success as a failure rather than returning it", async () => { + respond(200, ""); + await expect(fetchDevices()).rejects.toThrow(SERVER_FAILURE); + }); + + it("passes the service's own message through", async () => { + respond(404, JSON.stringify({ error: "no such machine" })); + await expect(fetchDevices()).rejects.toThrow("no such machine"); + }); + + it("falls back to a sentence when a failure carries no message", async () => { + respond(500, ""); + await expect(fetchDevices()).rejects.toThrow(SERVER_FAILURE); + }); + + it("returns the body of a success", async () => { + respond(200, JSON.stringify({ devices: [] })); + await expect(fetchDevices()).resolves.toEqual({ devices: [] }); + }); +}); diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 92141dae..02e9cee9 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -235,6 +235,13 @@ export interface SessionRecord { class ApiError extends Error {} +/* + * Written for the person reading them. BASE is empty in every deployment, so + * a message that names it reads "at ." -- and it was never theirs to fix. + */ +export const NETWORK_FAILURE = "Could not reach shell.online. Check your connection and try again."; +export const SERVER_FAILURE = "Something went wrong on our side. Try again."; + async function request(path: string, init: RequestInit = {}): Promise { const user = auth.currentUser; if (!user) throw new ApiError("You are signed out. Sign in and try again."); @@ -255,15 +262,24 @@ async function request(path: string, init: RequestInit = {}): Promise { }, }); } catch { - throw new ApiError( - `Could not reach the accounts service at ${BASE}. Is it running?`, - ); + throw new ApiError(NETWORK_FAILURE); } + /* + * The edge answers an outage with an HTML page, not JSON. The reader is owed + * a sentence about what happened, not the parser's complaint about a "<". + */ const text = await response.text(); - const body = text ? (JSON.parse(text) as Record) : {}; + let body: Record = {}; + if (text) { + try { + body = JSON.parse(text) as Record; + } catch { + throw new ApiError(SERVER_FAILURE); + } + } if (!response.ok) { - throw new ApiError(String(body.error ?? `Request failed with ${response.status}`)); + throw new ApiError(typeof body.error === "string" ? body.error : SERVER_FAILURE); } return body as T; } @@ -414,9 +430,14 @@ export async function downloadAuditCsv(sessionId?: string): Promise { if (!user) throw new Error("You are signed out."); const token = await user.getIdToken(); const query = sessionId ? `?session=${encodeURIComponent(sessionId)}` : ""; - const response = await fetch(`${BASE}/api/audit.csv${query}`, { - headers: { Authorization: `Bearer ${token}` }, - }); + let response: Response; + try { + response = await fetch(`${BASE}/api/audit.csv${query}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + } catch { + throw new Error(NETWORK_FAILURE); + } if (!response.ok) throw new Error("Could not export the audit log."); return response.blob(); } diff --git a/app/src/routes/CliAuthorize.tsx b/app/src/routes/CliAuthorize.tsx index c11e9f31..2faf20fa 100644 --- a/app/src/routes/CliAuthorize.tsx +++ b/app/src/routes/CliAuthorize.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Navigate, useLocation } from "react-router-dom"; +import { Link, Navigate, useLocation } from "react-router-dom"; import { Terminal, ShieldCheck } from "@phosphor-icons/react"; import { Wordmark } from "../components/Wordmark"; import { Button } from "../components/Button"; @@ -127,7 +127,8 @@ function Consent({

A terminal on this computer is asking to sign in as{" "} {email}. Once linked, sessions you start with{" "} - shell show up in your account. + shell show up in your account, and everyone on your + team can see them.

{error && ( @@ -145,7 +146,26 @@ function Consent({
Grants
-
Publishing your sessions to this account, with their passwords sealed to your vault
+
+ Publishing this machine’s sessions to your team, with + their passwords sealed to your vault +
+
+ {/* + What publishing means, spelled out. Terms sections "What Is Sent + to the Service" and "Activity Records" are the long form; these + two rows must not promise less than they do. + */} +
+
Your team sees
+
+ Each session’s link, full command line, machine name, + session name and timings +
+
+
+
Recorded
+
What anyone types into a session from a browser
Does not grant
@@ -182,9 +202,16 @@ function Consent({

- Terminal content stays end-to-end encrypted. Only the link, the - command name and the timing are published; passwords reach this - account sealed, and shell.online cannot open them. + Terminal output stays end-to-end encrypted, and passwords reach + this account sealed, so shell.online cannot open them. The link, + command line, machine name and timings are shared with your + team, and what you type from a browser is recorded. Starting + sessions from a browser is a separate choice, made in your + terminal. The{" "} + + terms + {" "} + list everything that is kept.

diff --git a/app/src/routes/Join.tsx b/app/src/routes/Join.tsx index f0e9be7e..e465717b 100644 --- a/app/src/routes/Join.tsx +++ b/app/src/routes/Join.tsx @@ -7,7 +7,7 @@ import { Alert } from "../components/Alert"; import { Booting } from "../components/Booting"; import { useAuth } from "../auth/AuthProvider"; import { usePageTitle } from "../lib/page-title"; -import { fetchOrg, accountsBaseUrl } from "../lib/api"; +import { fetchOrg, accountsBaseUrl, NETWORK_FAILURE } from "../lib/api"; interface Preview { organization: { name: string }; @@ -44,7 +44,7 @@ export function Join() { else setPreview(body as Preview); }) .catch(() => { - if (!cancelled) setError("Could not reach the accounts service."); + if (!cancelled) setError(NETWORK_FAILURE); }); return () => { cancelled = true;