mirror of
https://github.com/hanzo-apps/tabs.git
synced 2026-09-15 19:32:29 +00:00
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:
+36
-3
@@ -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))}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user