From 46be4872152e1bddc46c83d0c1bdf1b5442eab56 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Fri, 11 Sep 2026 14:29:20 -0700 Subject: [PATCH 1/3] Point the README's Homebrew command at the real tap `brew install TeoSlayer/shell-online/shell-online` looks for the repository TeoSlayer/homebrew-shell-online, which does not exist, so the command the GitHub README offers fails on every machine. Use the three lines the landing page and llms.txt already show: tap this repository by URL, trust it, install. --- CHANGELOG.md | 3 +++ README.md | 9 +++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f62d53..7185c088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ 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. ## [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). From 6eecb6a540a07bba32922feecc3bf1000d9d8865 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Fri, 11 Sep 2026 14:29:20 -0700 Subject: [PATCH 2/3] Say what happened when a request fails The network error interpolated the accounts base URL, which is empty in every deployment, so any dropped request read "Could not reach the accounts service at . Is it running?". When the edge answered with an HTML error page, the raw JSON.parse message reached the screen instead. Name shell.online and the reader's connection, and turn an unreadable response into "Something went wrong on our side." The invite page and the audit export use the same sentence. --- CHANGELOG.md | 3 +++ app/src/lib/api.test.ts | 52 +++++++++++++++++++++++++++++++++++++++++ app/src/lib/api.ts | 37 ++++++++++++++++++++++------- app/src/routes/Join.tsx | 4 ++-- 4 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 app/src/lib/api.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7185c088..633b2133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve - 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. ## [0.11.3] — 2026-09-11 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/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; From feaa405cf189da3cdeca85d7aa522ecafaa95773 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Fri, 11 Sep 2026 14:29:20 -0700 Subject: [PATCH 3/3] State what linking a machine shares with the team The consent page said only the link, the command name and the timing are published. The Terms list the full command line, host name, session name, flags, exit code and typed input, all visible to every member of the team. Spell that out in the rows and the note, say that remote start is a separate choice made in the terminal, and link the Terms. --- CHANGELOG.md | 4 ++++ app/src/routes/CliAuthorize.tsx | 39 ++++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 633b2133..5c7659da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve - 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/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.