tabs: sandboxes are machines — the builder's pods join the workspace

Third machine source beside linked tunnels and visor boxes: dev sandboxes,
framed through the one terminal contract every Hanzo surface uses
(/v1/sandboxes/:id/terminal, single-use thirty-second ticket, ?arg= names
the tmux session exactly as ttyd's --url-arg does). A sandbox host carries
an id instead of a published base; the workspace mints the URL when a pane
binds — once per pane, because the frame's first load spends the ticket —
and a refused pane offers Reconnect (a fresh mint), never the sign-in tab,
which for a spent ticket would open a dead door.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-08 16:29:54 -07:00
co-authored by Hanzo Dev
parent 7c878a23bd
commit 35c7194fc2
4 changed files with 205 additions and 6 deletions
+36 -3
View File
@@ -20,11 +20,15 @@ import Link from 'next/link';
import {
type CloudMachine,
type Machine,
type SandboxMachine,
type Session,
cloudMachines,
launchCloudMachine,
machines,
sandboxes,
sandboxTerminal,
sessions,
terminalTicket,
} from '@/lib/api';
import { renew, session, signIn, signOut } from '@/lib/iam';
import { withCloud } from '@/lib/panes';
@@ -33,7 +37,12 @@ import { Workspace, type TerminalHost } from '@/components/workspace';
export default function App() {
const [token, setToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [data, setData] = useState<{ m: Machine[]; s: Session[]; c: CloudMachine[] } | null>(null);
const [data, setData] = useState<{
m: Machine[];
s: Session[];
c: CloudMachine[];
b: SandboxMachine[];
} | null>(null);
const [error, setError] = useState<string | null>(null);
// The session this browser already holds, renewed if the access token has
@@ -57,12 +66,15 @@ export default function App() {
// else. It is caught here rather than reported here because the launch
// button is where a broken visor is worth saying out loud — a banner about
// a plane you may not be using is noise on a workspace that still works.
const [m, s, c] = await Promise.all([
const [m, s, c, b] = await Promise.all([
machines(t),
sessions(t),
cloudMachines(t).catch(() => []),
// The builder's pods. A third plane with the same posture as visor:
// its absence costs you those panes and nothing else.
sandboxes(t).catch(() => []),
]);
setData({ m, s, c });
setData({ m, s, c, b });
setError(null);
} catch (e) {
// A registry that cannot be read is reported as such. Rendering an empty
@@ -109,12 +121,32 @@ export default function App() {
for (const [host, url] of base) {
if (!out.has(host)) out.set(host, { machine: host, base: url, status: 'online' });
}
// The builder's sandboxes. A sandbox has no tunnel to publish — its
// terminal URL is MINTED per open (single-use ticket), so the host carries
// the sandbox id instead of a base and the workspace mints when a pane
// binds. One live sandbox per project is the server's rule, so the project
// name is a stable, unique machine name.
for (const s of data.b) {
const key = s.project || `box-${s.id.slice(0, 6)}`;
if (!out.has(key)) out.set(key, { machine: key, sandbox: s.id, status: 'online' });
}
// A box we launched exists to visor before it exists to the registry. It is
// merged in for that gap only, and stops being a special case the moment its
// own `hanzo link` registers it above.
return withCloud([...out.values()], data.c, (machine, status) => ({ machine, status }));
}, [data]);
/** A fresh framed-terminal URL for a sandbox pane — ticket minted here, where
* the token lives, so the workspace stays credential-free. */
const mint = useCallback(
async (sandbox: string, shell: string) => {
if (!token) throw new Error('signed out');
const ticket = await terminalTicket(token, sandbox);
return sandboxTerminal(sandbox, ticket, shell);
},
[token],
);
if (!token) {
return (
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center px-6">
@@ -169,6 +201,7 @@ export default function App() {
{data ? (
<Workspace
hosts={hosts}
mint={mint}
onLaunch={() => launchCloudMachine(token).then(() => refresh(token))}
/>
) : (
+75 -3
View File
@@ -67,6 +67,9 @@ 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. */
sandbox?: string;
status: string;
label?: string;
}
@@ -92,17 +95,22 @@ function load(): Saved | null {
export function Workspace({
hosts,
mint,
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>;
onLaunch?: () => Promise<void>;
}) {
// A box we launched is admitted before it has a tunnel. Without that it would be
// invisible for the minute it takes to boot and link, and a launch you cannot
// see is a button that looks broken. Everything else with no `base` is a machine
// serving no terminal, and there is nothing to open on one of those.
// serving no terminal, and there is nothing to open on one of those — except a
// sandbox, whose URL is minted on bind rather than published.
const live = useMemo(
() => hosts.filter((h) => (h.base || isPending(h.status)) && h.status !== 'offline'),
() => hosts.filter((h) => (h.base || h.sandbox || isPending(h.status)) && h.status !== 'offline'),
[hosts],
);
@@ -318,6 +326,42 @@ export function Workspace({
const [picking, setPicking] = useState<null | { 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
// until that pane reconnects — a ticket is spent by the frame's first load,
// so re-deriving the URL on render would hand the iframe a dead credential.
// In-flight ids are tracked outside state so a re-render mid-mint cannot
// start a second mint for the same pane (two tickets, one wasted).
const [minted, setMinted] = useState<Record<string, string>>({});
const minting = useRef<Set<string>>(new Set());
useEffect(() => {
if (!mint) return;
for (const id of rendered) {
const b = bind[id];
if (!b || b.kind !== 'shell') continue;
const h = hostOf(b.shell.machine);
if (!h?.sandbox || minted[id] || minting.current.has(id)) continue;
minting.current.add(id);
mint(h.sandbox, b.shell.name)
.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.
.catch(() => {})
.finally(() => minting.current.delete(id));
}
}, [rendered, bind, hostOf, mint, minted]);
/** Reconnect a sandbox pane: forget the spent URL and the frame's history so
* the mint effect runs again with a fresh ticket and the deadline re-arms. */
const reconnect = useCallback((id: string) => {
const drop = <T,>(o: Record<string, T>): Record<string, T> => {
const { [id]: _gone, ...rest } = o;
return rest;
};
setMinted(drop);
setAlive(drop);
setWaited(drop);
}, []);
if (live.length === 0 && !tile) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 text-center">
@@ -423,7 +467,15 @@ export function Workspace({
const on = id === focus;
const shell = b.kind === 'empty' ? null : b.shell;
const host = shell ? hostOf(shell.machine) : undefined;
const url = host?.base && shell ? shellUrl(host.base, shell.name) : null;
// 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.
const url = host?.sandbox
? (minted[id] ?? null)
: host?.base && shell
? shellUrl(host.base, shell.name)
: null;
// Every rect is page-relative; the track is `pages * 100%` wide, so a
// page occupies `100/pages` of it.
const left = paging ? r.left / pages : r.left;
@@ -543,6 +595,25 @@ export function Workspace({
</p>
</div>
) : refused[id] ? (
host?.sandbox ? (
// A sandbox frame that never said ready is a spent or
// expired ticket — the URL cannot be reopened, only
// re-minted. Never the sign-in tab here: the ticket IS
// 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.
</p>
<button
type="button"
onClick={() => reconnect(id)}
className="inline-flex min-h-11 items-center rounded-md border border-border px-4 text-sm text-foreground hover:bg-muted"
>
Reconnect
</button>
</div>
) : (
<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">
This terminal needs a one-time sign-in on its own domain. The gate refuses
@@ -557,6 +628,7 @@ export function Workspace({
Sign in to this terminal ↗
</a>
</div>
)
) : null}
</div>
</div>
+43
View File
@@ -107,3 +107,46 @@ export const launchCloudMachine = (token: string, name?: string) =>
method: 'POST',
body: JSON.stringify({ kind: 'tab', name: name || undefined }),
});
/**
* Dev sandboxes — the third kind of machine, and the ONE terminal contract.
*
* The builder's pods (hanzo.app, console.hanzo.ai) serve the same framed
* terminal every other surface uses: cloud hosts the whole emulator page at
* /v1/sandboxes/:id/terminal, and the URL's only credential is a single-use,
* thirty-second ticket. So a sandbox joins this workspace with no new pane
* kind — it is a machine whose terminal URL is MINTED per open instead of
* published by `hanzo link`, and `?arg=` names the tmux session exactly as a
* linked machine's ttyd does.
*/
export interface SandboxMachine {
id: string;
status: string;
project?: string;
}
export const sandboxes = (token: string) =>
read<{ sandboxes?: SandboxMachine[] }>('/v1/sandboxes?status=running', token).then((r) =>
(r.sandboxes ?? []).filter((s) => s.status === 'running'),
);
/** 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`, {
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;
};
/** 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)}`
);
}
+51
View File
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { API, sandboxTerminal } 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.
*/
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'));
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.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');
expect(u).not.toMatch(/token|bearer|authorization/i);
});
});
describe('the workspace mints once per pane', () => {
const src = readFileSync(join(process.cwd(), 'components/workspace.tsx'), 'utf8');
it('stores the minted URL as pane state, never re-derives it per render', () => {
// A ticket is spent by the frame's first load; deriving the URL on render
// would hand the iframe a dead credential on the next paint.
expect(src).toContain('minted[id] ?? null');
expect(src).toContain('minting.current.has(id)');
});
it('reconnect forgets the spent URL AND the readiness history', () => {
expect(src).toContain('setMinted(drop)');
expect(src).toContain('setAlive(drop)');
expect(src).toContain('setWaited(drop)');
});
it('a refused sandbox pane offers a re-mint, never the sign-in tab', () => {
// The rescue branches on the host KIND first: a sandbox's dead URL cannot
// be reopened in a tab, only re-minted.
expect(src).toMatch(/refused\[id\] \? \(\s*host\?\.sandbox \?/);
expect(src).toMatch(/refused\[id\] \?[\s\S]*?Reconnect/);
});
});