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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
52 changes: 52 additions & 0 deletions app/src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -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, "<html><body>Bad gateway</body></html>");
await expect(fetchDevices()).rejects.toThrow(SERVER_FAILURE);
});

it("treats an unreadable success as a failure rather than returning it", async () => {
respond(200, "<html></html>");
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: [] });
});
});
37 changes: 29 additions & 8 deletions app/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
const user = auth.currentUser;
if (!user) throw new ApiError("You are signed out. Sign in and try again.");
Expand All @@ -255,15 +262,24 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
},
});
} 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<string, unknown>) : {};
let body: Record<string, unknown> = {};
if (text) {
try {
body = JSON.parse(text) as Record<string, unknown>;
} 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;
}
Expand Down Expand Up @@ -414,9 +430,14 @@ export async function downloadAuditCsv(sessionId?: string): Promise<Blob> {
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();
}
39 changes: 33 additions & 6 deletions app/src/routes/CliAuthorize.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -127,7 +127,8 @@ function Consent({
<p>
A terminal on this computer is asking to sign in as{" "}
<b>{email}</b>. Once linked, sessions you start with{" "}
<code>shell</code> show up in your account.
<code>shell</code> show up in your account, and everyone on your
team can see them.
</p>

{error && (
Expand All @@ -145,7 +146,26 @@ function Consent({
</div>
<div className="consent-row">
<dt>Grants</dt>
<dd>Publishing your sessions to this account, with their passwords sealed to your vault</dd>
<dd>
Publishing this machine&rsquo;s sessions to your team, with
their passwords sealed to your vault
</dd>
</div>
{/*
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.
*/}
<div className="consent-row">
<dt>Your team sees</dt>
<dd>
Each session&rsquo;s link, full command line, machine name,
session name and timings
</dd>
</div>
<div className="consent-row">
<dt>Recorded</dt>
<dd>What anyone types into a session from a browser</dd>
</div>
<div className="consent-row">
<dt>Does not grant</dt>
Expand Down Expand Up @@ -182,9 +202,16 @@ function Consent({
<p className="consent-note">
<ShieldCheck size={14} weight="bold" />
<span>
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{" "}
<Link to="/terms" target="_blank" rel="noreferrer">
terms
</Link>{" "}
list everything that is kept.
</span>
</p>
</>
Expand Down
4 changes: 2 additions & 2 deletions app/src/routes/Join.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand Down
Loading