drydock

Configuration

drydock init creates ~/.drydock/ (mode 0700) and seeds two files:

Path What
~/.drydock/config.yaml Operator settings (network, gateway IP, budget, timeout, concurrency, paths, listener, behavior flags)
~/.drydock/egress.yaml The allowlist: hosts and ports the sandbox may reach (see Egress)

Both are seeded from defaults the first time; drydock init never overwrites them. Env vars win over file values, so existing scripts keep working. Edit config.yaml and re-run drydock start.

The vendor keys (ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY) are intentionally not in these files; they live in your shell env, or at ~/.drydock/api-keys.env (mode 0600), read host-side and never passed into the VM. All three keys are recognized automatically; no extra config is needed to declare them.

Common settings

Field (config.yaml) Env override Default Meaning
anthropic_auth DRYDOCK_ANTHROPIC_AUTH api_key api_key uses ANTHROPIC_API_KEY; subscription uses ~/.drydock/claude-oauth.json
openai_auth DRYDOCK_OPENAI_AUTH api_key api_key uses OPENAI_API_KEY; subscription uses ~/.drydock/codex-oauth.json
default_agent DRYDOCK_DEFAULT_AGENT claude Agent when --agent is omitted (claude | codex | gemini | opencode)
default_model DRYDOCK_DEFAULT_MODEL (empty) --model fallback for Claude Code and Codex only; empty = the agent picks. Not applied to gemini (uses its own gemini-2.5-pro default) or opencode (uses openai_compat.model).
task_budget_usd DRYDOCK_TASK_BUDGET_USD 2.0 Per-task USD soft cap, metered post-hoc; overshoot bounded to task_max_inflight in-flight requests (default 1); set max_request_cost_usd for a reservation-backed bound (api_key mode only; unused in subscription mode)
task_max_inflight DRYDOCK_TASK_MAX_INFLIGHT 1 Concurrent gateway requests admitted per task lease; bounds budget overshoot to this many in-flight requests; 0 disables the cap
max_request_cost_usd DRYDOCK_MAX_REQUEST_COST_USD 0 (disabled) Worst-case USD reserved per in-flight request so concurrent requests cannot admit past the budget; 0 disables (post-hoc metering only)
task_max_requests DRYDOCK_TASK_MAX_REQUESTS 0 (falls closed to a built-in default of 1000) Hard cap on API round-trips per task; the primary runaway control in subscription mode
aggregate_budget_usd DRYDOCK_AGGREGATE_BUDGET_USD 0 (disabled) Cross-task USD ceiling per api_key provider over aggregate_window; 0 disables the cap; subscription mode is out of scope (bounded per-task by task_max_requests)
aggregate_window DRYDOCK_AGGREGATE_WINDOW 24h Rolling window for the aggregate cap; 0 = total since brokerd boot, resets on restart
global_budget_usd DRYDOCK_GLOBAL_BUDGET_USD 0 (disabled) Global usage ceiling, USD limb. Cumulative broker-metered USD across all vendors and both auth modes over global_window; 0 disables it. See The global usage ceiling
global_max_tasks DRYDOCK_GLOBAL_MAX_TASKS 0 (disabled) Global usage ceiling, task limb. Cumulative task starts across all vendors and both auth modes over global_window; 0 disables it. Must be >= max_concurrent_tasks when set (the pair is refused at load)
global_window DRYDOCK_GLOBAL_WINDOW 24h Rolling window for both global limbs; 0s = total, nothing ages out — and unlike aggregate_window it is durable across restarts
task_timeout n/a 30m Wall-clock per task
approval_timeout n/a 0s Auto-deny a task left at an approval gate after this long; 0 = wait forever (right for interactive use; set for unattended runs). Must be non-zero when ci.max_attempts > 0 — brokerd refuses the pair at load, because an unattended retry child holds a concurrency slot across the gate it re-poses
max_concurrent_tasks DRYDOCK_MAX_CONCURRENT_TASKS 2 Excess POSTs to /tasks get HTTP 503
notifications DRYDOCK_NO_NOTIFY=1 (off) true macOS notifications on pending approval
push_max_retries DRYDOCK_PUSH_MAX_RETRIES 3 Transient push failures (network errors) to retry with exponential backoff before giving up; 0 disables transient retry
push_retry_backoff DRYDOCK_PUSH_RETRY_BACKOFF 1s Base delay for push retry backoff (backoff * 2^n); 0 disables the delay between retries
push_fresh_branch_tries DRYDOCK_PUSH_FRESH_BRANCH_TRIES 2 Alternate remote branch names (agent/<id>-2, -3, ...) to try when a branch-name collision is detected; 0 disables fresh-branch recovery

The global usage ceiling (opt-in, off by default)

Every other spend control in drydock is per task or per vendor. The global usage ceiling is neither: it bounds the daemon as a whole, across every vendor and both auth modes, over one rolling window.

global_budget_usd: 25       # cumulative broker-metered USD across ALL vendors
global_max_tasks:  40       # cumulative TASK STARTS across all vendors
global_window:     24h      # the window both limbs are measured over

Both default to 0, which is off: with neither set, brokerd opens no ledger, creates no file under audit_root, and every admission path behaves exactly as it did before this feature existed.

Why there are two limbs

They measure different things because not every lane has dollars to measure.

Retries and their parents count alike against both — a retry is a task start like any other.

How it interacts with aggregate_budget_usd

They are independent and both may be set. The stricter answer wins: a task start is refused if either says so. Otherwise they differ in every dimension:

aggregate_budget_usd global_budget_usd / global_max_tasks
scope one vendor (N vendors ⇒ N × the number) all vendors, both auth modes
auth modes api_key only both
storage in memory durable under audit_root
restart total mode resets to $0 survives, deliberately
on "I can't tell" admits (fail-open) refuses (fail-closed)
currencies USD only USD and task starts

What an operator sees when it trips

The ceiling refuses task starts. It never kills a running task: the money is already spent, and terminating in-flight work would leave half-finished trees for no saving.

Fail-closed means the ceiling refuses whenever it cannot answer, and it is gated per limb — which is deliberate, not a weakness:

The refusal text says which limb is enforced and what clears it; in total mode it says plainly that the condition does not age out on its own.

Reading the headroom

$ drydock stats

global ceiling (the last 24h0m0s):
  spend:  $12.50 of $50.00 broker-metered — $37.50 left
  starts: 7 of 20 — 13 left (6 recorded, 1 in flight)

The section appears only when the ceiling is on and brokerd is reachable — the rest of drydock stats reads the audit dir directly and works with the daemon stopped, but headroom is live state, and three of its numbers (in-flight starts, the degraded flags, the verdict) exist only in the running process.

in flight is the count of starts this process has admitted that have not reached their terminal yet, so they are not in the durable ledger. They are included in the total because that is the number the ceiling is actually comparing against.

Extra lines appear when they apply:

The same data is served as JSON by GET /admin/ceiling (same listener and auth as every other /admin/* route — the 0600 unix socket, or the loopback-guarded TCP wrap; nothing here is reachable from a sandbox VM) and by drydock stats --json under global_ceiling.

What the ceiling does not cover

The USD limb can only count dollars the broker measured. The list below is the set known today; it is not exhaustive, because any response shape whose usage the broker cannot parse joins it. It under-counts when:

global_max_tasks is the backstop for every one of those: it counts events, not dollars, so no metering gap can under-report it. If you run subscription or unpriced openai_compat lanes, set the task limb — the dollar limb cannot help you there. Set it even if the dollar limb is what you care about: it is also the only thing that bounds the crash-recovery and resumed-task gaps above, and without it those are unbounded across repeated crashes.

Only broker-metered spend counts

Neither limb ever reads a total_cost_usd an agent printed. The USD figure is the gateway lease's own metering, recorded host-side; the ledger lives under audit_root with 0600 permissions in a 0700 directory and is never read or written by anything inside a VM. An agent cannot inflate the ceiling to deny service, and it cannot deflate it to keep spending.

Nor does either limb read anything else out of a task trace. That is the stronger form of the same rule and it is what boot reconciliation follows: a trace's existence under a broker-minted task id is a fact the broker established, and its content — including the src field a reader might filter on — is text the agent's stdout was copied into. So reconciliation counts the start and records the spend as unknown, and it takes the entry's timestamp from filesystem metadata rather than from a timestamp inside the file. Neither a forged cost nor a forged (or back-dated) timestamp can raise or lower either limb.

The same rule now holds for every surface that displays spend. drydock stats, drydock tasks and the web UI history table read the broker-authored src=="broker" audit row; the web UI's push-approval gate shows the live gateway lease's own figure, published on the task state by the broker at gate entry, so there is nothing for it to parse at all. Where the only figure that exists is one the agent reported (a task still running, say), it is shown but explicitly marked as agent-reported, and never added to a spend total.

global_window: 0 (total mode)

Nothing ages out. Unlike aggregate_window: 0, which is in-memory and resets on every restart, this is durable — that is the point, since a crash loop that reset the ceiling would be the hole rather than the feature. The consequence is worth knowing before you set it: an exhausted ceiling stays exhausted across reboots until you raise a limb or remove the ledger file. brokerd warns at boot when a limb is armed in total mode.

Removing the ledger file requires stopping brokerd first. The entries also live in memory, and the next compaction rewrites the file from memory — so deleting or hand-editing <audit_root>/global/ledger.jsonl under a running daemon does not clear anything; the file comes back with the same contents. Stop brokerd, remove the file, start it again. The same applies to repairing a damaged ledger to clear a degraded limb.

Diff policy: caps, blocked paths, second-look

The optional diff_policy block in config.yaml constrains the diff a task may propose. It is enforced host-side by brokerd against the broker-computed diff facts (the same analysis the trust brief reports), so nothing the agent prints can influence it. All checks are off by default (zero values / empty lists).

diff_policy:
  max_files_changed: 0        # fail closed when the diff changes more files than this (0 = no cap)
  max_lines_changed: 0        # fail closed when added+deleted lines exceed this (0 = no cap)
  blocked_paths: []           # e.g. ["**/*.pem", ".github/workflows/**"] — touching one fails the task
  second_look_paths: []       # e.g. ["**/Dockerfile"] — approver must acknowledge each flagged category

Two distinct mechanisms:

Path patterns are **-aware repo-relative globs: * matches within a path segment, ** crosses segments. Write dir/** to cover everything under dir — a trailing-slash pattern like dir/ matches nothing and is rejected at config load. There is no env override for this block; configure it in config.yaml (it participates in drydock policy explain's divergence check like any other field).

Execution profiles: setup and readiness (per repo)

The optional profiles block in config.yaml gives a repository a setup phase: commands drydock runs against the task's live work tree before the agent starts — dependency install, code generation — plus readiness commands that gate the run. Keys must be the canonical host/owner/repo form (a non-canonical key is a config error, not a silent never-match):

profiles:
  repos:
    "github.com/you/yourrepo":
      setup:
        - ["npm", "ci"]
      readiness:
        - ["node", "--version"]
      timeout: 10m      # per command; 0 = the default (10m)
      cache: false      # true = opt in to the persistent per-repo dependency cache

What the phase guarantees:

Verdicts are the process exit codes the broker observes — nothing a command prints can flip a status. The task shows a setting_up stage while the phase runs, and the per-command evidence (status, exit codes, durations) lands in the trust brief (drydock inspect <id>); the combined output is kept display-only at ~/.drydock/audit/<id>.setup.log. See Submitting tasks for the full behavior.

Persistent dependency cache (opt-in, per repo)

Set cache: true in a repo's profile to reuse setup's dependency downloads across tasks instead of re-fetching everything each run. Package-manager caches (npm, Go modules, pip, cargo) are pointed at /deps, a host-side store under cache_root (default ~/.drydock/cache, env DRYDOCK_CACHE_ROOT) bounded by cache_quota_gb (default 20 GiB, env DRYDOCK_CACHE_QUOTA_GB; least-recently-used entries are evicted past the bound, and 0 disables the cache entirely, even for repos with cache: true).

The semantics that keep it safe:

Cache participation lands in the trust brief's setup block (drydock inspect <id>): hit/miss/disabled: no lockfile plus the entry's key prefix.

Host-side CI observation (opt-in, off by default)

The optional ci block lets brokerd follow a pushed PR's continuous integration, record what it observed on the queue item, and — separately opt-in — enqueue a bounded number of retry tasks when it observes a failure. It is off by default: a stock install writes no marker, starts no watch goroutine, makes no API call on a timer, and never retries anything.

ci:
  watch:         false          # enable the watch (default false = off)
  poll_interval: 60s            # watch tick; 0 = built-in default (60s), minimum 10s
  watch_timeout: 90m            # absolute per-PR deadline; 0 = built-in default (90m); must exceed max(2 x poll_interval, 5m)
  max_attempts:  0              # bounded retry on an observed CI failure; 0 = off (>0 requires a non-zero approval_timeout)
Field (under ci:) Env override Default Meaning
watch DRYDOCK_CI_WATCH=1 false Enable the host-side CI watch. Only the exact value 1 enables it via env
poll_interval DRYDOCK_CI_POLL_INTERVAL 60s Watch tick. One GitHub API call per watched PR per tick; 0 = the built-in default, minimum 10s
watch_timeout DRYDOCK_CI_WATCH_TIMEOUT 90m Per-PR deadline, absolute and anchored at push, so a restart cannot extend it; 0 = the built-in default. Must exceed the dispatch floor max(2 × poll_interval, 5m) — a shorter window makes every watch dead-letter, so the pair is rejected at load
max_attempts DRYDOCK_CI_MAX_ATTEMPTS 0 (off) Bounded retry on an observed CI failure. Counts retries, so 2 means at most two extra tasks in a chain. Each is a new task with a fresh full task_budget_usd, so one chain's worst case is max_attempts × task_budget_usd on top of the original. Capped at 10, and 10 is genuinely reachable — a retry's instruction is your original task plus one attempt's evidence, so its size does not grow with chain depth

What the watch does, and what it does not

It puts your gh credential on a timer

This is the one thing to weigh before enabling it. Until now every host gh/git call drydock made was operator-initiated — a push and a PR open, both downstream of your approval at the diff gate. The watch is not: it runs every poll_interval for up to watch_timeout with no human in the loop.

The calls are read-only (gh pr checks --json and gh pr view --json; no write subcommand is reachable from the watch path), the GitHub host is pinned in the flag value so an exported GH_HOST cannot redirect the credential elsewhere, and the environment is the same curated env every other host CLI call gets. See N5 in the threat model for the full statement.

To turn it off: set ci.watch: false (or delete the block — the default is off) and restart brokerd with drydock start. To keep the observation but stop the retry, set ci.max_attempts: 0 instead. Make sure DRYDOCK_CI_WATCH and DRYDOCK_CI_MAX_ATTEMPTS are not exported in the daemon's environment; env wins over the file. drydock policy explain shows which layer set it, and whether the running daemon agrees. An item already parked in awaiting_ci when the watch is switched off is terminated honestly at the next boot (dead_letter, "no CI conclusion was observed") rather than left hanging.

Bring your own model

opencode reaches any OpenAI-compatible endpoint via the openai_compat block in config.yaml (or the drydock setup wizard). There is no env override; configure it in the file. The real key is referenced by env-var name, never stored here.

Key (under openai_compat:) Meaning
base_url Endpoint host, e.g. https://generativelanguage.googleapis.com (empty = disabled; https, or http only for localhost)
base_path Path joined onto the request, e.g. /v1beta/openai
api_key_env Name of the host env var holding the real key (e.g. GEMINI_API_KEY)
model Model id passed to the agent, e.g. gemini-2.5-pro
prices Optional {<model>: {input, output}} USD per 1M tokens; enables USD budgeting, omit to rely on task_max_requests

Streaming and USD metering. Streaming chat/completions responses commonly omit token usage unless the client explicitly requests it (via stream_options.include_usage). drydock does not inject that option, so a streamed task against a priced openai_compat endpoint may be metered at $0 against task_budget_usd: the response completes but carries no usage to bill. The usage-independent backstop is task_max_requests: it counts every API round-trip regardless of whether the upstream reports usage. Set task_max_requests for any openai_compat lane where streaming is expected.

prices and the "default" row. The prices map is keyed by model id. If a task uses a model not explicitly listed and no "default" row exists, drydock has no price to apply and meters that call at $0, so the USD budget will never trip for that model. Add a "default" entry to catch unlisted models:

openai_compat:
  prices:
    my-model: {input: 1.00, output: 3.00}
    default:  {input: 1.00, output: 3.00}  # fallback for any unlisted model

See Bring your own model for worked examples.

Native Gemini

--agent gemini (default_agent: gemini) uses Google's native Gemini API directly. No openai_compat: block is needed; just set GEMINI_API_KEY in your env or ~/.drydock/api-keys.env. GEMINI_API_KEY is a recognized key automatically. There is no subscription mode for Gemini; API key is the only auth path.

default_agent: gemini          # make Gemini the default (defaults to gemini-2.5-pro)

default_model does not affect Gemini; pick a non-default Gemini model per task with --model gemini-2.5-flash.

Advanced: runtime, paths, listener

Field (config.yaml) Env override Default Meaning
network DRYDOCK_NETWORK drydock-egress vmnet network name
gateway_ip DRYDOCK_GW_IP 192.168.66.1 Gateway + squid bind here
sandbox_image SANDBOX_IMAGE drydock-sandbox:latest Per-task agent VM image
anchor_image DRYDOCK_ANCHOR_IMAGE drydock-anchor:latest Minimal image holding the vmnet gateway IP
stage_root / audit_root / squid_run_dir STAGE_ROOT / AUDIT_ROOT / SQUID_RUN_DIR ~/.drydock/{stage,audit,squid} Per-task scratch (audit dir 0700; log + diff 0600)
cache_root DRYDOCK_CACHE_ROOT ~/.drydock/cache Persistent per-repo dependency caches for profiles with cache: true
cache_quota_gb DRYDOCK_CACHE_QUOTA_GB 20 Total disk bound (GiB) for the dependency cache; LRU-evicted; 0 disables caching entirely
broker.socket BROKER_SOCKET $TMPDIR/drydock-$UID/drydock.sock Unix socket (parent dir 0700, socket 0600)
broker.addr BROKER_ADDR (empty) host:port to expose over TCP (no auth; see SECURITY.md § TCP exposure)
log_json DRYDOCK_LOG_JSON=1 false Force JSON logs even on a TTY
strict_container_version DRYDOCK_STRICT_CONTAINER_VERSION=1 false Fail closed when container's major drifts from the tested range
n/a EGRESS_CONFIG ~/.drydock/egress.yaml Path override for the egress YAML

Gateway port 8088 and squid port 3128 are hard-coded in cmd/brokerd/main.go and image/entrypoint.sh; change both together.

Inspecting the effective policy

With env vars layered over config.yaml layered over built-in defaults, it is easy to lose track of which value actually won. drydock policy explain resolves the config exactly like drydock start does and prints every setting with the layer that supplied it:

SETTING          VALUE             SOURCE
Network          drydock-egress    default
GatewayIP        192.168.66.1      default
TaskBudgetUSD    5                 config.yaml
MaxConcurrent    4                 env:DRYDOCK_MAX_CONCURRENT_TASKS
...

daemon: in sync — the running brokerd resolved this same policy

The SOURCE column is one of three values, resolved per field (env > config.yaml > default):

The daemon verdict. brokerd resolves its policy once, at boot. When it is reachable, policy explain compares your shell's resolution against the daemon's:

--json emits the machine-readable form: {"local": {fields, hash}, "live": {fields, hash} | null, "in_sync": bool | null} (live/in_sync are null when the daemon could not be asked). Both hash values cover only the divergence-comparison subset (connection fields broker.socket / broker.addr are excluded), so clients should compare the two hash values to each other or check in_sync rather than recomputing a hash over the full fields list.

Two semantics worth knowing: