sandbox

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 13, 2026 License: Apache-2.0 Imports: 33 Imported by: 0

Documentation

Overview

Firecracker provider (Linux). Boots an OCI image as an ext4 rootfs with clawk-init as PID 1 and the clawk-pty-agent on vsock — the same guest stack as the vz provider — and talks to it over firecracker's hybrid vsock. No sshd: every guest session goes through the agent, like vz.

Networking mirrors vz too: the VM runs out of process under the __fcd daemon, which drives an in-process gvproxy (gvisor-tap-vsock) userspace TCP/IP stack as the guest's gateway and enforces the same per-connection egress allow-list + DNS-aware filtering (internal/netfilter.AllowList). gvproxy can't drive firecracker's TAP directly, so the daemon bridges the two with a frame pump (fcnet_linux.go).

Known limitations:

  • One shared bridge / one fixed guest IP, so two firecracker sandboxes at once collide. A per-sandbox /30 is the next step.
  • No virtio-fs, so the phase worktree is baked into the rootfs at Create time rather than live-mounted; host edits don't propagate.

Linux VM provider plumbing — bridge + TAP, firecracker-ci kernel fetch, loop-mounted rootfs. Consumed by the firecracker provider; kept separate because the asset/network plumbing is sizeable and unrelated to the hypervisor-specific spec building in firecracker_linux.go.

Index

Constants

View Source
const CurrentGuestABI = 1

CurrentGuestABI is the version of the guest-side contract baked into a sandbox's disk at create time. Two things move together under this one number: the clawk-init boot manifest schema (guestcfg.Version) and the pty-agent vsock protocol (vsockproto.ProtoVersion). It is recorded on the sandbox record (config.Sandbox.GuestABI) so a later host binary can tell — without booting anything — whether it still speaks this sandbox's dialect.

View Source
const DefaultDiskSizeGiB = 8

DefaultDiskSizeGiB is the virtual ceiling every VM root disk is grown to. The image is sparse, so this bounds runaway growth rather than charging steady-state cost — a box only ever occupies the blocks it has actually written. Build caches live on shared virtio-fs mounts (see ToolchainCacheShares), which keeps each rootfs's working set small.

OCI rootfs disks are padded sparse ext4 images sized to this ceiling at build time.

View Source
const GuestHome = "/home/" + GuestUser

GuestHome is the agent user's home dir. Kept as a constant (not `/home/` + GuestUser expression) so it can be concatenated at compile time in other constants.

View Source
const GuestUser = "agent"

GuestUser is the non-root Linux user the agent runs as inside every sandbox. "agent" mirrors Docker AI Sandbox's convention (docker/sandbox-templates:claude-code, UID 1000, /home/agent), which keeps our docs and the wider ecosystem's docs interchangeable.

All user-facing paths inside the VM are derived from this constant (GuestHome, WorkspaceRoot, ~/.claude/... mounts, etc.), so if we ever switch the base image's user, nothing else has to change.

View Source
const MinSupportedGuestABI = 1

MinSupportedGuestABI is the oldest recorded guest ABI this host binary still boots and attaches to. Raising it turns every sandbox created before the new floor into a "recreate me" error, so raise it only alongside an actual manifest/protocol break — and say so in the changelog.

View Source
const NinepBasePort uint32 = 1100

NinepBasePort is the first guest vsock port used by the host 9p cache servers; each ToolchainCacheShares entry gets NinepBasePort+index. Chosen clear of the fixed control ports (1024 pty-agent, 1025 time-sync, 1026 ssh-agent) with headroom so adding a cache never collides.

View Source
const OCICmdline = "console=hvc0 root=/dev/vda rw psi=1 init=" + guestcfg.InitPath +
	" clawk.cfg=/dev/vdb"

OCICmdline boots the image's flattened rootfs with the injected init. hvc0 is the vz virtio-console; root is the OCI-built ext4 on the first virtio-blk; clawk.cfg names the manifest config disk. psi=1 enables the kernel's pressure-stall information (CONFIG_PSI_DEFAULT_DISABLED is set in the Kata kernel) so the guest agent can report /proc/pressure/memory to the host balloon controller.

View Source
const OCIConfigDiskName = "guestcfg.img"

OCIConfigDiskName is the manifest disk's filename inside a sandbox's VM directory.

View Source
const WorkspaceRoot = GuestHome + "/workspace"

WorkspaceRoot is where worktrees get mounted inside the guest. Lives under the agent user's home (same convention as Docker AI Sandbox), not at the filesystem root — many agent tools (e.g., the Claude Code skills installer) create dotfiles next to the current working directory, and $HOME is writable by default. A root-owned /workspace causes "EACCES mkdir /workspace/.agents" errors.

View Source
const WorkspaceShareTag = "workspace"

WorkspaceShareTag is the virtio-fs tag for the single consolidated worktree share: the host worktree parent (store.WorktreeDir) mounted at WorkspaceRoot. Every managed (non-in-place) worktree lives under that one host dir, so folding them into a single virtio-fs device — instead of one device per phase — keeps sandboxes with many repos under the device ceiling Apple's Virtualization.framework enforces (see machine/vz). Both the vz share list (collectSandboxShares) and the guest mount manifest (OCIGuestManifest) key off this exact tag, so they must never diverge.

Variables

This section is empty.

Functions

func CheckGuestABI

func CheckGuestABI(sb *config.Sandbox) error

CheckGuestABI refuses, with recreate guidance, when the guest binaries baked into sb's disk are outside what this host supports. Called before boot and before attach: it is the readable version of the failure the guest itself would otherwise produce mid-boot (clawk-init manifest check) or mid-attach (pty-agent handshake check).

func ClearOAuthToken

func ClearOAuthToken(clawkRootDir string) error

ClearOAuthToken removes the persisted token. Removing a missing file is not an error — `clawk auth clear` should be idempotent so users can run it after losing track of state.

func ConsoleTail

func ConsoleTail(path string, n int) string

ConsoleTail returns the last n non-empty lines of a guest console log, framed for embedding in a boot-failure message. Returns "" when the log is missing or empty — callers append it to their error text unconditionally.

Boot failures are diagnosed from the guest console essentially every time (kernel panics, clawk-init errors, missing init binaries); making the CLI surface it directly turns "go read this file" into an answer.

func GuestWorkspace

func GuestWorkspace(p Provider) string

GuestWorkspace returns the per-provider guest workspace path, falling back to the package default when the provider does not implement WorkspaceProvider.

func HasManagedWorktree

func HasManagedWorktree(sb *config.Sandbox) bool

OCIGuestManifest assembles the clawk-init boot manifest for sb: the guest-side description of the user, network, mounts, and snapshot files clawk-init applies at boot. stateDir is the per-sandbox persistent state dir (Claude state), cacheDir the clawk cache (toolchain shares), rootDir the clawk root (~/.clawk).

Mount tags MUST stay in lock-step with the share enumeration the vz daemon exposes (collectSandboxShares in internal/cli/vzd.go) — a tag listed here but not exported by vz fails to mount in the guest, and vice versa silently hides a share. HasManagedWorktree reports whether sb has at least one non-in-place phase worktree — i.e. a worktree clawk created under store.WorktreeDir rather than pointing at the user's own repo. Only then is the consolidated WorkspaceShareTag mount emitted (an in-place-only sandbox keeps WorkspaceRoot a plain guest dir with per-phase sub-mounts). Shared by the spec side (collectSandboxShares) and the manifest side (OCIGuestManifest) so both agree on whether the parent device exists.

func InPlaceWorktreeTag

func InPlaceWorktreeTag(worktreePath string) string

InPlaceWorktreeTag is the virtio-fs tag for an in-place worktree device (from `clawk here`, where the worktree is the user's own directory). Keyed on the full worktree path. Note the guest mount POINT keeps the readable basename; only the opaque tag is bounded.

func InitRootHelpers

func InitRootHelpers()

InitRootHelpers handles privileged mount/umount re-exec before cobra runs. Returns only if os.Args does not match a helper command; otherwise it exits the process.

func OAuthTokenPath

func OAuthTokenPath(clawkRootDir string) string

OAuthTokenPath returns the on-disk location of the persisted token. Public so the CLI can echo it back in `clawk auth status` and the docs can reference an unambiguous string.

func OCIGuestManifest

func OCIGuestManifest(sb *config.Sandbox, stateDir, cacheDir, rootDir string) (guestcfg.Manifest, error)

func OCIRootFS

func OCIRootFS(sb *config.Sandbox, cacheDir string, bins guestbuild.Binaries) machine.OCIImage

OCIRootFS builds the machine.OCIImage rootfs spec for sb. Every caller (provider Create pre-build, vzd boot) must produce the identical value or the digest-keyed disk cache misses and a second disk gets built.

func RecordedGuestABI

func RecordedGuestABI(sb *config.Sandbox) int

RecordedGuestABI is sb's guest ABI, resolving the zero value (records written before the field existed) to ABI 1.

func RepoShareTag

func RepoShareTag(repoPath string) string

RepoShareTag is the virtio-fs tag for a managed repo's src_ alias (the device that re-exposes the repo at its original host path so worktree .git backpointers resolve in the guest). Keyed on the full repo path.

func SaveOAuthToken

func SaveOAuthToken(clawkRootDir, token string) error

SaveOAuthToken persists the token at ~/.clawk/claude-oauth-token with mode 0600. Atomic via write-tmp-rename so a Ctrl-C mid-write can't leave an empty file.

func SeedClaudeMemory

func SeedClaudeMemory(stateRoot, seed string) error

SeedClaudeMemory writes seed into the agent's auto-memory entrypoint (<stateRoot>/claude/memory/MEMORY.md — the path the autoMemoryDirectory setting points at) the FIRST time a sandbox boots, when no memory file exists yet. It never overwrites: once the agent — or a prior session folded in via internal/sessions — has written memory, that wins. An empty seed or an already-present file is a no-op. Best-effort like SeedClaudeStateDir: failing to seed baseline knowledge must not block boot.

func SeedClaudeStateDir

func SeedClaudeStateDir(stateRoot, clawkRootDir string) error

SeedClaudeStateDir writes the host-snapshot files (settings.json, CLAUDE.md, .credentials.json) directly into the per-sandbox state dir that PersistentClaudeShares mounts at ~/.claude/. Run by the provider during sandbox preparation, BEFORE the VM boots — so by the time virtio-fs mounts the dir, the files are already in place.

Semantics:

  • settings.json: overwritten on every call. Lets clawk refresh its forced overrides (bypass permissions, etc.) and propagate host settings.json edits into already-persisted sandboxes on re-create.

  • CLAUDE.md: overwritten on every call from host ~/.claude/CLAUDE.md (if present). Matches the old snapshot-at-create semantics exactly — host edits flow into newly-created sandboxes.

  • .credentials.json: written ONLY when (a) no long-lived OAuth token is configured (env var or clawk root file) and (b) the state dir doesn't already have a credentials file. The second condition preserves a refreshed token across destroy/recreate cycles, replacing the previous copy-in/copy-out dance through auth/credentials.json.

Returns the first error encountered. Best-effort: a missing host CLAUDE.md isn't an error (there's no source to copy), but failure to write into the state dir is.

func WriteOCIGuestConfig

func WriteOCIGuestConfig(sb *config.Sandbox, vmDir, stateDir, cacheDir, rootDir string) error

WriteOCIGuestConfig (re)writes the manifest config disk for sb into vmDir. Called before every boot so share/file changes propagate without a destroy.

Types

type Bar

type Bar struct {
	// Label names the item, shown left of the bar (e.g. "layer 3/12").
	Label string
	// Frac is the completion fraction, 0..1.
	Frac float64
}

Bar is one labelled sub-progress bar (e.g. a single layer's download) rendered on its own line beneath the current step.

type CaptureExecProvider

type CaptureExecProvider interface {
	ExecCapture(sb *config.Sandbox, command ...string) (output string, err error)
}

CaptureExecProvider is optionally implemented by providers that can run a guest command non-interactively and return its combined output. Narrated hooks (`on create` / `on up`) use it to keep successful command output off the screen and show it only on failure.

type ExitError

type ExitError struct{ Code int }

ExitError reports that an interactive guest session (a shell or an attached agent) finished with a non-zero exit status that should become clawk's own process exit code.

Provider Shell methods and the CLI's interactive-session helpers return it instead of calling os.Exit in place: that lets the cobra command's deferred cleanup run and keeps a single os.Exit site in cmd/clawk, which unwraps it with errors.As. Carrying the status as an error is also what preserves the exact code — a plain returned error collapses to exit 1.

It is deliberately NOT used for internal command execution (ExecCapture, phase-setup scripts): those failures are ordinary errors that should surface a message and exit 1, not hijack the process exit code.

func (*ExitError) Error

func (e *ExitError) Error() string

type FirecrackerProvider

type FirecrackerProvider struct {
	// contains filtered or unexported fields
}

FirecrackerProvider implements the Provider + agent interfaces using the machine/firecracker backend.

func NewFirecrackerProvider

func NewFirecrackerProvider(store *config.Store) *FirecrackerProvider

func (*FirecrackerProvider) Create

func (f *FirecrackerProvider) Create(sb *config.Sandbox) error

Create stages everything the VM boots from: the firecracker-CI kernel, the cross-compiled guest binaries, the OCI rootfs (with the worktree baked in), and the clawk-init manifest config disk.

func (*FirecrackerProvider) DaemonSpec

func (f *FirecrackerProvider) DaemonSpec(sb *config.Sandbox, allow *netfilter.AllowList) (machine.Spec, error)

DaemonSpec sets up the host network plumbing (IP-less bridge + the guest's TAP + the daemon-owned gvproxy TAP) and returns the machine.Spec the __fcd daemon boots. It runs in the daemon process; the returned spec carries a UserMode net in TAP-bridge mode so the firecracker backend brings up gvproxy (with allow as the egress filter) bridged to the guest's NIC.

func (*FirecrackerProvider) Destroy

func (f *FirecrackerProvider) Destroy(sb *config.Sandbox) error

func (*FirecrackerProvider) Exec

func (f *FirecrackerProvider) Exec(sb *config.Sandbox, command ...string) error

Exec runs a command in the guest over the vsock agent. Used for the coding-agent attach (claude), so it's interactive.

func (*FirecrackerProvider) ExecCapture

func (f *FirecrackerProvider) ExecCapture(sb *config.Sandbox, command ...string) (string, error)

ExecCapture runs a command non-interactively and returns its combined output over the agent's frame protocol.

func (*FirecrackerProvider) FCStateDir

func (f *FirecrackerProvider) FCStateDir(sb *config.Sandbox) string

FCStateDir exposes the machine-library state dir to the __fcd daemon.

func (*FirecrackerProvider) GuestWorkspaceRoot

func (f *FirecrackerProvider) GuestWorkspaceRoot() string

GuestWorkspaceRoot: firecracker has no `agent` user and no virtio-fs, so the worktree is baked into the rootfs under /workspace.

func (*FirecrackerProvider) Shell

func (f *FirecrackerProvider) Shell(sb *config.Sandbox, workdir string) error

Shell opens an interactive login shell in the guest over the vsock agent.

func (*FirecrackerProvider) Start

func (f *FirecrackerProvider) Start(sb *config.Sandbox) error

Start spawns the detached __fcd daemon — which owns gvproxy, the frame pump, and the firecracker VM for the VM's lifetime — and returns once the pty-agent answers over vsock. The VM must outlive this CLI invocation, so (like the vz provider) the work runs in a child process, not in-process.

func (*FirecrackerProvider) Status

func (f *FirecrackerProvider) Status(sb *config.Sandbox) (string, error)

func (*FirecrackerProvider) Stop

func (f *FirecrackerProvider) Stop(sb *config.Sandbox) error

Stop signals the __fcd daemon, which tears down the VM, the frame pump, and gvproxy. A missing/stale pidfile is not an error.

The timeout MUST exceed the daemon's own graceful-stop budget (gracefulStop gives m.Stop 15s: CtrlAltDel wait 10s + SIGTERM-firecracker wait 5s). The minimal OCI guest doesn't power off on CtrlAltDel, so the daemon always spends ~10s there before falling through to SIGTERM the firecracker child. If we SIGKILL the daemon before that completes, firecracker is orphaned — it keeps the guest TAP open, and the next boot fails with "Open tap device failed: Resource busy". 25s leaves margin over the 15s budget plus the daemon's post-stop cleanup.

type HostFile

type HostFile struct {
	HostPath  string
	Content   []byte
	GuestPath string
	Mode      uint32
	Owner     string // "user:group" or "" for root
}

HostFile is a single host file snapshotted into the VM at boot from the guest manifest. Static — edits on the host require a fresh sandbox (or a future `clawk sync`) to propagate. Used for items that don't fit virtiofs's dir-only mount model.

Exactly one of HostPath or Content must be set. Content is used when the source isn't a file — e.g. a secret extracted from the macOS Keychain.

func ClaudeJSONMarkerFile

func ClaudeJSONMarkerFile(phases []config.Phase, hasToken bool) HostFile

ClaudeJSONMarkerFile returns the HostFile that pre-creates ~/.claude.json. Two concerns share the file:

  • hasCompletedOnboarding (only when hasToken — the keychain credentials path doesn't need it).
  • hasTrustDialogAccepted=true for every phase worktree path the sandbox mounts, the per-repo source mounts those worktrees point at, and the workspace root. Without these, a fresh sandbox prompts "is this a project you trust?" on first claude launch in each directory — a 100% pointless gate in a VM whose only contents the user just chose to mount.

Always emitted: the trust block is sandbox-shape-dependent (we only know the paths after PrepareVM resolves phases), but it's always wanted.

func DefaultHostFiles

func DefaultHostFiles(clawkRootDir string) []HostFile

DefaultHostFiles returns host files snapshot-copied into each sandbox via the guest manifest. Today, the only items here are those that live OUTSIDE the per-sandbox ~/.claude/ mount:

  • /etc/profile.d/98-clawk-claude-oauth.sh (env var for the long- lived OAuth token, when configured)
  • ~/.claude.json (onboarding marker, at home root, not inside ~/.claude/)
  • ~/.gitconfig
  • ~/.ssh/known_hosts

Files that DO live inside ~/.claude/ — settings.json, CLAUDE.md, .credentials.json — are pre-seeded into the per-sandbox state dir by SeedClaudeStateDir before the VM boots; they appear at the canonical paths through the PersistentClaudeShares mount, no snapshot-file involvement needed.

clawkRootDir resolves the optional long-lived OAuth token (~/.clawk/claude-oauth-token, see oauth_token.go). When configured, the profile.d export and onboarding marker are emitted; the rotating keychain blob lives only in the seeded state dir.

func EnvFile

func EnvFile(sb *config.Sandbox) (HostFile, bool)

EnvFile synthesizes an /etc/profile.d script that exports every sandbox-required env var. Names come from sb.RequiredEnv (declared in clawk.mod); values come from the host's process env at this call. Missing host vars log a warning and are exported empty — callers decide whether that's fatal (e.g., an MCP server failing to auth is a clear signal).

Written into /etc/profile.d/99-clawk-env.sh so every login shell (ssh, `claude ...`, interactive bash, phase setup scripts) picks up the values without any per-tool configuration.

Returns ok=false if the sandbox has no required env — saves the caller from having to filter empty HostFiles.

func OAuthTokenEnvFile

func OAuthTokenEnvFile(token string) HostFile

OAuthTokenEnvFile returns the HostFile that drops an /etc/profile.d/ script exporting CLAUDE_CODE_OAUTH_TOKEN into every login shell inside the sandbox.

Mode is 0644 root-owned, deliberately readable by the agent user — /etc/profile.d/*.sh that aren't readable get silently skipped by /etc/profile, and the only non-root principal that ever runs inside the VM is `agent` anyway. The token is no more sensitive than the ~/.claude/.credentials.json blob already shipped.

The value is shell-escaped (backslash-escaping backslash, dollar, backtick, double-quote) so tokens that happen to contain shell metacharacters survive the export. Claude Code tokens are base64-ish today, but pinning that assumption into the export layer would silently break the day Anthropic changes the format.

func WorkspaceDocFile

func WorkspaceDocFile(sb *config.Sandbox) HostFile

WorkspaceDocFile returns a CLAUDE.md that gets dropped at /home/agent/workspace/CLAUDE.md on first boot. Claude Code auto-loads a CLAUDE.md in its startup CWD, so whatever's in here becomes part of the agent's instructions the moment it starts.

The content describes WHERE the agent is (a clawk VM, not the user's laptop), WHAT the layout looks like (per-phase worktrees), and WHICH constraints apply (egress allowlist, mounts, git/ssh setup). Knowing these upfront stops the agent from wasting turns investigating the environment or being surprised by a blocked connection mid-task.

type HostShare

type HostShare struct {
	HostPath       string
	Tag            string
	GuestPath      string
	ReadOnly       bool
	NinePVSockPort uint32
}

HostShare is a host directory shared into every sandbox. Tag must be unique across all shares on one VM (vz uses it as the virtio-fs mount identifier). GuestPath is where the guest mounts the share inside.

NinePVSockPort, when non-zero, marks the share for the 9p-over-vsock transport: the host runs a ninep server (internal/ninep) rooted at HostPath on that guest vsock port, and a 9p-capable clawk-init mounts it over 9p instead of virtio-fs. The virtio-fs device (Tag) is still attached as the fallback for older guests. Used for the toolchain caches, whose file counts make Apple's virtio-fs exhaust the host's kern.maxfiles. Zero = virtio-fs only (every other share). See ToolchainCacheShares.

func DefaultHostShares

func DefaultHostShares() []HostShare

DefaultHostShares returns host agent capability dirs that are safe to live read-write-share across host and VM.

We do NOT share all of ~/.claude, even though that would match local multi-terminal behavior. The Claude Code issue tracker documents real corruption from concurrent access:

  • anthropics/claude-code#28847 (.claude.json race corrupts state)
  • anthropics/claude-code#25609 (OAuth refresh race revokes tokens)
  • anthropics/claude-code#10039 (macOS Keychain deletes .credentials.json, breaking Linux co-mounted sessions)

Sharing Claude agents/commands and Codex skills is safe: user-authored capability dirs with low write contention. Sharing .claude.json, credentials, projects/, file-history/, or the whole ~/.codex state dir would risk concurrent writers. Each sandbox authenticates independently — one-time cost per sandbox in exchange for no cross-session corruption.

~/.claude/skills is deliberately NOT shared. A skill like gstack carries a large node_modules tree, and virtio-fs caches an inode (and host fd) per file it touches; across several running sandboxes that inflates the Virtualization.framework XPC processes' fd count enough to exhaust the host's system-wide open-file table (kern.maxfiles → ENFILE). Skills that a sandbox needs can be brought in per-sandbox via an explicit `shares (...)` entry instead.

func PersistentClaudeShares

func PersistentClaudeShares(stateRoot string) []HostShare

PersistentClaudeShares returns the per-sandbox host share that carries Claude Code's entire ~/.claude/ across destroy/recreate cycles. Call sites already resolved a Store so they pass the state root directly rather than re-deriving the path.

The host directory is created idempotently so virtiofs never encounters a missing source. The guest-side mount point is ~/.claude/ — per-sandbox storage that does NOT suffer the shared- .claude.json races documented below; two sandboxes can't touch the same path because each sandbox name maps to a distinct host dir.

We mount the whole dir rather than a curated subdir list because:

  • The "ephemeral" subdirs (cache/, paste-cache/, shell-snapshots/, telemetry/) measure in hundreds of KB total — exclusion isn't worth the bookkeeping.
  • settings.json and CLAUDE.md, formerly snapshot HostFiles, are now seeded via SeedClaudeStateDir straight into the synced dir before the mount happens. That removes the snapshot-file vs share-mount layering issue.
  • .credentials.json (non-token path) lives at its canonical place inside the synced dir, so claude's atomic write-rename refresh persists naturally — no more copy-in/copy-out trick via auth/credentials.json.

Mount ordering: this share must come BEFORE DefaultHostShares in the assembled share list. The agents/commands sub-mounts land on top of ~/.claude/ at boot, and Linux would shadow them under a later parent mount.

Cross-sandbox races (the ones DefaultHostShares is avoiding) don't apply here — each sandbox name maps to its own state dir, so two sandboxes never write to the same path.

Opt out by passing an empty stateRoot.

func ToolchainCacheShares

func ToolchainCacheShares(cacheDir string) []HostShare

ToolchainCacheShares returns host shares that back the dependency caches of common language toolchains. Mounting one host directory per cache means a module/crate is downloaded once and reused across every sandbox — preventing the multi-GB-per-sandbox blowup we observed where each new clone re-downloaded the same Go module set.

Each guest path is the toolchain's *default* cache location, so no env vars and no provision.sh changes are required: the mount is transparent. Host dirs are created on demand (virtiofs refuses missing source paths); MkdirAll failures silently drop the offending share rather than failing sandbox creation, matching the behavior of PersistentClaudeShares.

Caches included (well-documented concurrent safety, real payoff):

Go modules         ~/go/pkg/mod         — file-locked, append-only;
                                          standard CI sharing pattern.
Cargo registry+git ~/.cargo/registry/{index,cache}, ~/.cargo/git/db
                                       — Cargo holds cross-process
                                         locks; the same set every
                                         Rust GHA workflow caches.

Caches deliberately excluded:

Go build cache (~/.cache/go-build) — NOT shared. It stays per-VM on
  the rootfs (Go's default location). Two reasons: (1) the build
  cache is not safe for concurrent builds against one directory
  (golang/go#43645) — racing `go build`s in different sandboxes can
  delete/move entries mid-read and corrupt each other's lookups,
  unlike the append-only, file-locked module cache; (2) it's the
  churniest tree we'd share — rewritten on every build — and over
  Apple's virtio-fs each guest-cached inode pins a host fd in the VM
  XPC process, so it dominated the descriptor blowup we saw. A build
  cache is cheap to repopulate per-VM, so the sharing payoff never
  justified the hazard.
pnpm store / uv cache / Bun cache — all three hardlink from cache
  into the working tree (node_modules, .venv). Hardlinks don't
  cross filesystems, so on a virtiofs mount these tools silently
  fall back to copying — strictly worse than no sharing, since you
  pay the copy cost AND lose dedup. Revisit only if worktrees move
  onto the same shared mount layout.
Cargo target/ — locked per-project; cross-project sharing is an
  unimplemented Rust project goal.
~/.cargo/bin and ~/.cargo/registry/src — bin/ holds executables,
  not cache; src/ is cheap to re-extract from cache/ and sharing
  it would just balloon disk usage.
Zig and pip — small footprint here; pip is superseded by uv.

Pass cacheDir = "" to opt out.

func UserHostShares

func UserHostShares(shares []config.HostShare) []HostShare

UserHostShares converts a sandbox's user-declared shares (config.HostShare from `shares (...)` in clawk.mod) into the internal HostShare form with stable virtio-fs tags.

Tags are derived from the guest path so the same share survives `clawk down && clawk up` without re-randomising — the guest manifest, the provider's --device list, and the runtime mount loop all key off the tag, and a churned tag means a stale mount entry that fails on next boot. SHA-256 hex prefix gives 8 hex chars (32 bits), collision-resistant inside a single sandbox's share list while staying short enough for virtio-fs tag length constraints.

type MockProvider

type MockProvider struct {
	Created   []string
	Started   []string
	Stopped   []string
	Destroyed []string
	Running   map[string]bool
}

MockProvider is a test double for the VM provider.

func NewMockProvider

func NewMockProvider() *MockProvider

func (*MockProvider) Create

func (m *MockProvider) Create(sb *config.Sandbox) error

func (*MockProvider) Destroy

func (m *MockProvider) Destroy(sb *config.Sandbox) error

func (*MockProvider) Exec

func (m *MockProvider) Exec(sb *config.Sandbox, command ...string) error

Exec implements ShellProvider for testing.

func (*MockProvider) Shell

func (m *MockProvider) Shell(sb *config.Sandbox, workdir string) error

Shell implements ShellProvider for testing.

func (*MockProvider) Start

func (m *MockProvider) Start(sb *config.Sandbox) error

func (*MockProvider) Status

func (m *MockProvider) Status(sb *config.Sandbox) (string, error)

func (*MockProvider) Stop

func (m *MockProvider) Stop(sb *config.Sandbox) error

type OAuthTokenSource

type OAuthTokenSource string

OAuthTokenSource describes where a token came from. Used by `clawk auth status` so the user can see which copy is winning — the env var trumps the file on every Claude Code invocation, and a surprised user otherwise can't tell why edits to the file aren't taking effect.

const (
	OAuthTokenSourceNone OAuthTokenSource = ""
	OAuthTokenSourceEnv  OAuthTokenSource = "env"
	OAuthTokenSourceFile OAuthTokenSource = "file"
)

func LoadOAuthToken

func LoadOAuthToken(clawkRootDir string) (string, OAuthTokenSource)

LoadOAuthToken returns the long-lived Claude Code OAuth token to propagate into sandboxes, along with which source it came from. A token == "" / source == OAuthTokenSourceNone means none configured.

Resolution order matches the precedence we want Claude Code itself to see at runtime:

  1. CLAUDE_CODE_OAUTH_TOKEN in the host process env — convenient for CI and one-off shells.
  2. ~/.clawk/claude-oauth-token — what `clawk auth set-token` persists. Mode 0600.

Both forms produce the same effect inside the sandbox: an env var exported via /etc/profile.d/. Whitespace is trimmed so users who piped through `pbpaste` and got a trailing newline aren't surprised by a "token doesn't look valid" error inside the VM.

type PlainProgress

type PlainProgress struct{}

PlainProgress is the no-frills fallback: one line per completed step, no spinner, no transient detail. Suitable for pipes and logs.

func (PlainProgress) Close

func (PlainProgress) Close()

func (PlainProgress) Detail

func (PlainProgress) Detail(string, ...any)

func (PlainProgress) SetBars

func (PlainProgress) SetBars([]Bar)

func (PlainProgress) SetFraction

func (PlainProgress) SetFraction(float64)

func (PlainProgress) Skip

func (PlainProgress) Skip()

func (PlainProgress) Step

func (PlainProgress) Step(format string, args ...any)

func (PlainProgress) StepDone

func (PlainProgress) StepDone(format string, args ...any)

type Progress

type Progress interface {
	Step(format string, args ...any)
	Detail(format string, args ...any)
	StepDone(format string, args ...any)
	// Skip abandons the current step silently — for steps that turn out
	// to be cache hits, where a checkmark line would just be noise.
	Skip()
	// SetFraction attaches a completion fraction (0..1) to the current
	// step — renderers draw it as a progress bar. Negative clears it
	// (back to indeterminate). Implementations may ignore it.
	SetFraction(frac float64)
	// SetBars replaces the set of per-item sub-progress bars drawn under
	// the current step — one bar per concurrent layer download. Nil or
	// empty clears them. Implementations may ignore it.
	SetBars(bars []Bar)
	Close()
}

Progress narrates long-running provider work (image pulls, rootfs builds) to the user. Providers call it sequentially: Step begins a unit of work, Detail updates its live status line, StepDone replaces the step with a completion summary. Close stops any rendering; callers should defer it as soon as they obtain a tracker.

The CLI installs a spinner-based implementation on interactive terminals (see internal/cli); everything else gets PlainProgress.

type Provider

type Provider interface {
	// Create prepares the VM (image, config, etc.).
	Create(sb *config.Sandbox) error
	// Start launches the VM.
	Start(sb *config.Sandbox) error
	// Stop gracefully stops the VM.
	Stop(sb *config.Sandbox) error
	// Destroy removes all VM artifacts.
	Destroy(sb *config.Sandbox) error
	// Status returns the current VM state as a string.
	Status(sb *config.Sandbox) (string, error)
}

Provider abstracts VM lifecycle management. Implementations: vz (macOS), firecracker (Linux).

type RootExecProvider

type RootExecProvider interface {
	ExecRoot(sb *config.Sandbox, command ...string) error
}

RootExecProvider is optionally implemented by providers that can run a one-shot command as root inside the guest without relying on sudo. OCI-image sandboxes need this: arbitrary images frequently ship no sudo, but their agent runs as root and can spawn root children directly.

type ShellProvider

type ShellProvider interface {
	Shell(sb *config.Sandbox, workdir string) error
	Exec(sb *config.Sandbox, command ...string) error
}

ShellProvider is optionally implemented by providers that can open interactive shells directly (vz over the vsock agent).

type WorkspaceProvider

type WorkspaceProvider interface {
	GuestWorkspaceRoot() string
}

WorkspaceProvider is optionally implemented by providers whose guest mounts phase worktrees somewhere other than sandbox.WorkspaceRoot. Firecracker boots a bare-root rootfs without an `agent` user, so its shares live under /workspace; vz creates the agent user via clawk-init and mounts under /home/agent/workspace.

Callers that need to address phase paths inside the guest (run.go's agentStartDir, up.go's runPhaseSetup) should query this interface when present and fall back to sandbox.WorkspaceRoot otherwise.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL