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
2 changes: 2 additions & 0 deletions apps/cli/scripts/stage-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const dockerfileSrc = 'packages/sandbox-docker/Dockerfile.box';
const execBitFiles = new Set([
'packages/sandbox-docker/scripts/agentbox-vnc-start',
'packages/sandbox-docker/scripts/agentbox-dockerd-start',
'packages/sandbox-docker/scripts/agentbox-sshd-start',
'packages/sandbox-docker/scripts/agentbox-portless-trust',
'packages/sandbox-docker/scripts/agentbox-checkpoint-cleanup',
'packages/sandbox-docker/scripts/agentbox-open',
Expand All @@ -53,6 +54,7 @@ const contextFiles = [
'apps/cli/share/agentbox-setup/SKILL.md',
'packages/sandbox-docker/scripts/agentbox-vnc-start',
'packages/sandbox-docker/scripts/agentbox-dockerd-start',
'packages/sandbox-docker/scripts/agentbox-sshd-start',
'packages/sandbox-docker/scripts/agentbox-portless-trust',
'packages/sandbox-docker/scripts/agentbox-checkpoint-cleanup',
'packages/sandbox-docker/scripts/agentbox-open',
Expand Down
42 changes: 35 additions & 7 deletions apps/cli/src/commands/_open-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,47 @@ import { existsSync as realExistsSync } from 'node:fs';
import { homedir as realHomedir } from 'node:os';
import { delimiter, join } from 'node:path';

export type OpenInApp = 'codex' | 'herdr' | 'cmux' | 'vscode' | 'iterm2';
export type OpenTarget = OpenInApp | 'finder';

export const OPEN_IN_APPS: readonly OpenInApp[] = ['codex', 'herdr', 'cmux', 'vscode', 'iterm2'];
// `finder` is a first-class open target: `agentbox open <box>` (no `--in`) is
// equivalent to `--in finder` and sshfs-mounts /workspace + reveals it. It's an
// app like the others so the Hub/Tray "Open In" surfaces can list it, gated to
// SSH-capable providers via its `providers` in detectOpenTargets.
export type OpenInApp = 'codex' | 'herdr' | 'cmux' | 'vscode' | 'iterm2' | 'finder';
export type OpenTarget = OpenInApp;

export const OPEN_IN_APPS: readonly OpenInApp[] = [
'codex',
'herdr',
'cmux',
'vscode',
'iterm2',
'finder',
];

/**
* Providers whose per-box SSH identity outlives the CLI call (see
* packages/sandbox-core/src/cloud-ssh.ts). Codex connects on its own later, so
* an expiring token credential (Daytona) or no SSH at all (docker/vercel/e2b)
* disqualifies the box.
* an expiring token credential (Daytona) or no SSH at all (vercel/e2b)
* disqualifies the box. Docker qualifies: its localhost sshd is key-authed by a
* per-box key that persists under the box dir (only the loopback host port
* changes across restart, and `agentbox open`/start re-syncs `~/.ssh/config`).
*/
export const PERSISTENT_SSH_PROVIDERS: readonly string[] = ['hetzner'];
export const PERSISTENT_SSH_PROVIDERS: readonly string[] = ['docker', 'hetzner'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex offered for legacy docker

Medium Severity

Adding docker to PERSISTENT_SSH_PROVIDERS makes open --targets and the Hub treat every docker box as Codex-eligible, but openInCodex rejects pre-sshd docker boxes via sshEnabled. Legacy boxes still show an Open in Codex control that fails at click time.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6ac4225. Configure here.


/**
* Providers `agentbox code` can attach an IDE to: docker via the Dev Containers
* attached-container URI, clouds via a Remote-SSH alias.
*/
export const IDE_PROVIDERS: readonly string[] = ['docker', 'hetzner', 'daytona'];

/**
* Providers `agentbox open` can sshfs-mount `/workspace` from — they expose real
* SSH: docker's localhost sshd, Hetzner's VPS, Daytona's token gateway. Vercel/E2B
* have no SSH (their `buildAttach` yields a non-SSH `sbx exec` / SDK PTY bridge),
* so `open` fails fast with a readable pointer to `agentbox download`. Plugin
* providers stay opted out until they declare SSH support.
*/
export const SSH_MOUNT_PROVIDERS: readonly string[] = ['docker', 'hetzner', 'daytona'];

export interface DetectSeams {
env: NodeJS.ProcessEnv;
platform: NodeJS.Platform;
Expand Down Expand Up @@ -182,6 +204,12 @@ export function detectOpenTargets(seams: DetectSeams = realSeams()): OpenTargets
iterm2: {
available: macAppInstalled('iTerm.app', seams),
},
finder: {
// The OS file-manager reveal always works (Finder on macOS, xdg-open
// elsewhere); provider gating is what limits `open` to SSH-capable boxes.
available: true,
providers: [...SSH_MOUNT_PROVIDERS],
},
};
}

Expand Down
198 changes: 152 additions & 46 deletions apps/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import type { BoxRecord } from '@agentbox/core';
import {
agentboxAliasFor,
hostOpenCommand,
ensureCloudSshAlias,
recordBoxSsh,
resolveCloudSshTarget,
syncAgentboxSshConfig,
} from '@agentbox/sandbox-core';
import { inspectBox, openBoxInFinder, startBox, unpauseBox } from '@agentbox/sandbox-docker';
import {
inspectBox,
openBoxInFinder,
refreshBoxSshd,
startBox,
unpauseBox,
} from '@agentbox/sandbox-docker';
import { Command, InvalidArgumentError } from 'commander';
import { resolveBoxOrExit } from '../box-ref.js';
import { hyperlink } from '../hyperlink.js';
Expand All @@ -27,6 +34,7 @@ import {
PERSISTENT_SSH_PROVIDERS,
renderTargets,
resolveCmuxBinary,
SSH_MOUNT_PROVIDERS,
type OpenTarget,
} from './_open-in.js';

Expand Down Expand Up @@ -59,22 +67,22 @@ function parseOpenTarget(value: string): OpenTarget {

export const openCommand = new Command('open')
.description(
"Open a box in Finder (default; docker: rsync'd snapshot; cloud: sshfs mount) or in a host app via --in",
'Mount a box /workspace via sshfs and reveal in Finder (default), or open it in a host app via --in',
)
.argument(
'[box]',
'box ref: project index, id, id prefix, name, or container (default: the only box in this project)',
)
.option('--no-refresh', "skip the rsync; open whatever's already on disk (docker only)")
.option('--no-refresh', "legacy docker (no sshd) only: skip the rsync; open what's on disk")
.option(
'--include-node-modules',
'include /workspace/node_modules in the merged export (docker only; off by default)',
'legacy docker (no sshd) only: include /workspace/node_modules in the export',
)
.option('--path', 'print the host workspace / mount path instead of launching Finder')
.option('--path', 'print the host mount path instead of launching Finder')
.option('--print', 'alias of --path')
.option(
'--unmount',
'cloud only: unmount any existing sshfs mount for the box and exit',
'unmount any existing sshfs mount for the box and exit',
)
.option(
'--in <app>',
Expand All @@ -93,6 +101,17 @@ export const openCommand = new Command('open')

const box = await resolveBoxOrExit(idOrName);

// `--unmount` is a pure host-side mount teardown — independent of the box's
// provider or whether it currently has sshd — so handle it up front before
// any provider dispatch (a legacy docker box would otherwise fall through to
// the rsync path and never unmount).
if (opts.unmount) {
const mountRoot = join(homedir(), '.agentbox', 'mounts', box.name);
const ok = await tryUnmount(mountRoot);
process.stdout.write(ok ? `unmounted ${mountRoot}\n` : `nothing mounted at ${mountRoot}\n`);
return;
}

const app = opts.in ?? 'finder';
if (app === 'codex') {
await openInCodex(box);
Expand All @@ -107,13 +126,30 @@ export const openCommand = new Command('open')
return;
}

const isCloud = (box.provider ?? 'docker') !== 'docker';
const providerName = box.provider ?? 'docker';

if (isCloud) {
await runCloudOpen(box, opts);
// Live sshfs mount of /workspace for any SSH-capable provider (docker's
// localhost sshd, Hetzner's VPS, Daytona's token gateway). A docker box
// that predates the localhost sshd (no `sshEnabled`) still falls through to
// the rsync-snapshot export below.
if (SSH_MOUNT_PROVIDERS.includes(providerName) && (providerName !== 'docker' || box.sshEnabled)) {
await runSshfsMount(box, opts);
return;
}

// Providers with no SSH (vercel, e2b) can't be sshfs-mounted and have no
// docker-style host export. Fail fast with a readable pointer — before any
// bring-online — instead of a cryptic SSH-parse error deeper in.
if (providerName !== 'docker') {
throw new Error(
`'agentbox open' live-mounts /workspace over sshfs, which ${providerName} boxes don't ` +
`support (no SSH). Use 'agentbox download ${box.name}' to copy files to your host, ` +
`or 'agentbox open ${box.name} --in vscode'.`,
);
}

// Legacy docker box (no localhost sshd): rsync the merged /workspace export
// to a host dir and reveal in Finder. Kept until this moves under `download`.
if (opts.path || opts.print) {
await runPath(box, {
refresh: opts.refresh, // print refreshes by default; --no-refresh skips
Expand Down Expand Up @@ -147,39 +183,67 @@ async function openInCodex(box: BoxRecord): Promise<void> {
const providerName = box.provider ?? 'docker';
if (!PERSISTENT_SSH_PROVIDERS.includes(providerName)) {
throw new Error(
`'--in codex' needs a box with a persistent SSH key — only Hetzner cloud boxes qualify ` +
`(this box is '${providerName}'). Docker boxes aren't reachable over SSH and Daytona uses ` +
`a 60-min token that expires. Try 'agentbox open ${box.name} --in vscode' instead.`,
Comment thread
cursor[bot] marked this conversation as resolved.
`'--in codex' needs a box with a persistent SSH key — docker (localhost sshd) and ` +
`Hetzner cloud boxes qualify (this box is '${providerName}'). Daytona uses a 60-min ` +
`token that expires; Vercel/E2B have no SSH. Try 'agentbox open ${box.name} --in vscode'.`,
);
}
const provider = await providerForBox(box);
const conn = await resolveCloudSshTarget(box, provider);
if (!conn.identityFile) {

// A docker box created before the localhost sshd shipped has no SSH surface —
// `PERSISTENT_SSH_PROVIDERS` gates by provider, so catch that here with a clear
// message instead of a confusing "sshd may have failed to start" downstream.
if (providerName === 'docker' && !box.sshEnabled) {
throw new Error(
`box '${box.name}' (provider '${providerName}') has no persistent SSH key, so it can't ` +
`be added to Codex over SSH.`,
`box '${box.name}' predates the in-box sshd, so it can't be added to Codex over SSH. ` +
`Recreate the box (new docker boxes run sshd), or use 'agentbox open ${box.name} --in vscode'.`,
);
}
await recordBoxSsh(box.id, {
host: conn.host,
user: conn.user,
identityFile: conn.identityFile,
});
await syncAgentboxSshConfig();
log.info(`ssh alias '${conn.alias}' written (Include'd from ~/.ssh/config)`);

const url = codexAddUrl(conn.alias);
let alias: string;
if (providerName === 'docker') {
// Docker: bring the box online (sshd up + loopback port fresh) and use the
// alias create/start already wrote to `~/.ssh/config`.
let online = await bringDockerBoxOnline(box);
if (!online.ssh) online = await resolveBoxOrExit(online.id);
if (!online.ssh?.identityFile) {
throw new Error(
`box '${box.name}' has no SSH key on disk — its in-box sshd may have failed to start. ` +
`Try 'agentbox start ${box.name}' and retry.`,
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
await syncAgentboxSshConfig();
alias = agentboxAliasFor(online.name);
} else {
const provider = await providerForBox(box);
const conn = await resolveCloudSshTarget(box, provider);
if (!conn.identityFile) {
throw new Error(
`box '${box.name}' (provider '${providerName}') has no persistent SSH key, so it can't ` +
`be added to Codex over SSH.`,
);
}
await recordBoxSsh(box.id, {
host: conn.host,
user: conn.user,
identityFile: conn.identityFile,
});
await syncAgentboxSshConfig();
alias = conn.alias;
}
log.info(`ssh alias '${alias}' written (Include'd from ~/.ssh/config)`);

const url = codexAddUrl(alias);
const opened = await execa(hostOpenCommand(), [url], { reject: false });
if (opened.exitCode !== 0) {
log.warn(
`could not auto-open the Codex link (is Codex.app installed?): ${opened.stderr || `exit ${String(opened.exitCode)}`}`,
);
const link = hyperlink(`Add ${conn.alias} to Codex SSH`, url, process.stdout);
const link = hyperlink(`Add ${alias} to Codex SSH`, url, process.stdout);
process.stdout.write(`open manually: ${link}\n${url}\n`);
process.exitCode = 1;
return;
}
log.success(`opening Codex — the SSH connection form is pre-filled with '${conn.alias}'`);
log.success(`opening Codex — the SSH connection form is pre-filled with '${alias}'`);
process.stdout.write(url + '\n');
}

Expand Down Expand Up @@ -278,21 +342,39 @@ async function openInTerminalApp(box: BoxRecord, host: 'herdr' | 'cmux' | 'iterm
}

/**
* Cloud `open`: mount the sandbox's `/workspace` via sshfs at a per-box host
* path (`~/.agentbox/mounts/<box-name>/`) and reveal in Finder. Reuses the
* SSH alias `agentbox code` already manages — the alias maps to a fresh
* 60-min Daytona SSH token, written into `~/.ssh/config` per call so sshfs
* has a live target without baking the token into the mount itself.
* Bring a docker box online (unpause/start) and return the freshest record with
* a live SSH target. `startBox` already re-resolves the ephemeral sshd host port;
* for a box that is *already running*, we still `refreshBoxSshd` because a
* `docker restart` (or docker daemon restart) outside `agentbox start`
* reallocates the `-p 0:22` host port, leaving the recorded `Port` stale.
*/
async function bringDockerBoxOnline(box: BoxRecord): Promise<BoxRecord> {
const insp = await inspectBox(box.id);
if (insp.state === 'paused') {
log.info('box is paused; unpausing');
await unpauseBox(box.id);
} else if (insp.state === 'stopped') {
log.info('box is stopped; starting');
const started = await startBox(box.id);
return started.record;
} else if (insp.state === 'missing') {
throw new Error(`box ${box.name} has no container; was it destroyed?`);
}
// Already running (or just unpaused): re-verify the sshd port is current.
return await refreshBoxSshd(box);
}

/**
* `open`: mount the box's `/workspace` via sshfs at a per-box host path
* (`~/.agentbox/mounts/<box-name>/`) and reveal in Finder. Docker boxes ride
* their always-on localhost sshd (the alias is already in `~/.ssh/config` from
* create/start); cloud boxes reuse the SSH alias `agentbox code` manages
* (Daytona maps it to a fresh 60-min token per call).
*/
async function runCloudOpen(box: BoxRecord, opts: OpenOpts): Promise<void> {
async function runSshfsMount(box: BoxRecord, opts: OpenOpts): Promise<void> {
const mountRoot = join(homedir(), '.agentbox', 'mounts', box.name);

if (opts.unmount) {
const ok = await tryUnmount(mountRoot);
if (ok) process.stdout.write(`unmounted ${mountRoot}\n`);
else process.stdout.write(`nothing mounted at ${mountRoot}\n`);
return;
}
// `--unmount` is handled up front in the action (provider-independent).

if (opts.path || opts.print) {
// Don't mount when we only print — print is meant to be lightweight.
Expand All @@ -306,15 +388,31 @@ async function runCloudOpen(box: BoxRecord, opts: OpenOpts): Promise<void> {
const sshfsBin = await locateBinary('sshfs');
if (!sshfsBin) {
throw new Error(
'sshfs not found on PATH. Install with `brew install macfuse sshfs` (macOS) or your distro\'s package manager, then retry. Cloud `agentbox open` mounts the sandbox /workspace via sshfs.',
'sshfs not found on PATH. Install with `brew install macfuse sshfs` (macOS) or your distro\'s package manager, then retry. `agentbox open` mounts the box /workspace via sshfs.',
);
}

// Same SSH alias machinery `agentbox code` uses — bring the box online and
// (re)write the alias (a fresh 60-min token for Daytona) so sshfs gets a live
// mount target.
const provider = await providerForBox(box);
const { alias } = await ensureCloudSshAlias(box, provider);
let alias: string;
if ((box.provider ?? 'docker') === 'docker') {
// Docker: bring the box online so sshd is up + the loopback port is fresh,
// then rely on the alias already written to `~/.ssh/config` by create/start.
let online = await bringDockerBoxOnline(box);
if (!online.ssh) online = await resolveBoxOrExit(online.id);
if (!online.ssh) {
throw new Error(
`box '${box.name}' has no SSH endpoint — its in-box sshd may have failed to start. ` +
`Try 'agentbox start ${box.name}' and retry, or 'agentbox open ${box.name} --in vscode'.`,
);
}
await syncAgentboxSshConfig();
alias = agentboxAliasFor(online.name);
} else {
// Same SSH alias machinery `agentbox code` uses — bring the box online and
// (re)write the alias (a fresh 60-min token for Daytona) so sshfs gets a
// live mount target.
const provider = await providerForBox(box);
({ alias } = await ensureCloudSshAlias(box, provider));
}

// Ensure the mount dir exists. If something's already mounted there (a
// stale mount from a previous run) we tear it down before re-mounting —
Expand Down Expand Up @@ -344,7 +442,15 @@ async function runCloudOpen(box: BoxRecord, opts: OpenOpts): Promise<void> {
{ reject: false },
);
if (mount.exitCode !== 0) {
throw new Error(`sshfs mount failed (exit ${String(mount.exitCode)}): ${mount.stderr || mount.stdout}`);
const err = `${mount.stderr || ''}${mount.stdout || ''}`;
// sshfs is on PATH but the mount failed — on macOS the usual cause is a
// missing/unapproved macFUSE kernel extension (sshfs is built against it).
const macfuseHint =
process.platform === 'darwin' && /fuse|macfuse|load|not permitted|mount_macfuse/i.test(err)
? ` — is macFUSE installed and approved? Run 'brew install macfuse' (may need a reboot and ` +
`approving the system extension in System Settings › Privacy & Security), then retry.`
: '';
throw new Error(`sshfs mount failed (exit ${String(mount.exitCode)}): ${err}${macfuseHint}`);
}
// Reveal the mount in the OS file manager (Finder on macOS, the default
// handler via xdg-open on Linux). Best-effort — the mount path is already
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ if (AGENTBOX_VERSION !== '0.0.0-dev') {
versionPromptShown = true;
try {
const yes = await confirm({
message: `agentbox was updated (${state.lastRunVersion} → ${AGENTBOX_VERSION}) — refresh skills, box image, relay, and the menu-bar app now?`,
message: `agentbox was updated (${state.lastRunVersion} → ${AGENTBOX_VERSION}) — download new version now?`,
initialValue: true,
});
if (yes) {
Expand Down
Loading
Loading