Skip to content
Closed
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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 21 additions & 21 deletions apps/web/content/docs/configuration.mdx

Large diffs are not rendered by default.

23 changes: 21 additions & 2 deletions apps/web/content/docs/local-docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/remote-docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias>` — see below.
Expand Down
1 change: 1 addition & 0 deletions packages/config/schema/user-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@
"enum": [
"orbstack",
"docker-desktop",
"colima",
"other",
"auto"
]
Expand Down
6 changes: 3 additions & 3 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/config/test/coerce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
1 change: 1 addition & 0 deletions packages/config/test/schema-drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
Expand Down
29 changes: 28 additions & 1 deletion packages/sandbox-docker/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
removeInBoxWorktree,
seedWorkspace,
seedWorkspaceFromDir,
unwritableGitBind,
type RepoCarryOver,
type RestoreWorktreePlan,
} from './sync/in-box-git.js';
Expand Down Expand Up @@ -1000,9 +1001,19 @@ export async function createBox(opts: CreateBoxOptions): Promise<CreatedBox> {
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],
Expand Down Expand Up @@ -1061,6 +1072,22 @@ export async function createBox(opts: CreateBoxOptions): Promise<CreatedBox> {
// 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 <alias> localhost\`, needs SSH/Remote Login, then \`agentbox docker:<alias> create\`), or run Docker Desktop / OrbStack for local boxes. See the local-docker docs for the colima notes.`,
);
}
}
try {
await seedWorkspace({
container: containerName,
Expand Down Expand Up @@ -1284,7 +1311,7 @@ export async function createBox(opts: CreateBoxOptions): Promise<CreatedBox> {
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 <container>.orb.local)');
} else {
Expand Down
21 changes: 18 additions & 3 deletions packages/sandbox-docker/src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand Down Expand Up @@ -78,10 +87,16 @@ export async function runBox(spec: RunBoxSpec): Promise<string> {
'--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/<pid>/ns/net
// for a non-root container init (image default USER is vscode → uid 1000).
Expand Down
5 changes: 3 additions & 2 deletions packages/sandbox-docker/src/provider-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export async function dockerChecks(): Promise<CheckResult[]> {
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',
},
];
}
Expand Down Expand Up @@ -92,7 +92,8 @@ export async function dockerChecks(): Promise<CheckResult[]> {
} 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,
Expand Down
25 changes: 17 additions & 8 deletions packages/sandbox-docker/src/sync/host-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,9 +69,11 @@ async function pinnedEngine(): Promise<DockerEngine | null> {
/**
* 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
Expand All @@ -87,12 +89,19 @@ export async function detectEngine(): Promise<DockerEngine> {
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;
}
Expand Down
32 changes: 32 additions & 0 deletions packages/sandbox-docker/src/sync/in-box-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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[],
Expand Down
4 changes: 2 additions & 2 deletions packages/sandbox-docker/src/vnc.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading