diff --git a/README.md b/README.md index 58d31dc4..87efa7fa 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Want to help test what's coming? `npm -g install @madarco/agentbox@nightly` puts [nightly channel](https://agent-box.sh/docs/nightly) — pre-release builds, with stable releases still reaching you automatically. `agentbox self-update --channel stable` opts back out. -Requirements: macOS (arm64 or Intel) or Linux, Docker ([Docker Desktop](https://www.docker.com/products/docker-desktop/) or [OrbStack](https://orbstack.dev/)), Node `>=20.10`. The first `agentbox create` / `agentbox claude` builds the `agentbox/box:dev` image (~1 GB, one-time). +Requirements: macOS (arm64 or Intel) or Linux, Docker ([Docker Desktop](https://www.docker.com/products/docker-desktop/), [OrbStack](https://orbstack.dev/) or [Colima](https://github.com/abiosoft/colima) — colima works best via the `remote-docker` provider, see [local-docker](/docs/local-docker) for the engine notes), Node `>=20.10`. The first `agentbox create` / `agentbox claude` builds the `agentbox/box:dev` image (~1 GB, one-time). Uses `portless` to give box web apps the same URL from inside the box and on the host. ## Cloud Providers diff --git a/apps/web/content/docs/configuration.mdx b/apps/web/content/docs/configuration.mdx index 16d43205..0b2fb258 100644 --- a/apps/web/content/docs/configuration.mdx +++ b/apps/web/content/docs/configuration.mdx @@ -430,27 +430,27 @@ See [background and parallel](/docs/background-and-parallel) and [checkpoints an The remaining infrastructure knobs — mostly advanced. -| Key | Type | Default | Meaning | -| -------------------------------------- | ----------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cloud.useCurrentBranch` | bool | `false` | on daytona/hetzner, start boxes on the host's current branch instead of forking `agentbox/`; overridden by `--use-branch` / `--from-branch` | -| `cloud.viaHub` | bool | `true` | when a control box is configured (`relay.controlPlaneUrl`), build **cloud** boxes on it by default (so they keep running with the laptop off) instead of on this machine; overridden per-command by `--via-hub` / `--local`. Docker and remote-docker always build locally. Cloud **base bakes** (`agentbox prepare`) follow the same routing, and this machine's hub UI mirrors the control box's provider state. See [deployed hub](/docs/deployed-hub) | -| `engine.kind` | enum `orbstack`/`docker-desktop`/`other`/`auto` | `auto` | override docker-engine auto-detection (which otherwise reads `docker info` once per process — restart the hub/relay after switching engines) | -| `portless.enabled` | bool | prompt | map each box web app to `https://.localhost` via the Portless proxy (Docker Desktop only) | -| `portless.stateDir` | string (advanced) | empty | host Portless state dir to share into boxes | -| `relay.port` | int (advanced) | `8787` | TCP port the host daemon binds. Governs the lean relay **and** the hub — the hub is the same relay plus the Web UI in one process, and the two are mutually exclusive on the port, so they always move together. Machine-wide (`~/.agentbox/relay.pid` and `relay.log` are single), so set it `--global` and run `agentbox relay restart` (plus `agentbox hub restart` if a hub is running). Docker boxes get it baked in at create time via `host.docker.internal:`, and container env is immutable — so boxes created before the change keep dialling the OLD port and their host actions (git push, `cp`, checkpoint) fail with `relay returned 502`. Recreate those boxes; `agentbox doctor` names any that are stranded. Cloud boxes are unaffected (the host polls them). `8788` is refused — every box binds that internally for its own in-box relay. | -| `relay.controlPlaneUrl` | string | empty | URL of a deployed control plane; cloud boxes point at it for git-token leasing, permission state, and the registry/events so they keep working with the laptop off. Set via `agentbox hub set-url` | -| `hub.gitAuth` | enum `gh`/`app` | `gh` | which git credential a deployed [control box](/docs/deployed-hub) uses: `gh` (the hub holds a GitHub token taken from your own `gh` login and does the git work itself, so boxes never receive a credential and nothing needs installing on GitHub) or `app` (the hub holds a GitHub App key and leases a 1-hour, single-repo token to each box — tighter, but the repo owner must install the App). Deploy intent: it selects what `agentbox hub setup` / `hub deploy` provisions and which push mode a cloud box gets; it can't reconfigure a hub that's already running | -| `git.pushMode` | enum `auto`/`relay`/`lease`/`direct` | `auto` | how a **cloud** box's `git push` reaches GitHub (docker always uses `relay`): `relay` (the host relay pushes with your host credentials — they never enter the box), `lease` (the relay/plane leases a short-lived GitHub-App token and the box pushes directly, so it works with the laptop off), `direct` (the box holds a **copy** of your git credentials and pushes on its own — set via `--with-credentials`), or `auto` (lease when `relay.controlPlaneUrl` is set, else relay). `direct` is **refused when a control box is configured** — use leasing (the `auto` default) instead, which does the same job without copying the credential into the box and its snapshots | -| `hub.mode` | enum `auto`/`thin`/`local` | `auto` | whether the local docker engine is offered on this machine. `auto` gates `docker`/`remote-docker` off once a [control box](/docs/deployed-hub) is configured (`relay.controlPlaneUrl`) — a docker box built on your laptop can't run with the laptop off, so `create --provider docker` is refused, docker rows drop out of the provider pickers / `doctor` / `prepare`, and docker boxes show as inactive in `ls`. `thin` forces that even with no control box. **With a control box configured, boxes are built by the control box and local docker on that machine is not supported** — `local` still builds one, but this machine's hub owns it, so it never appears in `agentbox list` against the control box and every `agentbox git` command against it fails; creates and bakes warn. `local` is the real escape hatch only for `thin` with no control box. Exception: with [`hub expose`](/docs/deployed-hub) the control box IS this machine, so docker stays on and nothing warns | -| `relay.custodyMaxBodyBytes` | int (advanced) | `33554432` (32 MiB) | per-request body cap for the custody **JSON** API — the simple base64-in-JSON surface used for small values (credentials, `.env`, SSH keys). Large payloads use the streaming blob API instead (see below). Applies only to custody, so every other route keeps the 1 MiB cap. A control box enforces its own via `AGENTBOX_CUSTODY_MAX_BODY_BYTES`, so raise **both** | -| `relay.custodyMaxBlobBytes` | int (advanced) | `104857600` (100 MiB) | per-request cap for the streaming custody **blob** API, which carries payloads too large to buffer as base64 — chiefly a project's `carry:` material. Defaults to the same value as `box.cpMaxBytes` so an entry the carry prompt accepted can actually be stored on a control box; if the two disagree, AgentBox would offer to copy a file the transport then refuses. Enforced mid-upload. A control box enforces its own via `AGENTBOX_CUSTODY_MAX_BLOB_BYTES`, so raise **both** | -| `relay.hostReachTimeoutMs` | int (advanced) | `60000` (60s) | how long a **control box** waits for your machine to pick up a copy between a box and your files before treating it as offline — and how long a silence must last before a machine that already took one is presumed gone. Your machine long-polls the hub while its relay runs, so this is a missed-heartbeat window, not a per-copy timeout: once the copy reaches your machine it can wait on your approval indefinitely. Only meaningful with `relay.controlPlaneUrl` set; lower it to fall back to the hub's cache sooner on a flaky link, raise it if your machine sleeps often | -| `vnc.containerPort` | int (advanced) | `6080` | container-side noVNC port | -| `maintenance.pruneProjectConfigs` | bool | `true` | periodically delete `~/.agentbox/projects//` dirs whose source folder is gone | -| `maintenance.pruneProjectConfigsEvery` | int | `50` | run the orphan sweep every N successful `agentbox create` | -| `update.check` | bool | `true` | daily background check for a newer published CLI (npm) and menu-bar app (release checksum), plus the "newer version available" nudge; at most one network probe per 24h — `false` disables both | -| `update.channel` | enum | `auto` | release channel `self-update` and `install app` follow: `auto` follows the installed build, `nightly` opts into [pre-release builds](/docs/nightly), `stable` opts back out. Nightly installs the newest build of _either_ channel, so a stable release supersedes the nightlies before it | -| `update.registry` | string | `https://registry.npmjs.org` | npm registry [provider-plugin](/docs/build-a-provider) updates are resolved against, for `agentbox self-update` and `agentbox plugin update`. Point it at a mirror or a private registry when your plugins are not on the public one | +| Key | Type | Default | Meaning | +| -------------------------------------- | -------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cloud.useCurrentBranch` | bool | `false` | on daytona/hetzner, start boxes on the host's current branch instead of forking `agentbox/`; overridden by `--use-branch` / `--from-branch` | +| `cloud.viaHub` | bool | `true` | when a control box is configured (`relay.controlPlaneUrl`), build **cloud** boxes on it by default (so they keep running with the laptop off) instead of on this machine; overridden per-command by `--via-hub` / `--local`. Docker and remote-docker always build locally. Cloud **base bakes** (`agentbox prepare`) follow the same routing, and this machine's hub UI mirrors the control box's provider state. See [deployed hub](/docs/deployed-hub) | +| `engine.kind` | enum `orbstack`/`docker-desktop`/`colima`/`other`/`auto` | `auto` | override docker-engine auto-detection (which otherwise reads `docker info` once per process — restart the hub/relay after switching engines) | +| `portless.enabled` | bool | prompt | map each box web app to `https://.localhost` via the Portless proxy (Docker Desktop only) | +| `portless.stateDir` | string (advanced) | empty | host Portless state dir to share into boxes | +| `relay.port` | int (advanced) | `8787` | TCP port the host daemon binds. Governs the lean relay **and** the hub — the hub is the same relay plus the Web UI in one process, and the two are mutually exclusive on the port, so they always move together. Machine-wide (`~/.agentbox/relay.pid` and `relay.log` are single), so set it `--global` and run `agentbox relay restart` (plus `agentbox hub restart` if a hub is running). Docker boxes get it baked in at create time via `host.docker.internal:`, and container env is immutable — so boxes created before the change keep dialling the OLD port and their host actions (git push, `cp`, checkpoint) fail with `relay returned 502`. Recreate those boxes; `agentbox doctor` names any that are stranded. Cloud boxes are unaffected (the host polls them). `8788` is refused — every box binds that internally for its own in-box relay. | +| `relay.controlPlaneUrl` | string | empty | URL of a deployed control plane; cloud boxes point at it for git-token leasing, permission state, and the registry/events so they keep working with the laptop off. Set via `agentbox hub set-url` | +| `hub.gitAuth` | enum `gh`/`app` | `gh` | which git credential a deployed [control box](/docs/deployed-hub) uses: `gh` (the hub holds a GitHub token taken from your own `gh` login and does the git work itself, so boxes never receive a credential and nothing needs installing on GitHub) or `app` (the hub holds a GitHub App key and leases a 1-hour, single-repo token to each box — tighter, but the repo owner must install the App). Deploy intent: it selects what `agentbox hub setup` / `hub deploy` provisions and which push mode a cloud box gets; it can't reconfigure a hub that's already running | +| `git.pushMode` | enum `auto`/`relay`/`lease`/`direct` | `auto` | how a **cloud** box's `git push` reaches GitHub (docker always uses `relay`): `relay` (the host relay pushes with your host credentials — they never enter the box), `lease` (the relay/plane leases a short-lived GitHub-App token and the box pushes directly, so it works with the laptop off), `direct` (the box holds a **copy** of your git credentials and pushes on its own — set via `--with-credentials`), or `auto` (lease when `relay.controlPlaneUrl` is set, else relay). `direct` is **refused when a control box is configured** — use leasing (the `auto` default) instead, which does the same job without copying the credential into the box and its snapshots | +| `hub.mode` | enum `auto`/`thin`/`local` | `auto` | whether the local docker engine is offered on this machine. `auto` gates `docker`/`remote-docker` off once a [control box](/docs/deployed-hub) is configured (`relay.controlPlaneUrl`) — a docker box built on your laptop can't run with the laptop off, so `create --provider docker` is refused, docker rows drop out of the provider pickers / `doctor` / `prepare`, and docker boxes show as inactive in `ls`. `thin` forces that even with no control box. **With a control box configured, boxes are built by the control box and local docker on that machine is not supported** — `local` still builds one, but this machine's hub owns it, so it never appears in `agentbox list` against the control box and every `agentbox git` command against it fails; creates and bakes warn. `local` is the real escape hatch only for `thin` with no control box. Exception: with [`hub expose`](/docs/deployed-hub) the control box IS this machine, so docker stays on and nothing warns | +| `relay.custodyMaxBodyBytes` | int (advanced) | `33554432` (32 MiB) | per-request body cap for the custody **JSON** API — the simple base64-in-JSON surface used for small values (credentials, `.env`, SSH keys). Large payloads use the streaming blob API instead (see below). Applies only to custody, so every other route keeps the 1 MiB cap. A control box enforces its own via `AGENTBOX_CUSTODY_MAX_BODY_BYTES`, so raise **both** | +| `relay.custodyMaxBlobBytes` | int (advanced) | `104857600` (100 MiB) | per-request cap for the streaming custody **blob** API, which carries payloads too large to buffer as base64 — chiefly a project's `carry:` material. Defaults to the same value as `box.cpMaxBytes` so an entry the carry prompt accepted can actually be stored on a control box; if the two disagree, AgentBox would offer to copy a file the transport then refuses. Enforced mid-upload. A control box enforces its own via `AGENTBOX_CUSTODY_MAX_BLOB_BYTES`, so raise **both** | +| `relay.hostReachTimeoutMs` | int (advanced) | `60000` (60s) | how long a **control box** waits for your machine to pick up a copy between a box and your files before treating it as offline — and how long a silence must last before a machine that already took one is presumed gone. Your machine long-polls the hub while its relay runs, so this is a missed-heartbeat window, not a per-copy timeout: once the copy reaches your machine it can wait on your approval indefinitely. Only meaningful with `relay.controlPlaneUrl` set; lower it to fall back to the hub's cache sooner on a flaky link, raise it if your machine sleeps often | +| `vnc.containerPort` | int (advanced) | `6080` | container-side noVNC port | +| `maintenance.pruneProjectConfigs` | bool | `true` | periodically delete `~/.agentbox/projects//` dirs whose source folder is gone | +| `maintenance.pruneProjectConfigsEvery` | int | `50` | run the orphan sweep every N successful `agentbox create` | +| `update.check` | bool | `true` | daily background check for a newer published CLI (npm) and menu-bar app (release checksum), plus the "newer version available" nudge; at most one network probe per 24h — `false` disables both | +| `update.channel` | enum | `auto` | release channel `self-update` and `install app` follow: `auto` follows the installed build, `nightly` opts into [pre-release builds](/docs/nightly), `stable` opts back out. Nightly installs the newest build of _either_ channel, so a stable release supersedes the nightlies before it | +| `update.registry` | string | `https://registry.npmjs.org` | npm registry [provider-plugin](/docs/build-a-provider) updates are resolved against, for `agentbox self-update` and `agentbox plugin update`. Point it at a mirror or a private registry when your plugins are not on the public one | See [web apps and tunnels](/docs/web-apps-and-tunnels), [local Docker](/docs/local-docker), [sync and git](/docs/sync-and-git), and [browser and screen](/docs/browser-and-screen). diff --git a/apps/web/content/docs/local-docker.mdx b/apps/web/content/docs/local-docker.mdx index 62209358..5936aafc 100644 --- a/apps/web/content/docs/local-docker.mdx +++ b/apps/web/content/docs/local-docker.mdx @@ -10,7 +10,8 @@ Each box is one local container. `/workspace` is an in-container git worktree on ## Requirements - macOS (arm64 or Intel) or Linux. -- A Docker engine: **Docker Desktop** or **OrbStack**. +- A Docker engine: **Docker Desktop**, **OrbStack** — or **Colima** (see the + [Colima notes](#colima) below for what works and what doesn't). - Node `>=20.10`. - First create pulls (or builds) the `agentbox/box:dev` image, a one-time ~1 GB cost covered below. @@ -30,7 +31,25 @@ agentbox install Installation basics are also covered in [teleport-a-project](/docs/teleport-a-project); override the engine with `engine.kind` in [configuration](/docs/configuration). -Which engine you are on is read off the daemon itself — `docker info`'s operating system, matched against OrbStack and Docker Desktop — and it decides host-side conventions like a box's `.orb.local` URL. It is resolved once per process, so after switching engines restart the two long-running ones (`agentbox hub restart`, `agentbox relay restart`) or they keep minting URLs for the engine they started on. Boxes created on the old engine stay on it — that daemon still owns their containers. Pin the answer with `engine.kind` to take the detection out of the loop. +Which engine you are on is read off the daemon itself — `docker info`'s operating system and hostname, matched against OrbStack, Docker Desktop and Colima — and it decides host-side conventions like a box's `.orb.local` URL. It is resolved once per process, so after switching engines restart the two long-running ones (`agentbox hub restart`, `agentbox relay restart`) or they keep minting URLs for the engine they started on. Boxes created on the old engine stay on it — that daemon still owns their containers. Pin the answer with `engine.kind` to take the detection out of the loop. + +## Colima + +[Colima](https://github.com/abiosoft/colima) is detected and supported, with one structural caveat. + +**The supported path is `remote-docker` on the same machine.** The [remote-docker](/docs/remote-docker) provider syncs the workspace into the box instead of bind-mounting your `.git`, so it is unaffected by colima's file-sharing semantics — and its `ssh bash -lc 'docker …'` transport finds the Homebrew-installed `docker` CLI that colima uses. With SSH (Remote Login) enabled: + +```bash +agentbox remote-docker add colima localhost +agentbox docker:colima create -y +``` + +**Local docker boxes on colima fail fast with guidance.** Local boxes bind-mount the host's `.git/` and run in-box git as the box user (`vscode`, uid 1000). Colima shares macOS files with their **macOS** ownership, so bind-mounted `.git` keeps your mac uid (typically 501) and the box's git can't write it — `create` stops with a clear message pointing here rather than a cryptic permission error. It only works if your macOS uid happens to be 1000. Two things make the local path behave right on colima: + +- AgentBox does **not** pass `--add-host=host.docker.internal:host-gateway` on colima — the flag would override colima's native `host.docker.internal → macOS host` mapping with the VM's docker0 gateway and cut the box off from the host relay. +- The engine is detected from the colima VM's hostname (`colima`), so `engine.kind: colima` is a valid pin and `agentbox doctor` names the engine correctly. + +Docker Desktop / OrbStack remain the smooth engines for local boxes on macOS. ## First-run image diff --git a/apps/web/content/docs/remote-docker.mdx b/apps/web/content/docs/remote-docker.mdx index 41d4f47a..0d6cbd04 100644 --- a/apps/web/content/docs/remote-docker.mdx +++ b/apps/web/content/docs/remote-docker.mdx @@ -181,7 +181,7 @@ back to `docker`. - **Boxes are unlimited by default.** The remote engine is _your_ machine, so a box isn't capped at some cloud default. Cap it if you want to: `--size 4-8` gives it 4 CPUs and 8 GB (`--cpus` / `--memory` on the container). - **Ports are fixed at create.** Docker port mappings are immutable for a container's life, so a service you add to `agentbox.yaml` _after_ the box exists is still reachable through the box's web proxy, but won't get its own preview URL until you recreate it. -- **macOS remotes work.** Docker Desktop, OrbStack and Colima are all found, because AgentBox runs `docker` in a login shell on the remote (which is why OrbStack's `~/.orbstack/bin` is on the PATH). +- **macOS remotes work.** Docker Desktop, OrbStack and Colima are all found, because AgentBox runs `docker` in a login shell on the remote (which is why OrbStack's `~/.orbstack/bin` is on the PATH). A colima-driven Mac is a first-class remote — and the supported way to use colima at all, since local docker boxes on colima hit its macOS file-ownership sharing (see [local-docker](/docs/local-docker)). - **The remote engine is trusted.** Anyone with root on that machine can read the box. This is a "your own machine" provider, not an isolation boundary against the machine's owner. - **Concurrent boxes share the machine.** Nothing arbitrates between them beyond the limits you set. - **Host aliases are per machine until you share them.** `agentbox remote-docker add` writes `~/.agentbox/remote-docker-hosts.json` on the machine you ran it on. A [control box](/docs/deployed-hub) has neither that file nor your `~/.ssh/config`, so it knows nothing about your engines until you run `agentbox remote-docker share ` — see below. diff --git a/packages/config/schema/user-config.schema.json b/packages/config/schema/user-config.schema.json index 7af8f7c6..4c3eff72 100644 --- a/packages/config/schema/user-config.schema.json +++ b/packages/config/schema/user-config.schema.json @@ -454,6 +454,7 @@ "enum": [ "orbstack", "docker-desktop", + "colima", "other", "auto" ] diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index fc6136e5..b6459ff6 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -13,7 +13,7 @@ import { pluginAgentSettings } from './agent-plugins.js'; import { PROVIDERS, PROVIDER_NAMES, perProviderConfigKey, type ProviderKind } from './providers.js'; export type IdeFlavor = 'vscode' | 'cursor' | 'auto'; -export type EngineKind = 'orbstack' | 'docker-desktop' | 'other' | 'auto'; +export type EngineKind = 'orbstack' | 'docker-desktop' | 'colima' | 'other' | 'auto'; export type BrowserKind = 'agent-browser' | 'playwright' | 'both'; /** Sandbox backend new boxes are created on. Defined in `providers.ts` (the single source of truth) and re-exported here for back-compat. */ export type { ProviderKind }; @@ -1262,9 +1262,9 @@ export const BUILTIN_KEY_REGISTRY: readonly KeyDescriptor[] = [ { key: 'engine.kind', type: 'enum', - enumValues: ['orbstack', 'docker-desktop', 'other', 'auto'] as const, + enumValues: ['orbstack', 'docker-desktop', 'colima', 'other', 'auto'] as const, description: - 'Override the docker-engine auto-detection (used for OrbStack-only optimisations).', + 'Override the docker-engine auto-detection (used for OrbStack-only optimisations, and to name colima for the local-docker/colima notes).', }, { key: 'browser.default', diff --git a/packages/config/test/coerce.test.ts b/packages/config/test/coerce.test.ts index 3f5bf4ec..a452c269 100644 --- a/packages/config/test/coerce.test.ts +++ b/packages/config/test/coerce.test.ts @@ -43,6 +43,7 @@ describe('coerceFromString', () => { it('accepts valid enum values', () => { expect(coerceFromString('engine.kind', 'orbstack')).toBe('orbstack'); + expect(coerceFromString('engine.kind', 'colima')).toBe('colima'); expect(coerceFromString('code.ide', 'cursor')).toBe('cursor'); }); diff --git a/packages/config/test/schema-drift.test.ts b/packages/config/test/schema-drift.test.ts index 8b074515..95bf5551 100644 --- a/packages/config/test/schema-drift.test.ts +++ b/packages/config/test/schema-drift.test.ts @@ -24,6 +24,7 @@ const VALID: Fixture[] = [ { name: 'box defaultCheckpoint', yaml: 'box:\n defaultCheckpoint: warm-1\n' }, { name: 'checkpoint maxLayers', yaml: 'checkpoint:\n maxLayers: 3\n' }, { name: 'engine override', yaml: 'engine:\n kind: orbstack\n' }, + { name: 'engine colima', yaml: 'engine:\n kind: colima\n' }, { name: 'kitchen sink', yaml: ` diff --git a/packages/sandbox-docker/src/create.ts b/packages/sandbox-docker/src/create.ts index 9905a6aa..0417e6b4 100644 --- a/packages/sandbox-docker/src/create.ts +++ b/packages/sandbox-docker/src/create.ts @@ -46,6 +46,7 @@ import { removeInBoxWorktree, seedWorkspace, seedWorkspaceFromDir, + unwritableGitBind, type RepoCarryOver, type RestoreWorktreePlan, } from './sync/in-box-git.js'; @@ -1000,9 +1001,19 @@ export async function createBox(opts: CreateBoxOptions): Promise { createdAt, }; + // Engine kind resolves host-side conventions that differ between engines. + // Cached per process, so this is one `docker info` at most. Used right below + // for the host-gateway add-host, and later for the portless/colima branches. + const engine = await detectEngine(); + await runBox({ name: containerName, image: imageRef, + // Colima resolves host.docker.internal to the macOS host itself; the + // host-gateway entry would override that with the VM's docker0 gateway and + // cut the box off from the host relay. Every other engine needs (or + // tolerates) the entry. + addHostGateway: engine !== 'colima', extraVolumes, limits: effectiveLimits, portMappings: [...vncPortMappings, ...webPortMappings, ...sshPortMappings], @@ -1061,6 +1072,22 @@ export async function createBox(opts: CreateBoxOptions): Promise { // layer at /workspace). if (!checkpointImage) { if (repoCarryOvers.length > 0) { + // Colima shares the host's bind-mounted `.git` with macOS ownership, so + // the box user (vscode/1000) usually can't write it and `git worktree + // add` fails with a cryptic EACCES. Surface that before seeding. + if (engine === 'colima') { + const unwritable = await unwritableGitBind(containerName, repoCarryOvers); + if (unwritable !== null) { + // Nothing to inspect here — the engine can't host the .git bind, so + // tear the container + provisional record down like the useBranch + // seed-failure path does. + await execa('docker', ['rm', '-f', containerName], { reject: false }); + await removeBoxRecord(id); + throw new Error( + `colima engine: the box's agent (uid 1000) cannot write the host repo at ${unwritable} — colima mounts macOS files with their macOS ownership, so the bind-mounted .git is not writable by the box user. Create the box on this machine via remote-docker instead (\`agentbox remote-docker add localhost\`, needs SSH/Remote Login, then \`agentbox docker: create\`), or run Docker Desktop / OrbStack for local boxes. See the local-docker docs for the colima notes.`, + ); + } + } try { await seedWorkspace({ container: containerName, @@ -1284,7 +1311,7 @@ export async function createBox(opts: CreateBoxOptions): Promise { let portlessVncUrl: string | undefined; if (opts.portless === true && (webHostPort || (vncEnabled && vncHostPort))) { try { - const engine = await detectEngine(); + // `engine` resolved above (before runBox), cached per process. if (engine === 'orbstack') { log('portless: skipped (OrbStack already provides .orb.local)'); } else { diff --git a/packages/sandbox-docker/src/docker.ts b/packages/sandbox-docker/src/docker.ts index b5cff4c2..8b0c8964 100644 --- a/packages/sandbox-docker/src/docker.ts +++ b/packages/sandbox-docker/src/docker.ts @@ -41,6 +41,15 @@ export interface RunBoxSpec { * `127.0.0.1` for loopback-only exposure. */ portMappings?: Array<{ hostPort: number; containerPort: number; hostIp?: string }>; + /** + * Add the `--add-host=host.docker.internal:host-gateway` entry (default + * true). Colima must NOT get it: colima resolves `host.docker.internal` to + * the macOS host through its own network layer, and the explicit host-gateway + * entry overrides that with the VM's docker0 bridge gateway (172.17.0.1) — + * the box then can't reach the host relay. `createBox` computes this from the + * detected engine. + */ + addHostGateway?: boolean; } export async function runBox(spec: RunBoxSpec): Promise { @@ -78,10 +87,16 @@ export async function runBox(spec: RunBoxSpec): Promise { '--cgroupns=private', // Make the host reachable from inside the container at the well-known DNS // name host.docker.internal. Docker Desktop / OrbStack ship this alias by - // default; on Linux native Docker it requires this explicit flag (no-op - // on the macOS engines). Boxes use it to reach the host relay process. - '--add-host=host.docker.internal:host-gateway', + // default; on Linux native Docker it requires an explicit entry (no-op on + // the macOS engines). Colima is the exception: it maps the name to the + // macOS host itself, and the entry would override that with the VM's + // docker0 gateway — see `addHostGateway` above. createBox passes + // `addHostGateway: false` for colima, so the entry is pushed after the + // literal below. ]; + if (spec.addHostGateway !== false) { + args.push('--add-host=host.docker.internal:host-gateway'); + } // Nested AgentBox-in-AgentBox dev only: the outer sandbox lacks // CAP_SYS_PTRACE, so the inner dockerd can't bind-mount /proc//ns/net // for a non-root container init (image default USER is vscode → uid 1000). diff --git a/packages/sandbox-docker/src/provider-module.ts b/packages/sandbox-docker/src/provider-module.ts index 3daef146..9c3accd6 100644 --- a/packages/sandbox-docker/src/provider-module.ts +++ b/packages/sandbox-docker/src/provider-module.ts @@ -43,7 +43,7 @@ export async function dockerChecks(): Promise { detail: 'not found', hint: linux ? 'install docker engine: https://docs.docker.com/engine/install/' - : 'install Docker Desktop, OrbStack, or docker engine', + : 'install Docker Desktop, OrbStack, or Colima', }, ]; } @@ -92,7 +92,8 @@ export async function dockerChecks(): Promise { } else if (linux) { hint = 'start Docker: `sudo systemctl start docker` (install docker engine if missing)'; } else { - hint = 'start Docker (Desktop / OrbStack)'; + hint = + 'start Docker (Desktop / OrbStack) — or `colima start` + `docker context use colima` if you run Colima'; } return [ cliRes, diff --git a/packages/sandbox-docker/src/sync/host-export.ts b/packages/sandbox-docker/src/sync/host-export.ts index 24fd7870..b0d59e03 100644 --- a/packages/sandbox-docker/src/sync/host-export.ts +++ b/packages/sandbox-docker/src/sync/host-export.ts @@ -33,7 +33,7 @@ export { buildHostEnvFindArgs, } from '@agentbox/sandbox-core'; -export type DockerEngine = 'orbstack' | 'docker-desktop' | 'other'; +export type DockerEngine = 'orbstack' | 'docker-desktop' | 'colima' | 'other'; /** In-container path bind-mounted to the per-box host export dir by createBox. */ export const CONTAINER_EXPORT_MERGED = '/host-export'; @@ -69,9 +69,11 @@ async function pinnedEngine(): Promise { /** * Which docker engine this host runs, deciding the host-side conventions that * differ between them (a box's `.orb.local` URL, Portless, volume paths). - * `docker info --format '{{.OperatingSystem}}'` returns strings like "OrbStack" - * or "Docker Desktop" — the only two that matter on macOS. An `engine.kind` - * pin wins over the probe. + * `docker info --format '{{.OperatingSystem}}|{{.Name}}'` returns strings like + * "OrbStack" or "Docker Desktop" for the first two macOS engines and the + * daemon's hostname (`colima` for a colima VM, `ubuntu`/… for native Linux) in + * the second field. The first two fields are the ones that matter on macOS; an + * `engine.kind` pin wins over the probe. * * Resolved ONCE per process. Switching engines is a thing people do about * never, so the cost of noticing (a `docker info` on a live timer, in every @@ -87,12 +89,19 @@ export async function detectEngine(): Promise { cachedEngine = pinned; return cachedEngine; } - const result = await execa('docker', ['info', '--format', '{{.OperatingSystem}}'], { + const result = await execa('docker', ['info', '--format', '{{.OperatingSystem}}|{{.Name}}'], { reject: false, }); - const os = (result.stdout ?? '').trim().toLowerCase(); - if (os.includes('orbstack')) cachedEngine = 'orbstack'; - else if (os.includes('docker desktop')) cachedEngine = 'docker-desktop'; + const [os, name] = (result.stdout ?? '').trim().toLowerCase().split('|'); + if ((os ?? '').includes('orbstack') || (name ?? '').includes('orbstack')) + cachedEngine = 'orbstack'; + else if ((os ?? '').includes('docker desktop') || (name ?? '').includes('docker desktop')) + cachedEngine = 'docker-desktop'; + // The colima VM's hostname is `colima`, so the daemon's Name field identifies + // it even though the OperatingSystem field reports the guest distro + // ("Ubuntu 22.04.5 LTS"). The colima docker context is also named `colima`. + else if ((os ?? '').includes('colima') || (name ?? '').includes('colima')) + cachedEngine = 'colima'; else cachedEngine = 'other'; return cachedEngine; } diff --git a/packages/sandbox-docker/src/sync/in-box-git.ts b/packages/sandbox-docker/src/sync/in-box-git.ts index 29c9ca27..42ed3282 100644 --- a/packages/sandbox-docker/src/sync/in-box-git.ts +++ b/packages/sandbox-docker/src/sync/in-box-git.ts @@ -230,6 +230,38 @@ export async function chownGitBindParents(args: { } } +/** + * Colima fail-fast: colima's file sharing (virtiofs on `vz`, sshfs on `qemu`) + * presents macOS bind mounts with their **macOS** ownership, so the host's + * bind-mounted `.git/` keeps its mac-user uid (typically 501) inside the box — + * while the box's agent runs as `vscode` (uid 1000). `git worktree add` (which + * registers into the bind, as `vscode`) then dies with a cryptic EACCES at + * create. Probes each bind's writability as the box user and returns the first + * unwritable repo path, or null when everything is writable (a macOS uid of + * 1000, or an engine that translates ownership, proceeds normally). + * + * Only called when the engine is colima — other engines get Docker + * Desktop/OrbStack's ownership translation or uid-matched binds for free. + */ +export async function unwritableGitBind( + container: string, + repos: Array<{ repo: { hostMainRepo: string } }>, +): Promise { + for (const r of repos) { + const gitdir = `${r.repo.hostMainRepo}/.git`; + // Inside the box the host repo sits at the identical absolute path (the + // bind mount), so the container-side path is the host path. + const result = await execa( + 'docker', + ['exec', '--user', 'vscode', container, 'sh', '-c', `test -w '${gitdir}'`], + { reject: false }, + ); + // `test -w` exits non-zero when the box user can't write the dir. + if (result.exitCode !== 0) return gitdir; + } + return null; +} + export async function bindWorktrees( container: string, binds: WorktreeBindSpec[], diff --git a/packages/sandbox-docker/src/vnc.ts b/packages/sandbox-docker/src/vnc.ts index b524d8e5..b8c439c0 100644 --- a/packages/sandbox-docker/src/vnc.ts +++ b/packages/sandbox-docker/src/vnc.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto'; import { buildNoVncUrl, type BoxRecord, type Provider } from '@agentbox/core'; import { execInBox } from './docker.js'; -import { detectEngine } from './sync/host-export.js'; +import { detectEngine, type DockerEngine } from './sync/host-export.js'; export interface VncLaunchResult { up: boolean; @@ -99,7 +99,7 @@ export function buildVncUrls( portlessVncAlias?: string; portlessVncUrl?: string; }, - engine: 'orbstack' | 'docker-desktop' | 'other', + engine: DockerEngine, ): VncUrls { if (!record.vncEnabled || !record.vncPassword) return {}; const containerPort = record.vncContainerPort ?? VNC_CONTAINER_PORT; diff --git a/packages/sandbox-docker/test/docker.test.ts b/packages/sandbox-docker/test/docker.test.ts new file mode 100644 index 00000000..412d9690 --- /dev/null +++ b/packages/sandbox-docker/test/docker.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execaMock } = vi.hoisted(() => ({ execaMock: vi.fn() })); +vi.mock('execa', () => ({ execa: execaMock })); + +import { runBox } from '../src/docker.js'; +import { unwritableGitBind } from '../src/sync/in-box-git.js'; + +describe('runBox', () => { + it('passes the host-gateway add-host by default (Linux native docker needs it)', async () => { + execaMock.mockResolvedValue({ stdout: 'agentbox-test-1', stderr: '', exitCode: 0 }); + await runBox({ name: 'agentbox-test-1', image: 'agentbox/box:dev' }); + const [cmd, args] = execaMock.mock.calls[0] as [string, string[]]; + expect(cmd).toBe('docker'); + expect(args).toContain('--add-host=host.docker.internal:host-gateway'); + }); + + it('omits the host-gateway add-host when addHostGateway is false (colima)', async () => { + execaMock.mockReset().mockResolvedValue({ stdout: 'agentbox-test-2', stderr: '', exitCode: 0 }); + await runBox({ name: 'agentbox-test-2', image: 'agentbox/box:dev', addHostGateway: false }); + const [, args] = execaMock.mock.calls[0] as [string, string[]]; + expect(args).not.toContain('--add-host=host.docker.internal:host-gateway'); + }); +}); + +describe('unwritableGitBind', () => { + beforeEach(() => { + execaMock.mockReset(); + }); + + it('returns the first repo whose bind-mounted .git the box user cannot write', async () => { + // vscode can write repo A's .git (exit 0), not repo B's (exit 1). + execaMock + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 1 }); + const bad = await unwritableGitBind('agentbox-test', [ + { repo: { hostMainRepo: '/Users/me/ok' } }, + { repo: { hostMainRepo: '/Users/me/bad' } }, + ]); + expect(bad).toBe('/Users/me/bad/.git'); + // The probe runs as the box user against the exact bind path. + const [, args] = execaMock.mock.calls[1] as [string, string[]]; + expect(args[1]).toBe('--user'); + expect(args[2]).toBe('vscode'); + expect(args[6]).toContain("test -w '/Users/me/bad/.git'"); + }); + + it('returns null when every bind is writable', async () => { + execaMock.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); + expect( + await unwritableGitBind('agentbox-test', [{ repo: { hostMainRepo: '/Users/me/ok' } }]), + ).toBeNull(); + }); +}); diff --git a/packages/sandbox-docker/test/engine-detect.test.ts b/packages/sandbox-docker/test/engine-detect.test.ts index 4e732f76..685129ab 100644 --- a/packages/sandbox-docker/test/engine-detect.test.ts +++ b/packages/sandbox-docker/test/engine-detect.test.ts @@ -38,6 +38,16 @@ describe('detectEngine', () => { expect(await detectEngine()).toBe('docker-desktop'); }); + it('detects colima from the VM hostname field even when the OS reports a plain distro', async () => { + // colima's `docker info` reports the guest distro as OperatingSystem and + // the VM hostname (`colima`) as Name — the probe carries both. + daemonSays('Ubuntu 22.04.5 LTS|colima'); + expect(await detectEngine()).toBe('colima'); + setEngineOverride(null); + daemonSays('colima'); + expect(await detectEngine()).toBe('colima'); + }); + it('falls back to "other" on an unknown daemon or a failed probe', async () => { daemonSays('Ubuntu 24.04'); expect(await detectEngine()).toBe('other'); @@ -71,6 +81,13 @@ describe('detectEngine', () => { expect(execaMock).not.toHaveBeenCalled(); }); + it('honours a colima engine.kind pin', async () => { + pin.value = 'colima'; + daemonSays('OrbStack'); + expect(await detectEngine()).toBe('colima'); + expect(execaMock).not.toHaveBeenCalled(); + }); + it('probes when the pin is auto, in the same process', async () => { // The pin is read on the same one-shot path as the probe, so clearing it // takes effect in the next process -- here, the next cleared cache.