Files
zeekayandHanzo Dev 1b2abd099f The workspace has one home and more than one host
tabs.hanzo.ai is one host for it. The desktop shell is another, and
hanzo.ai a third, and the alternative to exporting it is three pane trees
that look alike — a second set of answers to what a split does, which
then drift.

Almost nothing had to change, because the component was already pure: it
takes `hosts` and `mint` as props, holds no token and calls no server. So
this is packaging. Its own `@/lib` imports become relative, since a
consumer has no such alias; the control plane's origin becomes a
parameter defaulting to where the web app already pointed, so a host aims
it at whichever cloud its identity belongs to without module state a
second host must remember to set; and `index.ts` names what a host
mounts.

Source is published rather than a build. Both hosts compile TypeScript,
so a build step here would be a second toolchain making something neither
needs and one more thing to be stale. React, @hanzo/ui, @hanzo/gui and
lucide are peers for the same reason a shell must not get a second copy
of a context in one tree.

Two things were already broken on this branch and are fixed with it.
@hanzo/ui hard-imports react-hook-form, which it declares an OPTIONAL
peer — a bundler resolves it statically either way, so the module has to
be there and `next build` failed on every path without it. And Next 16
builds with Turbopack by default, which dies here with a worker error
while webpack compiles in 9.6s; Next's own message asks callers to name
the bundler, so the script names the one that works.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-14 20:57:21 -07:00

266 lines
11 KiB
TypeScript

/**
* The control plane, read from the browser.
*
* Tabs has no backend of its own — deliberately. The machines are already
* registered with api.hanzo.ai by `hanzo link`, and the terminals are already
* served by those machines over their own tunnels. A server here would be a third
* party to a conversation that has two, and one more thing to keep running.
*/
/**
* Where the control plane answers.
*
* A DEFAULT, not a constant, because this module is read by two hosts now: the
* web app at tabs.hanzo.ai, and the desktop shell, which points at whichever
* cloud its identity belongs to. Every function here already takes a token, so
* it takes the origin the same way — a parameter with this as its default,
* rather than module state a second host would have to remember to set before
* the first call.
*
* `process.env` is read defensively: a bundler that does not define it leaves
* the identifier undefined rather than an empty object, and reading a property
* off that throws at module load.
*/
export const API =
(typeof process !== 'undefined' ? process.env?.NEXT_PUBLIC_HANZO_API : undefined) ??
'https://api.hanzo.ai';
export interface Machine {
id: string;
label: string;
host?: string;
status: string;
capacity?: string;
}
export interface Session {
id: string;
host?: string;
status: string;
terminal?: string;
cwd?: string;
agent?: string;
updatedAt?: string;
}
/**
* A refusal, carrying the platform's own word for it.
*
* `code` is what the caller can BRANCH on — being out of credit wants a
* different answer from a full node, and telling them apart by reading the
* sentence would break the first time the sentence is reworded.
*/
export class Refusal extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
this.name = 'Refusal';
}
}
/** Out of credit, or past the org's spend cap. Both are answered the same way —
* put money on it, or bring a machine of your own — so they are one question. */
export const unfunded = (e: unknown): boolean =>
e instanceof Refusal && (e.code === 'insufficient_balance' || e.code === 'spend_cap_exceeded');
/**
* Read a refusal off a response, in EITHER shape the fleet sends.
*
* The money wire answers `{error:{code,message}}` — an OBJECT — while other
* endpoints answer `{error:"..."}`. Reading only the flat one coerced the nested
* object into a string, so the one refusal a person can actually act on
* rendered as `[object Object]` beside a button that had just failed.
*/
async function refusal(res: Response, fallback: string): Promise<Refusal> {
const body = (await res.json().catch(() => null)) as
| { error?: string | { code?: string; message?: string } }
| null;
const e = body?.error;
return typeof e === 'object' && e !== null
? new Refusal(res.status, e.code ?? '', e.message || fallback)
: new Refusal(res.status, '', e || fallback);
}
async function read<T>(path: string, token: string, origin = API): Promise<T> {
const res = await fetch(`${origin}${path}`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) throw new Error(`${path} → ${res.status}`);
return (await res.json()) as T;
}
export const machines = (token: string, origin = API) =>
read<{ targets: Machine[] }>('/v1/agents/targets', token, origin).then((r) => r.targets ?? []);
export const sessions = (token: string, origin = API) =>
read<{ sessions: Session[] }>('/v1/agents/sessions?limit=200', token, origin).then(
(r) => r.sessions ?? [],
);
/**
* Sandboxes — the machines we start for you, rather than the ones you linked.
*
* 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.
*
* It is the SAME plane as the registry above, which is why there is no second
* base URL here. A cloud machine used to be a DigitalOcean droplet ordered
* through visor: a second provisioning plane, a second failure mode, and a
* minute of boot and `hanzo link` before it could serve a shell. A sandbox is a
* pod, so the request that asks for one comes back when it is already running.
*/
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, origin = API) =>
read<{ sandboxes?: SandboxMachine[] }>('/v1/sandboxes?status=running', token, origin).then((r) =>
(r.sandboxes ?? []).filter((s) => s.status === 'running'),
);
/** How a sandbox names itself in the workspace. Its project is what its DISK is
* keyed on, so it is the name that means the same box tomorrow. */
export const machineName = (s: SandboxMachine) => s.project || `cloud-${s.id.slice(0, 6)}`;
/** What a machine of each class is called, before its number. It says what the
* machine IS — one you shell into, one with a screen — where the name used to
* say which product opened it, so a workspace of three read `tabs`, `tabs-2`,
* `tabs-3` and told you nothing.
*
* Two stems, not one, because the project keys the DISK and machines are
* counted against the ones RUNNING: a stopped `cloud-2` still owns its disk, so
* a desktop later handed that name would mount a shell machine's home and call
* it a screen. */
const STEM: Record<Class, string> = { dev: 'cloud', 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.
*
* 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,
kind: Class = 'dev',
origin = API,
): Promise<SandboxMachine> => {
const taken = new Set((await sandboxes(token)).map(machineName));
// Every machine carries its number, the first one included: `cloud` beside
// `cloud-2` reads as a different kind of thing rather than the one before it.
// The bound is the pigeonhole — among n machines, one of n+1 names is free.
const stem = STEM[kind];
let project = `${stem}-1`;
for (let n = 2; taken.has(project); n++) project = `${stem}-${n}`;
const res = await fetch(`${origin}/v1/sandboxes`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ class: kind, project }),
cache: 'no-store',
});
// Starting a machine fails for reasons a person can act on — no credit, an
// image that will not pull, a project already held, an org at its ceiling —
// and the server says which. A bare status code next to the button would name
// none of them, and the CODE is what lets the pane offer the right remedy.
if (!res.ok) throw await refusal(res, `new machine → ${res.status}`);
return (await res.json()) as SandboxMachine;
};
/**
* Hand a linked machine's tunnel the identity this browser already holds.
*
* A published terminal is a shell, so `hanzo link` puts its tunnel behind the
* org's identity provider. The tunnel used to learn who you are the only way it
* could — by redirecting to hanzo.id — and hanzo.id will not be framed, quite
* rightly, because a login page inside someone else's document is clickjacking.
* So a pane could not open the terminal of a machine belonging to the very
* person looking at it: the redirect had to leave the frame, and the answer was
* a second sign-in for a session that already existed.
*
* This is that redirect, skipped. The tunnel is asked once, with the token this
* page is already using to read the registry, and answers with the session it
* would have set at the end of the round trip. One identity, one sign-in, no
* tab. The bearer stays in this module; what the workspace gets is a URL.
*
* `credentials: 'include'` because the whole point is the cookie that comes
* back, and `share.hanzo.ai` is a sibling of this page, not a third party.
*/
export const grant = async (token: string, base: string): Promise<void> => {
const res = await fetch(new URL('/.well-known/zrok/session', base).toString(), {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
// Never chase it: a tunnel that answers with a redirect is one that does not
// know how to be asked, and following it lands on the sign-in page whose
// absence is the point.
redirect: 'manual',
cache: 'no-store',
});
if (res.ok) return;
throw new Error(
res.type === 'opaqueredirect'
? 'terminal grant → the tunnel asked for a sign-in'
: `terminal grant → ${res.status}`,
);
};
/** 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 the 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,
origin = API,
): Promise<string> => {
const res = await fetch(`${origin}/v1/sandboxes/${encodeURIComponent(id)}/${door}/ticket`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
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 `${origin}${body.url}` + (arg ? `&arg=${encodeURIComponent(arg)}` : '');
};