diff --git a/apps/cli/scripts/stage-runtime.mjs b/apps/cli/scripts/stage-runtime.mjs index 7f4e0cf7..3d1e7f59 100644 --- a/apps/cli/scripts/stage-runtime.mjs +++ b/apps/cli/scripts/stage-runtime.mjs @@ -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', @@ -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', diff --git a/apps/cli/src/commands/_open-in.ts b/apps/cli/src/commands/_open-in.ts index f7867ac9..96b2a54b 100644 --- a/apps/cli/src/commands/_open-in.ts +++ b/apps/cli/src/commands/_open-in.ts @@ -12,18 +12,31 @@ 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 ` (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']; /** * Providers `agentbox code` can attach an IDE to: docker via the Dev Containers @@ -31,6 +44,15 @@ export const PERSISTENT_SSH_PROVIDERS: readonly string[] = ['hetzner']; */ 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; @@ -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], + }, }; } diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index 5ddc2bf1..221a723d 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -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'; @@ -27,6 +34,7 @@ import { PERSISTENT_SSH_PROVIDERS, renderTargets, resolveCmuxBinary, + SSH_MOUNT_PROVIDERS, type OpenTarget, } from './_open-in.js'; @@ -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 ', @@ -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); @@ -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 @@ -147,39 +183,67 @@ async function openInCodex(box: BoxRecord): Promise { 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.`, + `'--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.`, + ); + } + 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'); } @@ -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//`) 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 { + 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//`) 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 { +async function runSshfsMount(box: BoxRecord, opts: OpenOpts): Promise { 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. @@ -306,15 +388,31 @@ async function runCloudOpen(box: BoxRecord, opts: OpenOpts): Promise { 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 — @@ -344,7 +442,15 @@ async function runCloudOpen(box: BoxRecord, opts: OpenOpts): Promise { { 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 diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 80b646fe..6d1ab044 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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) { diff --git a/apps/cli/src/lib/doctor-checks.ts b/apps/cli/src/lib/doctor-checks.ts index 3975c8af..413c0cee 100644 --- a/apps/cli/src/lib/doctor-checks.ts +++ b/apps/cli/src/lib/doctor-checks.ts @@ -6,7 +6,7 @@ * a cloud API. Remote snapshot inventory lives in `agentbox prepare --status`. */ -import { accessSync, constants as fsConstants, mkdirSync } from 'node:fs'; +import { accessSync, constants as fsConstants, existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { execa } from 'execa'; @@ -114,9 +114,47 @@ async function checkSsh(): Promise { }; } +/** True when `bin` resolves on PATH (definitive install check, no version-flag quirks). */ +async function onPath(bin: string): Promise { + const r = await execa('which', [bin], { reject: false }); + if (r.exitCode !== 0) return null; + const p = (r.stdout ?? '').trim(); + return p.length > 0 ? p : null; +} + +// sshfs + macFUSE are OPTIONAL deps of `agentbox open` (the sshfs live-mount of a +// box's /workspace), so a miss is `warn`, never `fail`. +async function checkSshfs(): Promise { + const path = await onPath('sshfs'); + if (path) return { label: 'sshfs', status: 'ok', detail: path }; + const hint = + process.platform === 'darwin' + ? 'optional: `brew install macfuse sshfs` — needed for `agentbox open` (sshfs mount)' + : 'optional: install sshfs (e.g. `apt install sshfs`) — needed for `agentbox open` (sshfs mount)'; + return { label: 'sshfs', status: 'warn', detail: 'not found', hint }; +} + +/** macOS-only: macFUSE isn't a PATH binary — probe its filesystem bundle. */ +function checkMacfuse(): CheckResult { + const present = + existsSync('/Library/Filesystems/macfuse.fs') || existsSync('/Library/Filesystems/osxfuse.fs'); + return present + ? { label: 'macfuse', status: 'ok', detail: '/Library/Filesystems/macfuse.fs' } + : { + label: 'macfuse', + status: 'warn', + detail: 'not installed', + hint: 'optional: `brew install macfuse` — the FUSE backend `agentbox open` mounts through', + }; +} + export async function runSystemChecks(): Promise { - const [git, ssh] = await Promise.all([checkGit(), checkSsh()]); - return [checkNode(), checkPlatform(), checkAgentboxHome(), git, ssh]; + const [git, ssh, sshfs] = await Promise.all([checkGit(), checkSsh(), checkSshfs()]); + const results = [checkNode(), checkPlatform(), checkAgentboxHome(), git, ssh, sshfs]; + // macFUSE is a macOS concept; on Linux FUSE is a kernel module and sshfs alone + // is the signal, so don't show a spurious row. + if (process.platform === 'darwin') results.push(checkMacfuse()); + return results; } /** diff --git a/apps/cli/test/open-in.test.ts b/apps/cli/test/open-in.test.ts index 8082bb5e..e9290c58 100644 --- a/apps/cli/test/open-in.test.ts +++ b/apps/cli/test/open-in.test.ts @@ -5,6 +5,7 @@ import { detectOpenTargets, IDE_PROVIDERS, PERSISTENT_SSH_PROVIDERS, + SSH_MOUNT_PROVIDERS, pathHasBinary, renderTargets, resolveCmuxBinary, @@ -109,16 +110,31 @@ describe('detectOpenTargets', () => { expect(detectOpenTargets(seams({})).iterm2.available).toBe(false); }); + it('finder is always available, gated to SSH-mount providers', () => { + expect(detectOpenTargets(seams({})).finder).toEqual({ + available: true, + providers: [...SSH_MOUNT_PROVIDERS], + }); + // Available on linux too (xdg-open reveal), unlike the macOS-only apps. + expect(detectOpenTargets(seams({ platform: 'linux' })).finder.available).toBe(true); + }); + it('never throws on linux (no /Applications probes)', () => { expect(() => detectOpenTargets(seams({ platform: 'linux' }))).not.toThrow(); }); }); describe('provider eligibility constants', () => { - it('codex is hetzner-only; vscode covers docker + ssh clouds', () => { - expect(PERSISTENT_SSH_PROVIDERS).toEqual(['hetzner']); + it('codex/persistent-ssh covers docker + hetzner; vscode covers docker + ssh clouds', () => { + expect(PERSISTENT_SSH_PROVIDERS).toEqual(['docker', 'hetzner']); expect(IDE_PROVIDERS).toEqual(['docker', 'hetzner', 'daytona']); }); + + it('open sshfs-mounts docker + hetzner + daytona; vercel/e2b excluded (no SSH)', () => { + expect(SSH_MOUNT_PROVIDERS).toEqual(['docker', 'hetzner', 'daytona']); + expect(SSH_MOUNT_PROVIDERS).not.toContain('vercel'); + expect(SSH_MOUNT_PROVIDERS).not.toContain('e2b'); + }); }); describe('codexAddUrl', () => { @@ -201,13 +217,15 @@ describe('renderTargets', () => { cmux: { available: true }, vscode: { available: true, providers: ['docker', 'hetzner', 'daytona'] }, iterm2: { available: true }, + finder: { available: true, providers: ['docker', 'hetzner', 'daytona'] }, }); expect(out).toBe( 'codex: not installed\n' + 'herdr: available\n' + 'cmux: available\n' + 'vscode: available (docker, hetzner, daytona boxes)\n' + - 'iterm2: available\n', + 'iterm2: available\n' + + 'finder: available (docker, hetzner, daytona boxes)\n', ); }); }); diff --git a/apps/hub/app/(dashboard)/api/v1/lib/validate.ts b/apps/hub/app/(dashboard)/api/v1/lib/validate.ts index 87175adc..6444b795 100644 --- a/apps/hub/app/(dashboard)/api/v1/lib/validate.ts +++ b/apps/hub/app/(dashboard)/api/v1/lib/validate.ts @@ -146,7 +146,7 @@ export function isGitOp(v: string): v is GitOp { // Host apps a box can be launched in (mirrors OPEN_IN_APPS in the CLI's // _open-in.ts; hardcoded to keep @agentbox/* out of the Next bundle). -export const OPEN_IN_APPS = ['codex', 'herdr', 'cmux', 'vscode', 'iterm2'] as const; +export const OPEN_IN_APPS = ['codex', 'herdr', 'cmux', 'vscode', 'iterm2', 'finder'] as const; export type OpenInApp = (typeof OPEN_IN_APPS)[number]; export function isOpenInApp(v: string): v is OpenInApp { diff --git a/apps/hub/app/(dashboard)/boxes/components/access.tsx b/apps/hub/app/(dashboard)/boxes/components/access.tsx index 80f5c113..d4d1b309 100644 --- a/apps/hub/app/(dashboard)/boxes/components/access.tsx +++ b/apps/hub/app/(dashboard)/boxes/components/access.tsx @@ -8,15 +8,18 @@ import type { OpenInApp, OpenTargets } from '@/lib/boxes/backend-types'; import type { Box } from '@/lib/boxes/types'; import { SectionLabel } from './section-label'; -// Display order + labels for the five host "open in" apps (mirrors the tray's -// Open In menu and the CLI's OPEN_IN_APPS). Codex gets its glyph; the terminal -// multiplexers share the terminal icon; VS Code/Cursor uses the external glyph. +// Display order + labels for the host "open in" apps (mirrors the tray's Open In +// menu and the CLI's OPEN_IN_APPS). Codex gets its glyph; the terminal +// multiplexers share the terminal icon; VS Code/Cursor uses the external glyph; +// Finder (sshfs-mount /workspace + reveal) uses the folder glyph and is gated to +// SSH-capable providers via its `providers` in the open-targets report. const APPS: { app: OpenInApp; label: string; icon: keyof typeof Icons }[] = [ { app: 'codex', label: 'Codex', icon: 'codex' }, { app: 'vscode', label: 'VS Code', icon: 'ext' }, { app: 'cmux', label: 'cmux', icon: 'terminal' }, { app: 'herdr', label: 'Herdr', icon: 'terminal' }, { app: 'iterm2', label: 'iTerm2', icon: 'terminal' }, + { app: 'finder', label: 'Finder', icon: 'folder' }, ]; function CopyButton({ url }: { url: string }) { diff --git a/apps/hub/lib/boxes/backend-types.ts b/apps/hub/lib/boxes/backend-types.ts index f06eaaf4..67c7351b 100644 --- a/apps/hub/lib/boxes/backend-types.ts +++ b/apps/hub/lib/boxes/backend-types.ts @@ -12,7 +12,7 @@ export type BoxOpResult = { ok: true; stdout?: string; stderr?: string } | { ok: // Host apps a box can be opened in (`agentbox open --in `). Mirrors the // CLI's OPEN_IN_APPS (apps/cli/src/commands/_open-in.ts); duplicated here to keep // @agentbox/* packages out of the Next bundle, like AGENTS/PROVIDERS in validate.ts. -export type OpenInApp = 'codex' | 'herdr' | 'cmux' | 'vscode' | 'iterm2'; +export type OpenInApp = 'codex' | 'herdr' | 'cmux' | 'vscode' | 'iterm2' | 'finder'; // One app's install/eligibility, as reported by the CLI's `open --targets --json`. // `providers` (when present) limits the app to boxes on those providers (e.g. diff --git a/apps/web/content/docs/access-your-box.mdx b/apps/web/content/docs/access-your-box.mdx index ae2ee1b0..259b6620 100644 --- a/apps/web/content/docs/access-your-box.mdx +++ b/apps/web/content/docs/access-your-box.mdx @@ -143,16 +143,30 @@ The first attach to a fresh box downloads the IDE server (~70MB, keyed to your d ## Open files -`agentbox open [box]` opens the box's `/workspace` in Finder. The merged `/workspace` only exists inside the container, so `open` first rsyncs it to a per-box host directory and then launches Finder. The export is a snapshot, refreshed on demand — re-run to re-sync. See [CLI commands](/docs/cli) for flags like `--no-refresh`, `--include-node-modules`, and `--path`. +`agentbox open [box]` **live-mounts** the box's `/workspace` on your host and reveals it in Finder. Every box runs a localhost-only SSH server (published on `127.0.0.1` only, key-authenticated by a per-box key), so `open` mounts `/workspace` over `sshfs` at `~/.agentbox/mounts//`. Unlike a snapshot, edits flow both ways in real time. This needs `sshfs` on the host — `brew install macfuse sshfs` on macOS. See [CLI commands](/docs/cli) for `--path` (print the mount path) and `--unmount`. ```bash -# rsync /workspace and open it in Finder +# sshfs-mount /workspace and open it in Finder agentbox open mybox -# print the host path without opening Finder (for editors/scripts) -agentbox open mybox --path --no-refresh +# print the mount path without opening Finder (for editors/scripts) +agentbox open mybox --path + +# tear the mount down +agentbox open mybox --unmount ``` +Because the box exposes real SSH, you can also add it to the **Codex desktop app** as an SSH connection in one step: + +```bash +# writes an ~/.ssh/config alias and opens Codex's add-SSH-connection form +agentbox open mybox --in codex +``` + +`agentbox open` writes a managed `Host ` block to `~/.agentbox/ssh/config` (included from `~/.ssh/config`), so `ssh ` also just works and lands in `/workspace`. The alias points at `127.0.0.1` on an ephemeral loopback port that is re-resolved on every `agentbox start`. + +`open` works on any SSH-capable provider — Docker, Hetzner, and Daytona. Vercel and E2B boxes have no SSH, so `open` there fails with a short message pointing you at `agentbox download` (copy the files to your host) instead. `agentbox doctor` lists `sshfs` and `macFUSE` as optional checks so you can see at a glance whether the mount will work. + -`agentbox open` is a snapshot for browsing, not a sync-back. To merge box changes into your host workspace use `agentbox download` (see [Sync and git](/docs/sync-and-git)), and capture state with [checkpoints](/docs/checkpoints-and-pausing) before you destroy — the export under `~/.agentbox/boxes//` is wiped on destroy. +The sshd is bound to loopback only and never reachable off-host. It's a live mount, not a checkpoint — capture durable state with [checkpoints](/docs/checkpoints-and-pausing) before you destroy. The per-box key under `~/.agentbox/boxes//ssh/` is wiped on destroy. diff --git a/apps/web/content/docs/cli.mdx b/apps/web/content/docs/cli.mdx index 64a6d3d0..422372fe 100644 --- a/apps/web/content/docs/cli.mdx +++ b/apps/web/content/docs/cli.mdx @@ -82,15 +82,17 @@ agentbox url 2 agentbox screen 2 agentbox code 2 -# Open /workspace in Finder +# sshfs-mount /workspace and reveal in Finder (needs `brew install macfuse sshfs`) agentbox open 2 +agentbox open 2 --unmount # tear the mount down # Open the box in a host app -agentbox open 2 --in codex # auto-open Codex's add-SSH-connection form (Hetzner boxes) +agentbox open 2 --in codex # auto-open Codex's add-SSH-connection form (docker + Hetzner boxes) agentbox open 2 --in herdr # new Herdr workspace running `agentbox attach` agentbox open 2 --in cmux # new cmux workspace running `agentbox attach` agentbox open 2 --in vscode # same as `agentbox code` agentbox open 2 --in iterm2 # new iTerm2 window running `agentbox attach` +agentbox open 2 --in finder # same as bare `agentbox open` (sshfs mount + reveal) agentbox open --targets # which apps are installed (add --json for machines) # Multiplexed box dashboard (TUI) diff --git a/apps/web/content/docs/local-docker.mdx b/apps/web/content/docs/local-docker.mdx index 50b53e73..8db50d4a 100644 --- a/apps/web/content/docs/local-docker.mdx +++ b/apps/web/content/docs/local-docker.mdx @@ -100,7 +100,7 @@ The bind-mounted host `.git/` means commits land on the host immediately; pushes | Base image | `agentbox/box:dev` (Debian-based) | | Build method | Pulled from GHCR by build-context fingerprint; built locally on a pull miss (`--build` forces) | | Docker-in-Docker | Yes (always-on in-box `dockerd`) | -| SSH | None — local Docker exec + TTY bridge | +| SSH | Always-on sshd, published on `127.0.0.1` only (powers `agentbox open` sshfs + Codex); attach still uses Docker exec + TTY bridge | | Arch | Host-native (arm64 on Apple Silicon, amd64 on Intel/Linux) | | Live snapshots | Instant `docker pause`/`unpause` (cgroup freezer); checkpoints are layered `docker commit` | | Preview URL | `https://.localhost` (Portless) + mapped loopback ports | diff --git a/docs/features.md b/docs/features.md index 3561311f..dea35f6c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -21,7 +21,8 @@ Full local-Docker lifecycle (plus parity-tested for cloud via `--provider dayton - `agentbox status` / `logs` — proxy into the in-box `agentbox-ctl` via `docker exec` (see [`in-box-supervisor.md`](./in-box-supervisor.md)). `status` renders `TASKS` + `SERVICES` sections (the service row has a `BLOCKED ON` column for `waiting` services) and reports the claude tmux session state (via the `claude-session` wire op). `agentbox status --set-name ` (and `--clear-name`) **renames** a box: it sets a cosmetic `BoxRecord.displayName` shown in `list` / the hub / the tray, and addressable by `findBox` — the container, git branch, and web/VNC URL keep the original `name`. Pure state mutation (`setBoxDisplayName`), so it works for every provider and any box state; the hub exposes the same via `POST /api/v1/boxes/:id/rename` + a Rename button, and the tray via a per-box "Rename…" menu item. - `agentbox wait ` — blocks until all autostart tasks + services are ready. Thin wrapper over the daemon's `wait-ready` op; useful for scripted readiness gates. - `agentbox code ` — opens **VS Code or Cursor** Desktop against the box via the Dev Containers extension. Both IDEs are supported transparently: by default the CLI prefers `code` and falls back to `cursor` if `code` isn't in PATH; pass `--ide vscode` / `--ide cursor` to force a flavor. Auto-unpauses paused boxes and starts stopped ones (re-launching the ctl/dockerd/Xvnc daemons). Waits for `wait-ready` (default 120s) unless `--no-wait`, then writes `/workspace/.vscode/tasks.json` (sentinel-protected; `--regen-tasks` to overwrite a user-owned file) so the IDE auto-opens terminal panels tailing each service's log. The launcher uses ` --folder-uri "vscode-remote://attached-container+/workspace"` (Cursor inherits the `vscode-remote://` scheme as a VS Code fork); `--print` returns the URI instead. Each box mounts **both** server volume sets so either IDE can attach to any box without recreating: per-box `agentbox-vscode-server-` + `agentbox-cursor-server-` (server binary + TS cache, ~70MB downloaded on first attach), plus the shared `agentbox-vscode-extensions` + `agentbox-cursor-extensions` volumes for downloaded extensions across all boxes. -- `agentbox open --in ` — open the box in a host app instead of Finder (the flag-less `agentbox open` keeps the Finder/sshfs behavior). Targets: `codex` (writes the SSH alias like `shell --ssh-config`, then auto-opens the `codex://settings/connections/ssh/add?name=` deep link — persistent-SSH boxes only, i.e. Hetzner; gated before any ssh-config write), `herdr` / `cmux` (auto-start the box, then open a new workspace in that terminal app running `agentbox attach ` — works from *outside* the app: Herdr via its well-known `~/.config/herdr/herdr.sock`, cmux via its control CLI resolved from PATH or the `/Applications/cmux.app` bundle; cmux needs `socketControlMode: automation` or a socket password for external control — an auth rejection surfaces that as an actionable error), `iterm2` (same attach flow in a new iTerm2 window via the existing AppleScript spawn path — auto-launches the app), and `vscode` (delegates to `agentbox code`). `agentbox open --targets [--json]` is the once-at-launch availability probe (installed apps + per-app provider eligibility) the **AgentBox Tray** app uses to build its per-box "Open In…" submenu — a stable machine surface. Detection + eligibility helpers in `apps/cli/src/commands/_open-in.ts`; command wiring in `open.ts`; the new-pane command keeps the shell alive on exit (`keepShell` in `terminal/host.ts`) so a failed attach leaves a usable pane. +- `agentbox open ` (no `--in`) — **live-mounts** the box's `/workspace` over `sshfs` at `~/.agentbox/mounts//` and reveals it in Finder (both docker and cloud). Every **docker** box now runs an always-on OpenSSH daemon (`openssh-server` baked into the image; `agentbox-sshd-start` launched `docker exec -d --user root` on create/start, mirroring the VNC/dockerd daemons) bound to container :22 but **published only on `127.0.0.1:`** (`docker run -p 127.0.0.1:0:22`, re-resolved on every `start`), key-authed by a per-box ed25519 key minted under `~/.agentbox/boxes//ssh/` (`mintSshKey`, now shared in `@agentbox/sandbox-core`) and installed into the box's `authorized_keys`. The resolved target lands on `BoxRecord.ssh` (`{host, user, identityFile, port}`), regenerated into `~/.agentbox/ssh/config` by `syncAgentboxSshConfig` so `ssh ` and sshfs both work. Requires host `sshfs` + `macFUSE` (`brew install macfuse sshfs`) — both are OPTIONAL `agentbox doctor` checks (warn-not-fail); a missing `sshfs` yields a readable install hint, and a macFUSE-related mount failure appends a `brew install macfuse` hint. `--unmount` tears the mount down, `--path` prints the mount root. Only **SSH-capable providers** sshfs-mount (`SSH_MOUNT_PROVIDERS = docker, hetzner, daytona` in `_open-in.ts`); **vercel/e2b** (no SSH) fail fast with a one-line pointer to `agentbox download` *before* the box is brought online. Legacy docker boxes with no sshd fall back to the old rsync-snapshot export (`--no-refresh`/`--include-node-modules`) — kept for now, to move under `agentbox download` later. Wiring in `packages/sandbox-docker/src/ssh.ts` (`setUpBoxSshd`, `launchSshdDaemon`, `installAuthorizedKey`). +- `agentbox open --in ` — open the box in a host app instead of Finder. Targets: `codex` (writes the SSH alias like `shell --ssh-config`, then auto-opens the `codex://settings/connections/ssh/add?name=` deep link — persistent-SSH boxes: now **docker** (localhost sshd) and Hetzner; gated before any ssh-config write), `herdr` / `cmux` (auto-start the box, then open a new workspace in that terminal app running `agentbox attach ` — works from *outside* the app: Herdr via its well-known `~/.config/herdr/herdr.sock`, cmux via its control CLI resolved from PATH or the `/Applications/cmux.app` bundle; cmux needs `socketControlMode: automation` or a socket password for external control — an auth rejection surfaces that as an actionable error), `iterm2` (same attach flow in a new iTerm2 window via the existing AppleScript spawn path — auto-launches the app), `vscode` (delegates to `agentbox code`), and `finder` (the default no-`--in` behavior: sshfs-mount `/workspace` + reveal; gated to `SSH_MOUNT_PROVIDERS`). `agentbox open --targets [--json]` is the once-at-launch availability probe (installed apps + per-app provider eligibility) that both the **AgentBox Tray** "Open In…" submenu and the **Control Hub** box-detail "Apps" card use — `finder` is a first-class entry there (`available: true`, `providers: SSH_MOUNT_PROVIDERS`), so both surfaces show an **Open in Finder** button only for SSH-capable boxes. Detection + eligibility helpers in `apps/cli/src/commands/_open-in.ts`; command wiring in `open.ts`; the new-pane command keeps the shell alive on exit (`keepShell` in `terminal/host.ts`) so a failed attach leaves a usable pane. - `agentbox shell [-- ...]` — interactive shell convenience: drops you into `bash -l` as `vscode` in `/workspace`. Auto-unpauses paused boxes and starts stopped ones (same recovery as `agentbox code`). By default the interactive shell runs inside a **detachable tmux session** — `Ctrl+a d` detaches without killing it (the same chord `agentbox claude` uses, via the shared `buildTmuxSessionArgs` in `packages/sandbox-docker/src/claude.ts`), and `agentbox shell attach ` reattaches. `--no-tmux` (config key `shell.tmux: false`) runs a plain `docker exec` shell with no session — closing the terminal kills it. One-shot `-- ` and any non-interactive/piped use are **never** tmux-wrapped — they stay on a plain `docker exec` (`bash -l -c ''`) so stdout stays machine-readable. `--user ` overrides the in-container user (the tmux server is per-user); `--no-login` invokes `bash` without `-l`. Forwards host `TERM` so truecolor/hyperlinks survive. The wrapped-pty footer shows the `Control+a d: detach` hint only for the tmux-backed shell — `runWrappedAttach`'s `detachable` flag (default `mode === 'claude'`) drives the chord + footer, decoupled from the `mode` label. - **N shells per box** — a box can hold multiple shell sessions, not just one (Claude stays exactly one agent per box). tmux is the single source of truth — there is **no** `BoxRecord`/`state.json` shell registry; everything is derived live from `docker exec tmux list-sessions`. Shell sessions are tmux sessions named with a reserved `shell` prefix: `shell` (the default, `agentbox shell`), `shell-2`/`shell-3`/… (auto-numbered, `agentbox shell --new`), and `shell-