tabs: a machine you can watch, not only type into

A pane shows a machine. Until now there was one way to look at one — a
shell — so the binding that says WHAT a pane shows only ever said which
tmux session. A desktop sandbox has an X server, and nothing here could
show it.

So a binding has a second face. `{kind:'screen', machine}` carries no
name because a machine has one display where it has as many shells as you
ask for, and the machine's own class decides whether it is offered at all
— `desktop` runs an X server, the other classes do not, and a name is
never read as a capability.

ONE MINT FOR BOTH DOORS. The ticket answers the address it opens, so
`frameUrl` asks for a door and frames what the server named, rather than
composing a URL a client could spell wrong. The bearer still never leaves
lib/api; what a pane holds is the minted URL.

One button, one intention: Desktop opens a screen where a desktop is
running and starts one where none is. A disabled button beside a second
button that makes it work would be two controls for one thought.
This commit is contained in:
zooqueen
2026-08-11 00:26:42 -07:00
parent 4a09925faa
commit e67bc8cfa4
8 changed files with 368 additions and 137 deletions
+22 -10
View File
@@ -22,13 +22,13 @@ import {
type SandboxMachine,
type Session,
createSandbox,
frameUrl,
machineName,
machines,
sandboxes,
sandboxTerminal,
sessions,
terminalTicket,
} from '@/lib/api';
import { type Binding } from '@/lib/panes';
import { renew, session, signIn, signOut } from '@/lib/iam';
import { Workspace, type TerminalHost } from '@/components/workspace';
@@ -122,18 +122,30 @@ export default function App() {
// stable, unique machine name.
for (const s of data.b) {
const key = machineName(s);
if (!out.has(key)) out.set(key, { machine: key, sandbox: s.id, status: 'online' });
if (!out.has(key)) {
// `screen` is the machine's own class, not a guess: only a desktop
// sandbox runs an X server, so only a desktop has pixels to frame.
out.set(key, {
machine: key,
sandbox: s.id,
screen: s.class === 'desktop',
status: 'online',
});
}
}
return [...out.values()];
}, [data]);
/** A fresh framed-terminal URL for a sandbox pane — ticket minted here, where
* the token lives, so the workspace stays credential-free. */
/** A fresh URL for a sandbox pane — ticket minted here, where the token
* lives, so the workspace stays credential-free. What the pane SHOWS picks
* the door: a shell attaches to its tmux session by name, a screen has one
* display and needs no name. */
const mint = useCallback(
async (sandbox: string, shell: string) => {
async (sandbox: string, what: Binding) => {
if (!token) throw new Error('signed out');
const ticket = await terminalTicket(token, sandbox);
return sandboxTerminal(sandbox, ticket, shell);
return what.kind === 'screen'
? frameUrl(token, sandbox, 'screen')
: frameUrl(token, sandbox, 'terminal', what.kind === 'shell' ? what.shell.name : undefined);
},
[token],
);
@@ -197,8 +209,8 @@ export default function App() {
// opens a shell on the name it gets, and a pane can only mint a
// ticket for a machine the registry has already handed back, so the
// refresh is what stands between the two.
onLaunch={async () => {
const box = await createSandbox(token);
onLaunch={async (kind) => {
const box = await createSandbox(token, kind);
await refresh(token);
return machineName(box);
}}
+138 -58
View File
@@ -34,7 +34,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Cloud, Columns2, Plus, Rows2, X } from 'lucide-react';
import { Cloud, Columns2, Monitor, Plus, Rows2, X } from 'lucide-react';
import {
type Dir,
@@ -52,10 +52,12 @@ import {
stackFor,
} from '@/lib/tiles';
import {
DEADLINE,
DOT,
TERM_DEADLINE,
type Binding,
isTermReady,
isReady,
label,
machineOf,
mintName,
rescued,
shellUrl,
@@ -66,9 +68,13 @@ export interface TerminalHost {
machine: string;
/** The share URL `hanzo link` published. Absent ⇒ nothing to frame. */
base?: string;
/** A dev sandbox's id. Its terminal URL is MINTED per open (single-use
* ticket) rather than published, so a sandbox host has this and no base. */
/** A sandbox's id. Its URLs are MINTED per open (single-use ticket) rather
* than published, so a sandbox host has this and no base. */
sandbox?: string;
/** Whether this machine has a DISPLAY to watch. It is the machine's own
* answer — a `desktop` sandbox runs an X server and a VNC server, the other
* classes have neither — and never inferred from a name. */
screen?: boolean;
status: string;
label?: string;
}
@@ -98,11 +104,12 @@ export function Workspace({
onLaunch,
}: {
hosts: TerminalHost[];
/** A fresh framed-terminal URL for a sandbox pane. The caller owns the
* credential; the workspace only ever holds the minted, ticket-bearing URL. */
mint?: (sandbox: string, shell: string) => Promise<string>;
/** Start a cloud machine, and answer the name it goes by here. */
onLaunch?: () => Promise<string>;
/** A fresh URL for a sandbox pane, for whatever that pane shows. The caller
* owns the credential; the workspace only ever holds the minted,
* ticket-bearing URL. */
mint?: (sandbox: string, what: Binding) => Promise<string>;
/** Start a cloud machine of one class, and answer the name it goes by here. */
onLaunch?: (kind: 'dev' | 'desktop') => Promise<string>;
}) {
// A machine with no `base` serves no terminal, and there is nothing to open on
// one of those — except a sandbox, whose URL is minted on bind rather than
@@ -111,6 +118,10 @@ export function Workspace({
() => hosts.filter((h) => (h.base || h.sandbox) && h.status !== 'offline'),
[hosts],
);
/** The live machines with a display. A linked machine has none to offer —
* what `hanzo link` publishes is a terminal — so this is the sandboxes that
* were started as desktops. */
const watchable = useMemo(() => live.filter((h) => h.screen), [live]);
const [tile, setTile] = useState<Tile | null>(null);
const [bind, setBind] = useState<Record<string, Binding>>({});
@@ -180,25 +191,36 @@ export function Workspace({
[bind],
);
/** Open a shell in a NEW pane beside `target` — or as the whole layout. */
const open = useCallback(
(machine: string, dir: Dir | null, target: string | null) => {
const id = `p${seq.current++}`;
const name = mintName(takenOn(machine));
setBind((b) => ({ ...b, [id]: { kind: 'shell', shell: { machine, name } } }));
setTile((t) => (t && target && dir ? splitPane(t, target, dir, id) : (t ?? pane(id))));
setFocus(id);
},
/** What a machine looks like in a pane, each way of looking at one. Two
* functions and not a flag, because only one of them has a name to mint. */
const shellOn = useCallback(
(machine: string): Binding => ({
kind: 'shell',
shell: { machine, name: mintName(takenOn(machine)) },
}),
[takenOn],
);
const screenOn = useCallback((machine: string): Binding => ({ kind: 'screen', machine }), []);
/** Start a machine and open its first shell. The point of the button is the
* terminal: a machine that arrives with nothing framed on it is a row in a
* list, and you would have to go and ask for the shell you already asked for. */
const launch = useCallback(async () => {
if (!onLaunch) return;
open(await onLaunch(), focus ? 'row' : null, focus);
}, [onLaunch, open, focus]);
/** Open a NEW pane showing `what`, beside `target` — or as the whole layout. */
const open = useCallback((what: Binding, dir: Dir | null, target: string | null) => {
const id = `p${seq.current++}`;
setBind((b) => ({ ...b, [id]: what }));
setTile((t) => (t && target && dir ? splitPane(t, target, dir, id) : (t ?? pane(id))));
setFocus(id);
}, []);
/** Start a machine and open it. The point of the button is what you get to
* look at: a machine that arrives with nothing framed on it is a row in a
* list, and you would have to go and ask for the thing you already asked for. */
const launch = useCallback(
async (kind: 'dev' | 'desktop') => {
if (!onLaunch) return;
const machine = await onLaunch(kind);
open(kind === 'desktop' ? screenOn(machine) : shellOn(machine), focus ? 'row' : null, focus);
},
[onLaunch, open, focus, shellOn, screenOn],
);
const doClose = useCallback((id: string) => {
setTile((t) => (t ? closePane(t, id) : null));
@@ -303,7 +325,7 @@ export function Workspace({
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (!isTermReady(e.data)) return;
if (!isReady(e.data)) return;
// The sender identifies the pane: a message is trusted only when it comes
// from the window of a frame this workspace actually rendered. The origin
// is not pinned because every machine publishes on its own tunnel host.
@@ -323,13 +345,21 @@ export function Workspace({
if (!el) return;
// Give the terminal a moment to boot before calling it absent — a rescue that
// flashes over every pane on every load is its own defect.
const t = setTimeout(() => setWaited((w) => (w[id] ? w : { ...w, [id]: true })), TERM_DEADLINE);
const t = setTimeout(() => setWaited((w) => (w[id] ? w : { ...w, [id]: true })), DEADLINE);
return () => clearTimeout(t);
}, []);
const refused = useMemo(() => rescued(waited, alive), [waited, alive]);
const [picking, setPicking] = useState<null | { dir: Dir | null; target: string | null }>(null);
// A pick is "which machine", and what to MAKE of the one picked comes with
// the question — so the same picker asks for a shell's machine and a
// screen's, and neither knows about the other.
const [picking, setPicking] = useState<null | {
make: (m: string) => Binding;
from: TerminalHost[];
dir: Dir | null;
target: string | null;
}>(null);
const rendered = useMemo(() => order.filter((id) => bind[id]), [order, bind]);
// Sandbox panes: the URL is minted, not published. Minted per PANE and kept
@@ -343,11 +373,12 @@ export function Workspace({
if (!mint) return;
for (const id of rendered) {
const b = bind[id];
if (!b || b.kind !== 'shell') continue;
const h = hostOf(b.shell.machine);
const m = b && (b.kind === 'shell' || b.kind === 'screen') ? machineOf(b) : null;
if (!b || !m) continue;
const h = hostOf(m);
if (!h?.sandbox || minted[id] || minting.current.has(id)) continue;
minting.current.add(id);
mint(h.sandbox, b.shell.name)
mint(h.sandbox, b)
.then((src) => setMinted((m) => ({ ...m, [id]: src })))
// A failed mint leaves the pane on its "waiting" face; the deadline
// then offers reconnect, which is the retry.
@@ -377,33 +408,66 @@ export function Workspace({
</p>
{/* The header is not rendered in this branch, and someone with no machine
at all is exactly who has nowhere else to get one. */}
{onLaunch ? <Launch run={launch} /> : null}
{onLaunch ? (
<div className="flex items-center gap-1.5 text-xs">
<Launch run={() => launch('dev')} icon={<Cloud className="h-3.5 w-3.5" />} label="New cloud machine" busyLabel="Starting a machine…" />
<Launch run={() => launch('desktop')} icon={<Monitor className="h-3.5 w-3.5" />} label="New desktop" busyLabel="Starting a desktop…" />
</div>
) : null}
</div>
);
}
/** Never make someone choose from a set of one. */
const openHere = (dir: Dir | null, target: string | null) => {
if (live.length === 1) open(live[0]!.machine, dir, target);
else setPicking({ dir, target });
const openHere = (make: (m: string) => Binding, from: TerminalHost[], dir: Dir | null, target: string | null) => {
if (from.length === 1) open(make(from[0]!.machine), dir, target);
else setPicking({ make, from, dir, target });
};
/** A shell goes on any live machine. */
const openShell = (dir: Dir | null, target: string | null) => openHere(shellOn, live, dir, target);
/** A screen goes only where there IS one — and where there is none, the
* button starts the machine that has one. One press, one meaning: show me a
* desktop. */
const openScreen = (dir: Dir | null, target: string | null) =>
watchable.length ? openHere(screenOn, watchable, dir, target) : launch('desktop');
return (
<div className="flex h-full w-full flex-col gap-2">
{/* One row. Actions, not a status report. */}
<div className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<button
type="button"
onClick={() => openHere(focus ? 'row' : null, focus)}
onClick={() => openShell(focus ? 'row' : null, focus)}
className="inline-flex min-h-9 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted"
>
<Plus className="h-3.5 w-3.5" /> New shell
</button>
{onLaunch ? <Launch run={launch} /> : null}
{/* One button for the screen, whether or not a machine with one exists
yet: with a desktop live it opens it, without one it starts it. The
alternative — a disabled button beside a second button that makes it
work — is two controls for one intention. */}
{watchable.length || onLaunch ? (
<Launch
run={async () => openScreen(focus ? 'row' : null, focus)}
icon={<Monitor className="h-3.5 w-3.5" />}
label="Desktop"
busyLabel="Starting a desktop…"
/>
) : null}
{onLaunch ? (
<Launch
run={() => launch('dev')}
icon={<Cloud className="h-3.5 w-3.5" />}
label="New cloud machine"
busyLabel="Starting a machine…"
/>
) : null}
<span className="ml-auto flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => focus && openHere('row', focus)}
onClick={() => focus && openShell('row', focus)}
disabled={!focus}
title="Split right"
className="inline-flex min-h-9 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted disabled:opacity-40"
@@ -413,7 +477,7 @@ export function Workspace({
</button>
<button
type="button"
onClick={() => focus && openHere('col', focus)}
onClick={() => focus && openShell('col', focus)}
disabled={!focus}
title="Split down"
className="inline-flex min-h-9 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted disabled:opacity-40"
@@ -430,7 +494,7 @@ export function Workspace({
{Array.from({ length: pages }, (_, i) => {
const first = geo.rects.find((r) => Math.round(r.left / 100) === i);
const b = first ? bind[first.id] : undefined;
const label = b && b.kind !== 'empty' ? `${b.shell.machine}·${b.shell.name}` : `${i + 1}`;
const name = b ? label(b) : String(i + 1);
return (
<button
key={i}
@@ -443,7 +507,7 @@ export function Workspace({
i === page ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50'
}`}
>
{label}
{name}
</button>
);
})}
@@ -471,16 +535,21 @@ export function Workspace({
if (!r) return null;
const b = bind[id]!;
const on = id === focus;
const shell = b.kind === 'empty' ? null : b.shell;
const host = shell ? hostOf(shell.machine) : undefined;
const machine = machineOf(b);
const host = machine ? hostOf(machine) : undefined;
const title = label(b);
// A sandbox pane frames its minted, ticket-bearing URL; a linked
// machine's pane derives from the published tunnel. `minted` is
// per-pane state because the ticket is spent on first load —
// deriving would re-spend it every render.
//
// A SCREEN IS ALWAYS MINTED. A tunnel publishes a terminal and
// nothing else, so a linked machine has no screen to derive — the
// pane waits, which is the truth about that machine.
const url = host?.sandbox
? (minted[id] ?? null)
: host?.base && shell
? shellUrl(host.base, shell.name)
: host?.base && b.kind === 'shell'
? shellUrl(host.base, b.shell.name)
: null;
// Every rect is page-relative; the track is `pages * 100%` wide, so a
// page occupies `100/pages` of it.
@@ -511,12 +580,12 @@ export function Workspace({
}`}
/>
<span className="truncate">
{shell ? `${shell.machine} · ${shell.name}` : 'New shell'}
{title}
</span>
<span className="ml-auto hidden items-center gap-1 sm:flex">
<button
type="button"
onClick={() => openHere('row', id)}
onClick={() => openShell('row', id)}
title="Split right"
className="inline-flex size-6 items-center justify-center rounded hover:bg-muted-foreground/20"
>
@@ -524,7 +593,7 @@ export function Workspace({
</button>
<button
type="button"
onClick={() => openHere('col', id)}
onClick={() => openShell('col', id)}
title="Split down"
className="inline-flex size-6 items-center justify-center rounded hover:bg-muted-foreground/20"
>
@@ -549,7 +618,7 @@ export function Workspace({
<iframe
data-pane={id}
src={url}
title={shell ? `${shell.machine} · ${shell.name}` : 'terminal'}
title={title}
className="absolute inset-0 h-full w-full bg-black"
// Scripts (a terminal is one), and `allow-same-origin` gives
// the frame ITS OWN origin — which ttyd needs for its socket
@@ -596,8 +665,9 @@ export function Workspace({
// which offers to sign you in to a terminal at `#`.
<div className="absolute inset-0 z-10 flex items-center justify-center px-4 text-center">
<p className="max-w-xs text-xs text-muted-foreground">
Waiting for <span className="text-foreground">{b.shell.machine}</span> to
serve a terminal. It appears here as soon as the machine links.
Waiting for <span className="text-foreground">{machine}</span> to{' '}
{b.kind === 'screen' ? 'show its screen' : 'serve a terminal'}. It appears
here as soon as the machine is up.
</p>
</div>
) : refused[id] ? (
@@ -608,8 +678,8 @@ export function Workspace({
// the sign-in, and opening a dead one teaches nothing.
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black px-4 text-center">
<p className="max-w-xs text-xs text-muted-foreground">
The terminal did not come up. Its ticket lasts thirty seconds —
reconnecting mints a fresh one.
{b.kind === 'screen' ? 'The desktop' : 'The terminal'} did not come up.
Its ticket lasts thirty seconds — reconnecting mints a fresh one.
</p>
<button
type="button"
@@ -690,9 +760,9 @@ export function Workspace({
{picking ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-4">
<Picker
hosts={live}
hosts={picking.from}
onPick={(m) => {
open(m, picking.dir, picking.target);
open(picking.make(m), picking.dir, picking.target);
setPicking(null);
}}
onCancel={() => setPicking(null)}
@@ -713,7 +783,17 @@ export function Workspace({
* plane, and a workspace full of working terminals is not broken because it is
* down.
*/
function Launch({ run }: { run: () => Promise<void> }) {
function Launch({
run,
icon,
label,
busyLabel,
}: {
run: () => Promise<void>;
icon: React.ReactNode;
label: string;
busyLabel: string;
}) {
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState<string | null>(null);
return (
@@ -730,7 +810,7 @@ function Launch({ run }: { run: () => Promise<void> }) {
}}
className="inline-flex min-h-9 shrink-0 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted disabled:opacity-40"
>
<Cloud className="h-3.5 w-3.5" /> {busy ? 'Starting a machine…' : 'New cloud machine'}
{icon} {busy ? busyLabel : label}
</button>
{failed ? <span className="truncate text-muted-foreground">{failed}</span> : null}
</>
+51 -28
View File
@@ -63,6 +63,9 @@ export interface SandboxMachine {
id: string;
status: string;
project?: string;
/** `dev` shells; `desktop` also has an X server, so it has a SCREEN to watch.
* It is the machine's own answer and not a guess from its name. */
class?: string;
}
export const sandboxes = (token: string) =>
@@ -74,32 +77,39 @@ export const sandboxes = (token: string) =>
* keyed on, so it is the name that means the same box tomorrow. */
export const machineName = (s: SandboxMachine) => s.project || `box-${s.id.slice(0, 6)}`;
/** The project a first cloud machine takes; the ones after it count up. */
const PROJECT = 'tabs';
/** What a first cloud machine of each class is called; the ones after it count
* up. Two names because the project keys the DISK and the server keeps one
* live sandbox per project — so a desktop that borrowed the shell machines'
* name would be the same box's second attempt rather than a second box. */
const PROJECT: Record<Class, string> = { dev: 'tabs', desktop: 'desk' };
/** The two machines tabs starts. `dev` is the one you shell into — a toolchain,
* a home, and a disk that outlives the lease; `desktop` is that plus an X
* server, a window manager and a VNC server, so it has a screen. Neither is
* `exec`, the code interpreter's scratch pod, which tabs never opens. */
export type Class = 'dev' | 'desktop';
/**
* Start one, and answer it once it is RUNNING.
*
* `class: 'dev'` is the machine you shell into — a toolchain, a home, and a disk
* that outlives the lease — as against `exec`, which is the code interpreter's
* scratch pod. A dev sandbox is named by its project, and the project is what
* the disk is keyed on: one live sandbox per project is the server's rule, so
* the name is picked against what is already live rather than made up. That is
* what makes a second press a second machine instead of a second attempt at the
* first one.
* A sandbox is named by its project, and the project is what the disk is keyed
* on: one live sandbox per project is the server's rule, so the name is picked
* against what is already live rather than made up. That is what makes a second
* press a second machine instead of a second attempt at the first one.
*
* The lease is the server's to set. Tabs has no opinion about how long a machine
* should live, and a `ttlSec` here would be a second answer to drift from the one
* in the class table.
*/
export const createSandbox = async (token: string): Promise<SandboxMachine> => {
export const createSandbox = async (token: string, kind: Class = 'dev'): Promise<SandboxMachine> => {
const taken = new Set((await sandboxes(token)).map(machineName));
let project = PROJECT;
for (let n = 2; taken.has(project); n++) project = `${PROJECT}-${n}`;
const first = PROJECT[kind];
let project = first;
for (let n = 2; taken.has(project); n++) project = `${first}-${n}`;
const res = await fetch(`${API}/v1/sandboxes`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ class: 'dev', project }),
body: JSON.stringify({ class: kind, project }),
cache: 'no-store',
});
// Starting a machine fails for reasons a person can act on — an image that
@@ -113,23 +123,36 @@ export const createSandbox = async (token: string): Promise<SandboxMachine> => {
return (await res.json()) as SandboxMachine;
};
/** Mint the single-use ticket for a sandbox's terminal. Spent on first use. */
export const terminalTicket = async (token: string, id: string): Promise<string> => {
const res = await fetch(`${API}/v1/sandboxes/${encodeURIComponent(id)}/terminal/ticket`, {
/** The two ways to look at a machine: its shell, or its screen. Same sandbox,
* same credential, two pages — see cloud's apps/sandbox. */
export type Door = 'terminal' | 'screen';
/**
* Mint a single-use ticket and answer the page to frame.
*
* The URL is the SERVER'S, not composed here: the mint answers the path its own
* ticket opens, so a client cannot spell a door's address wrong or send a
* ticket somewhere it does not work. All this adds is the host it was already
* talking to, and `arg` — the tmux session a terminal attaches to, which a
* screen has no use for because a machine has one display.
*
* The ticket is spent by the first load and lasts thirty seconds, so this is
* called once per pane per connection, never per render. The bearer never
* leaves this module: what the workspace holds is the minted URL.
*/
export const frameUrl = async (
token: string,
id: string,
door: Door,
arg?: string,
): Promise<string> => {
const res = await fetch(`${API}/v1/sandboxes/${encodeURIComponent(id)}/${door}/ticket`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) throw new Error(`terminal ticket → ${res.status}`);
const body = (await res.json()) as { ticket?: string };
if (!body.ticket) throw new Error('terminal ticket → empty answer');
return body.ticket;
if (!res.ok) throw new Error(`${door} ticket → ${res.status}`);
const body = (await res.json()) as { url?: string };
if (!body.url) throw new Error(`${door} ticket → empty answer`);
return `${API}${body.url}` + (arg ? `&arg=${encodeURIComponent(arg)}` : '');
};
/** The framed terminal's address — ticket in the URL, bearer never. */
export function sandboxTerminal(id: string, ticket: string, name: string): string {
return (
`${API}/v1/sandboxes/${encodeURIComponent(id)}/terminal` +
`?ticket=${encodeURIComponent(ticket)}&arg=${encodeURIComponent(name)}`
);
}
+53 -13
View File
@@ -28,12 +28,44 @@ export interface Shell {
export type Binding =
| { kind: 'shell'; shell: Shell }
/** The machine's SCREEN — its X display, as pixels. No name, because a
* machine has one display where it has as many shells as you ask for. */
| { kind: 'screen'; machine: string }
/** Split made, machine not chosen yet. */
| { kind: 'empty' }
/** The machine went away. The pane STAYS, because tmux is still holding the
* session and the layout is the reader's, not a function of someone's uptime. */
| { kind: 'gone'; shell: Shell };
/** What a pane calls itself: the machine, then which of its faces this is.
* One function, so the header, the frame's title and the phone's pager cannot
* disagree about what a person is looking at. */
export function label(b: Binding): string {
switch (b.kind) {
case 'shell':
return `${b.shell.machine} · ${b.shell.name}`;
case 'gone':
return `${b.shell.machine} · ${b.shell.name}`;
case 'screen':
return `${b.machine} · desktop`;
default:
return 'New shell';
}
}
/** The machine a pane is bound to, whichever way it is looking at it. */
export function machineOf(b: Binding): string | null {
switch (b.kind) {
case 'shell':
case 'gone':
return b.shell.machine;
case 'screen':
return b.machine;
default:
return null;
}
}
/** The default tmux session a machine's first pane opens — matches the CLI's. */
export const DEFAULT_SHELL = 'hanzo';
@@ -89,12 +121,20 @@ export const DOT: Record<string, string> = {
};
/**
* The terminal readiness contract — BOTH halves, in one place.
* The readiness contract — BOTH halves, in one place.
*
* `TERM_READY` is the `source` our terminal page posts to its parent once xterm
* has opened (hanzo/cli `assets/term/client.js`). It lives here rather than in
* the component because it is a wire value shared with another repository: one
* side changing it silently is a workspace where every pane looks dead.
* `READY` are the `source` values our framed pages post to their parent once
* they are up: the terminal's when xterm has opened (hanzo/cli
* `assets/term/client.js`, cloud `apps/sandbox/term/page.html`), the screen's
* when noVNC has connected (cloud `apps/sandbox/screen/page.html`). They live
* here rather than in the component because they are wire values shared with
* another repository: one side changing one silently is a workspace where every
* pane of that kind looks dead.
*
* Two names and one meaning, on purpose. Each page says WHAT it is, because
* that costs nothing and a page that lied about itself would be worse; what a
* parent asks is only whether the thing it framed came up, so the question has
* one answer for both.
*
* The rule this enables is small and the reason for it is not. A parent cannot
* tell a healthy cross-origin frame from a refused one — the browser substitutes
@@ -103,19 +143,19 @@ export const DOT: Record<string, string> = {
* gate, a `frame-ancestors` refusal, a dead tunnel, an offline machine) collapses
* to the same silence and the same answer.
*/
export const TERM_READY = 'hanzo-term';
export const READY = ['hanzo-term', 'hanzo-screen'];
/** How long a pane waits to hear from its terminal before offering the way out.
* Long enough that a rescue never flashes over a terminal that is merely still
* connecting, which would be its own defect. */
export const TERM_DEADLINE = 6000;
/** How long a pane waits to hear from what it framed before offering the way
* out. Long enough that a rescue never flashes over a terminal that is merely
* still connecting, which would be its own defect. */
export const DEADLINE = 6000;
/** Whether a posted message is our terminal announcing itself. */
export function isTermReady(data: unknown): boolean {
/** Whether a posted message is one of our pages announcing itself. */
export function isReady(data: unknown): boolean {
return (
typeof data === 'object' &&
data !== null &&
(data as { source?: unknown }).source === TERM_READY
READY.includes((data as { source?: unknown }).source as string)
);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@hanzo/tabs",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hanzo/tabs",
"version": "0.1.0",
"version": "0.1.1",
"license": "MIT",
"dependencies": {
"@hanzo/design": "^0.4.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/tabs",
"version": "0.1.0",
"version": "0.1.1",
"private": false,
"license": "MIT",
"description": "Keep tabs on your agents. A browser terminal workspace for machines you have linked.",
+39 -12
View File
@@ -1,9 +1,11 @@
import {
DEADLINE,
DEFAULT_SHELL,
DOT,
TERM_DEADLINE,
TERM_READY,
isTermReady,
READY,
isReady,
label,
machineOf,
mintName,
rescued,
safeName,
@@ -81,15 +83,18 @@ describe('a machine’s next shell has a name nobody has to invent', () => {
* blocked terminal read as fine, and the user got an opaque black rectangle with
* the way out hidden underneath it.
*/
describe('a terminal announces itself, and silence is the rescue', () => {
it('recognises our terminal and nothing else', () => {
expect(isTermReady({ source: TERM_READY, ready: true })).toBe(true);
describe('a framed page announces itself, and silence is the rescue', () => {
it('recognises our pages and nothing else', () => {
// Both of them: a terminal and a screen fail the same way and are proved
// the same way, so one predicate answers for both.
for (const source of READY) expect(isReady({ source, ready: true })).toBe(true);
expect(READY).toEqual(['hanzo-term', 'hanzo-screen']);
// Everything a hostile or unrelated frame might post.
expect(isTermReady({ source: 'other-app' })).toBe(false);
expect(isTermReady({ ready: true })).toBe(false);
expect(isTermReady('hanzo-term')).toBe(false);
expect(isTermReady(null)).toBe(false);
expect(isTermReady(undefined)).toBe(false);
expect(isReady({ source: 'other-app' })).toBe(false);
expect(isReady({ ready: true })).toBe(false);
expect(isReady('hanzo-term')).toBe(false);
expect(isReady(null)).toBe(false);
expect(isReady(undefined)).toBe(false);
});
it('leaves a pane alone until its terminal has had time to boot', () => {
@@ -110,6 +115,28 @@ describe('a terminal announces itself, and silence is the rescue', () => {
});
it('waits long enough that a slow connect is not called dead', () => {
expect(TERM_DEADLINE).toBeGreaterThanOrEqual(3000);
expect(DEADLINE).toBeGreaterThanOrEqual(3000);
});
});
/**
* A pane shows a machine, one of two ways: its shell or its screen. Both are
* bound to a machine and neither is a special case of the other — which is the
* whole reason the binding says which it is instead of the URL implying it.
*/
describe('a pane bound to a screen is still bound to a machine', () => {
it('names the machine whichever face it is showing', () => {
expect(machineOf({ kind: 'shell', shell: { machine: 'desk', name: 'hanzo' } })).toBe('desk');
expect(machineOf({ kind: 'screen', machine: 'desk' })).toBe('desk');
expect(machineOf({ kind: 'gone', shell: { machine: 'desk', name: 'hanzo' } })).toBe('desk');
// A split with nothing chosen yet is bound to nothing, and saying so is what
// stops a mint being attempted for it.
expect(machineOf({ kind: 'empty' })).toBeNull();
});
it('says what it is showing, so the header and the frame agree', () => {
expect(label({ kind: 'shell', shell: { machine: 'desk', name: 'build' } })).toBe('desk · build');
expect(label({ kind: 'screen', machine: 'desk' })).toBe('desk · desktop');
expect(label({ kind: 'empty' })).toBe('New shell');
});
});
+62 -13
View File
@@ -1,29 +1,58 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { API, createSandbox, machineName, sandboxTerminal } from '@/lib/api';
import { API, createSandbox, frameUrl, machineName } from '@/lib/api';
/**
* A sandbox joins the workspace as a machine whose URL is MINTED, not
* published — the one terminal contract every Hanzo surface frames
* (/v1/sandboxes/:id/terminal, single-use thirty-second ticket). These pin the
* two properties that would fail silently: the URL's only credential is the
* ticket, and a pane never mints twice for one bind.
* A sandbox joins the workspace as a machine whose URLs are MINTED, not
* published — the one contract every Hanzo surface frames (a single-use
* thirty-second ticket, and the page the mint names). These pin the properties
* that would fail silently: the URL's only credential is the ticket, the door
* asked for is the door opened, and a pane never mints twice for one bind.
*/
describe('the framed terminal URL', () => {
it('carries the ticket and the shell name, both encoded, and nothing else', () => {
const u = new URL(sandboxTerminal('box 1/x', 'tk+&=', 'build'));
describe('the framed URL', () => {
const mint = (url: string, ok = true) => {
const calls: { url: string; init?: RequestInit }[] = [];
global.fetch = (async (u: string, init?: RequestInit) => {
calls.push({ url: String(u), init });
return { ok, status: ok ? 201 : 409, json: async () => ({ url }) };
}) as unknown as typeof fetch;
return calls;
};
it('asks the door it wants and frames the address that door answered', async () => {
const calls = mint('/v1/sandboxes/box%201/terminal?ticket=tk%2B');
const got = await frameUrl('bearer', 'box 1', 'terminal', 'build');
expect(calls[0]!.url).toBe(`${API}/v1/sandboxes/box%201/terminal/ticket`);
expect(calls[0]!.init!.method).toBe('POST');
const u = new URL(got);
expect(u.origin).toBe(new URL(API).origin);
expect(u.pathname).toBe('/v1/sandboxes/box%201%2Fx/terminal');
expect(u.searchParams.get('ticket')).toBe('tk+&=');
expect(u.pathname).toBe('/v1/sandboxes/box%201/terminal');
expect(u.searchParams.get('ticket')).toBe('tk+');
expect(u.searchParams.get('arg')).toBe('build');
expect([...u.searchParams.keys()].sort()).toEqual(['arg', 'ticket']);
});
it('never carries a bearer — the ticket IS the credential', () => {
const u = sandboxTerminal('b', 't', 's');
it('mints a SCREEN at the screen door, and gives it no shell name to attach to', async () => {
// A machine has one display. An `arg` here would be a tmux session name sent
// to something that has no tmux.
const calls = mint('/v1/sandboxes/b/screen?ticket=tk');
const got = await frameUrl('bearer', 'b', 'screen');
expect(calls[0]!.url).toBe(`${API}/v1/sandboxes/b/screen/ticket`);
expect(new URL(got).pathname).toBe('/v1/sandboxes/b/screen');
expect([...new URL(got).searchParams.keys()]).toEqual(['ticket']);
});
it('never carries a bearer — the ticket IS the credential', async () => {
mint('/v1/sandboxes/b/terminal?ticket=tk');
const u = await frameUrl('a-real-looking-bearer-token', 'b', 'terminal', 's');
expect(u).not.toMatch(/token|bearer|authorization/i);
});
it('refuses to frame anything when the mint refuses', async () => {
mint('/v1/sandboxes/b/screen?ticket=tk', false);
await expect(frameUrl('t', 'b', 'screen')).rejects.toThrow('screen ticket → 409');
});
});
describe('the workspace mints once per pane', () => {
@@ -36,6 +65,13 @@ describe('the workspace mints once per pane', () => {
expect(src).toContain('minting.current.has(id)');
});
it('a screen is always minted, never derived from a published tunnel', () => {
// `hanzo link` publishes a terminal and nothing else, so a screen has no
// base to derive from. Deriving one would frame a terminal and call it a
// desktop.
expect(src).toContain("host?.base && b.kind === 'shell'");
});
it('reconnect forgets the spent URL AND the readiness history', () => {
expect(src).toContain('setMinted(drop)');
expect(src).toContain('setAlive(drop)');
@@ -88,6 +124,19 @@ describe('a new cloud machine', () => {
expect(JSON.parse(String(post.init!.body))).toEqual({ class: 'dev', project: 'tabs' });
});
it('asks for a desktop under its OWN name — the project is what the disk is keyed on', async () => {
// Borrowing the shell machines' project would be the same box's second
// attempt (one live sandbox per project), not a second box.
answer([{ id: 'b1', status: 'running', project: 'tabs' }], {
id: 'b2',
status: 'running',
project: 'desk',
});
await createSandbox('t', 'desktop');
const post = calls.find((c) => c.init?.method === 'POST')!;
expect(JSON.parse(String(post.init!.body))).toEqual({ class: 'desktop', project: 'desk' });
});
it('counts past the boxes already live, so pressing twice makes two', async () => {
answer(
[