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
8 changes: 7 additions & 1 deletion apps/cli/src/commands/screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ export const screenCommand = new Command('screen')

const engine = await detectEngine();
const urls = buildVncUrls(box, engine);
const resolved = opts.loopback ? urls.loopbackUrl : (urls.orbUrl ?? urls.loopbackUrl);
// Preference when --loopback is off: portless > orb.local > loopback.
// Portless gives a stable name across box restarts (loopback port
// rerolls every `docker run`); orb.local is OrbStack-only; loopback is
// the always-available fallback. `--loopback` forces the raw port.
const resolved = opts.loopback
? urls.loopbackUrl
: (urls.portlessUrl ?? urls.orbUrl ?? urls.loopbackUrl);
if (!resolved) {
throw new Error(
`VNC URL unavailable (daemon may not be up); try \`agentbox inspect ${box.name}\``,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/box-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export interface DockerBoxFields {
portlessAlias?: string;
/** Full user-facing URL the Portless proxy serves for this box. */
portlessUrl?: string;
/** Portless route name registered for this box's noVNC port (`vnc-<box-name>`). */
portlessVncAlias?: string;
/** Full user-facing URL the Portless proxy serves for this box's VNC. */
portlessVncUrl?: string;
/** Volume mounted at /var/lib/docker for the in-box dockerd. */
dockerVolume?: string;
/** True when this box's `dockerVolume` is the shared cache. */
Expand Down Expand Up @@ -202,6 +206,10 @@ export interface BoxRecord {
portlessAlias?: string;
/** Full user-facing URL the Portless proxy serves for this box. Docker only. */
portlessUrl?: string;
/** Portless route name registered for this box's noVNC port (`vnc-<box-name>`). */
portlessVncAlias?: string;
/** Full user-facing URL the Portless proxy serves for this box's VNC. */
portlessVncUrl?: string;
/** Volume mounted at /var/lib/docker for the in-box dockerd. Docker only. */
dockerVolume?: string;
/** True when this box's `dockerVolume` is the shared cache. Docker only. */
Expand Down
122 changes: 109 additions & 13 deletions packages/sandbox-cloud/src/cloud-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,34 @@ function parseLoopbackPort(url: string): number | undefined {
}
}

/**
* Register a single host Portless alias `<alias>.localhost -> <previewUrl>`
* when `previewUrl` resolves to a loopback `http://127.0.0.1:<port>` (Hetzner's
* `ssh -L` forward). For backends that return a public URL (Daytona's signed
* preview) the alias is naturally skipped. Best-effort: every Portless call
* already swallows; we never throw from here. Returns the resolved URL on
* success.
*/
async function registerHostPortlessAlias(args: {
alias: string;
previewUrl: string;
label: string;
onLog: (line: string) => void;
}): Promise<string | undefined> {
const localPort = parseLoopbackPort(args.previewUrl);
if (localPort === undefined) return undefined;
const ok = await portlessAlias(args.alias, localPort);
if (!ok) {
args.onLog(
`portless: ${args.label} alias not registered (portless CLI missing or not running) — host URL stays http://127.0.0.1:${String(localPort)}`,
);
return undefined;
}
const url = await portlessGetUrl(args.alias);
args.onLog(`portless alias ${url} -> 127.0.0.1:${String(localPort)}`);
return url;
}

/**
* Register the host Portless alias for `<boxName>.localhost -> <webPreviewUrl>`
* and bring up the in-VPS mirror proxy so the same URL works from inside the
Expand All @@ -162,17 +190,13 @@ async function bootstrapPortlessForCloudBox(
handle: CloudHandle,
args: { boxName: string; webPreviewUrl: string; webPort: number; onLog: (line: string) => void },
): Promise<{ alias: string; url: string } | undefined> {
const localPort = parseLoopbackPort(args.webPreviewUrl);
if (localPort === undefined) return undefined;
const ok = await portlessAlias(args.boxName, localPort);
if (!ok) {
args.onLog(
`portless: alias not registered (portless CLI missing or not running) — host URL stays http://127.0.0.1:${String(localPort)}`,
);
return undefined;
}
const url = await portlessGetUrl(args.boxName);
args.onLog(`portless alias ${url} -> 127.0.0.1:${String(localPort)}`);
const url = await registerHostPortlessAlias({
alias: args.boxName,
previewUrl: args.webPreviewUrl,
label: 'web',
onLog: args.onLog,
});
if (!url) return undefined;
if (backend.startInBoxPortless) {
const mode = parsePortlessUrl(url) ?? { proxyPort: DEFAULT_PORTLESS_PROXY_PORT, tls: false };
try {
Expand Down Expand Up @@ -454,6 +478,33 @@ export function createCloudProvider(
portlessUrlResolved = r.url;
}
}
// Parallel `vnc-<box-name>.localhost` alias against the in-box noVNC
// port. Host-only — no in-box mirror; an agent inside the box opening
// its own VNC view is a degenerate self-loop. Same loopback-URL gate
// as the web path, so Daytona naturally skips and Hetzner registers.
let vncPreview: { url: string; token?: string } | undefined;
if (portlessOpt && vncEnabled) {
try {
vncPreview = await backend.previewUrl(handle, CLOUD_VNC_PORT);
} catch {
vncPreview = undefined;
}
}
let portlessVncAliasName: string | undefined;
let portlessVncUrlResolved: string | undefined;
if (portlessOpt && vncPreview) {
const vncAlias = `vnc-${name}`;
const url = await registerHostPortlessAlias({
alias: vncAlias,
previewUrl: vncPreview.url,
label: 'vnc',
onLog: log,
});
if (url) {
portlessVncAliasName = vncAlias;
portlessVncUrlResolved = url;
}
}
// Per-service preview URLs. Each `services.*.expose.port` from
// `agentbox.yaml` gets a direct preview URL alongside the main
// WebProxy URL — lets users hit services without going through the
Expand Down Expand Up @@ -531,6 +582,8 @@ export function createCloudProvider(
carry: carrySummary,
portlessAlias: portlessAliasName,
portlessUrl: portlessUrlResolved,
portlessVncAlias: portlessVncAliasName,
portlessVncUrl: portlessVncUrlResolved,
vncEnabled,
vncPassword,
vncContainerPort: vncEnabled ? CLOUD_VNC_PORT : undefined,
Expand Down Expand Up @@ -642,11 +695,35 @@ export function createCloudProvider(
portlessUrlResolved = r.url;
}
}
// Same story for the VNC alias — the ssh -L port for 6080 is fresh.
// Best-effort, silent (startBox has no onLog). Skipped when no VNC
// alias was set at create.
let portlessVncAliasName: string | undefined = box.portlessVncAlias;
let portlessVncUrlResolved: string | undefined = box.portlessVncUrl;
if (box.portlessVncAlias && box.vncEnabled) {
try {
const vncPreview = await backend.previewUrl(h, CLOUD_VNC_PORT);
const url = await registerHostPortlessAlias({
alias: box.portlessVncAlias,
previewUrl: vncPreview.url,
label: 'vnc',
onLog: () => {},
});
if (url) {
portlessVncAliasName = box.portlessVncAlias;
portlessVncUrlResolved = url;
}
} catch {
/* best-effort */
}
}

const next: BoxRecord = {
...box,
portlessAlias: portlessAliasName,
portlessUrl: portlessUrlResolved,
portlessVncAlias: portlessVncAliasName,
portlessVncUrl: portlessVncUrlResolved,
cloud: {
...(box.cloud ?? { backend: providerName, sandboxId: h.sandboxId }),
webPort,
Expand Down Expand Up @@ -731,15 +808,23 @@ export function createCloudProvider(
const msg = err instanceof Error ? err.message : String(err);
if (!/not.?found|missing/i.test(msg)) throw err;
}
// Best-effort: drop the host Portless alias so `<box>.localhost` stops
// pointing at a dead ssh -L. The in-VPS portless dies with the VPS.
// Best-effort: drop the host Portless aliases (web + vnc) so neither
// `<box>.localhost` nor `vnc-<box>.localhost` keeps pointing at a dead
// ssh -L. The in-VPS portless dies with the VPS.
if (box.portlessAlias) {
try {
await portlessUnalias(box.portlessAlias);
} catch {
// portlessUnalias swallows already; paranoid catch in case.
}
}
if (box.portlessVncAlias) {
try {
await portlessUnalias(box.portlessVncAlias);
} catch {
// best-effort
}
}
// Best-effort: stop the host poller and drop the registration.
try {
await forgetBoxFromRelay(box.id);
Expand Down Expand Up @@ -875,6 +960,17 @@ export function createCloudProvider(
): Promise<string> {
const h = handleFor(box);
const kind = opts?.kind ?? 'web';
// Prefer the stable Portless URL when one was registered (Hetzner gets
// both; Daytona naturally skips since previewUrl is non-loopback). The
// `--loopback` flag forces the raw signed/loopback path instead.
if (!opts?.loopback) {
if (kind === 'web' && box.portlessAlias) {
return box.portlessUrl ?? `https://${box.portlessAlias}.localhost`;
}
if (kind === 'vnc' && box.portlessVncAlias) {
return box.portlessVncUrl ?? `https://${box.portlessVncAlias}.localhost`;
}
}
// VNC port is fixed by Dockerfile.box (websockify serves noVNC on :6080).
const port = kind === 'vnc' ? CLOUD_VNC_PORT : (box.cloud?.webPort ?? CLOUD_WEB_PROXY_PORT);
// Always re-resolve through the SDK — cached URLs on the record may be
Expand Down
2 changes: 2 additions & 0 deletions packages/sandbox-core/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ function projectDockerFields(box: BoxRecord): DockerBoxFields {
webHostPort: box.webHostPort,
portlessAlias: box.portlessAlias,
portlessUrl: box.portlessUrl,
portlessVncAlias: box.portlessVncAlias,
portlessVncUrl: box.portlessVncUrl,
dockerVolume: box.dockerVolume,
dockerCacheShared: box.dockerCacheShared,
checkpointImage: box.checkpointImage,
Expand Down
4 changes: 4 additions & 0 deletions packages/sandbox-core/test/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ describe('state.ts', () => {
vncHostPort: 49001,
webHostPort: 49002,
portlessAlias: 'shape.localhost',
portlessVncAlias: 'vnc-shape.localhost',
portlessVncUrl: 'https://vnc-shape.localhost',
createdAt: '2026-05-12T12:00:00.000Z',
};
await recordBox(box, file);
Expand All @@ -184,6 +186,8 @@ describe('state.ts', () => {
expect(r.docker?.vncHostPort).toBe(49001);
expect(r.docker?.webHostPort).toBe(49002);
expect(r.docker?.portlessAlias).toBe('shape.localhost');
expect(r.docker?.portlessVncAlias).toBe('vnc-shape.localhost');
expect(r.docker?.portlessVncUrl).toBe('https://vnc-shape.localhost');
});

it('cloud records do NOT get a docker shape mirrored in', async () => {
Expand Down
47 changes: 33 additions & 14 deletions packages/sandbox-docker/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,13 +947,16 @@ export async function createBox(opts: CreateBoxOptions): Promise<CreatedBox> {
);
}

// Portless: register `https://<box-name>.localhost -> 127.0.0.1:<webHostPort>`.
// Best-effort — Portless is user-installed and never required; any failure
// here just leaves the box on its loopback URL. Skipped on OrbStack (which
// already has <container>.orb.local).
// Portless: register `https://<box-name>.localhost -> 127.0.0.1:<webHostPort>`
// and a parallel `https://vnc-<box-name>.localhost -> 127.0.0.1:<vncHostPort>`
// for the noVNC viewer. Best-effort — Portless is user-installed and never
// required; any failure here just leaves the box on its loopback URL.
// Skipped on OrbStack (which already has <container>.orb.local).
let portlessAliasName: string | undefined;
let portlessUrl: string | undefined;
if (opts.portless === true && webHostPort) {
let portlessVncAliasName: string | undefined;
let portlessVncUrl: string | undefined;
if (opts.portless === true && (webHostPort || (vncEnabled && vncHostPort))) {
try {
const engine = await detectEngine();
if (engine === 'orbstack') {
Expand All @@ -962,17 +965,31 @@ export async function createBox(opts: CreateBoxOptions): Promise<CreatedBox> {
const portless = await detectPortless();
if (!portless.installed) {
log('portless not installed — run `npm install -g portless` for a <name>.localhost URL');
} else if (await portlessAlias(name, webHostPort)) {
portlessAliasName = name;
// Resolve the real URL from the proxy: scheme + port depend on how
// the proxy was started (http://…:1355 no-TLS, or https://… on :443).
portlessUrl = await portlessGetUrl(name);
log(`portless alias ${portlessUrl} -> 127.0.0.1:${String(webHostPort)}`);
if (!portless.proxyRunning) {
} else {
if (webHostPort) {
if (await portlessAlias(name, webHostPort)) {
portlessAliasName = name;
// Resolve the real URL from the proxy: scheme + port depend on how
// the proxy was started (http://…:1355 no-TLS, or https://… on :443).
portlessUrl = await portlessGetUrl(name);
log(`portless alias ${portlessUrl} -> 127.0.0.1:${String(webHostPort)}`);
} else {
log('portless alias failed (best-effort) — box still reachable on the loopback URL');
}
}
if (vncEnabled && vncHostPort) {
const vncAlias = `vnc-${name}`;
if (await portlessAlias(vncAlias, vncHostPort)) {
portlessVncAliasName = vncAlias;
portlessVncUrl = await portlessGetUrl(vncAlias);
log(`portless alias ${portlessVncUrl} -> 127.0.0.1:${String(vncHostPort)}`);
} else {
log('portless vnc alias failed (best-effort) — VNC still reachable on the loopback URL');
}
}
if (!portless.proxyRunning && (portlessAliasName || portlessVncAliasName)) {
log(`portless proxy not running — start it with \`${portlessStartHint()}\``);
}
} else {
log('portless alias failed (best-effort) — box still reachable on the loopback URL');
}
}
} catch (err) {
Expand Down Expand Up @@ -1006,6 +1023,8 @@ export async function createBox(opts: CreateBoxOptions): Promise<CreatedBox> {
webHostPort: webHostPort ?? undefined,
portlessAlias: portlessAliasName,
portlessUrl,
portlessVncAlias: portlessVncAliasName,
portlessVncUrl,
dockerVolume,
dockerCacheShared: dockerCacheShared || undefined,
projectRoot: opts.projectRoot,
Expand Down
3 changes: 2 additions & 1 deletion packages/sandbox-docker/src/docker-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ export const dockerProvider: Provider = {
}
const engine = await detectEngine();
if (engine === 'orbstack' && !opts?.loopback) {
// OrbStack auto-routes <container>.orb.local to container :80.
// OrbStack auto-routes <container>.orb.local to the container; :80 is
// declared (EXPOSE 80) so no port suffix is needed.
return `http://${box.container}.orb.local`;
}
if (box.portlessAlias && !opts?.loopback) {
Expand Down
7 changes: 6 additions & 1 deletion packages/sandbox-docker/src/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ export async function getBoxEndpoints(

if (record.vncEnabled && record.vncPassword) {
const vncUrls = buildVncUrls(record, engine);
const url = vncUrls.orbUrl ?? vncUrls.loopbackUrl;
// Preference: portless (stable name) > orb.local > loopback. Mirrors the
// web endpoint's choice below and `agentbox screen`'s default.
const url =
engine === 'orbstack'
? (vncUrls.orbUrl ?? vncUrls.loopbackUrl)
: (vncUrls.portlessUrl ?? vncUrls.loopbackUrl);
endpoints.push({
kind: 'vnc',
name: 'vnc',
Expand Down
Loading
Loading