sandbox_sync

Uploads a local directory, or one file, into /work/<dest> in the box, sending only the files that differ. Runs only through the stdio adapter.

Input Output
id, localPath, dest, commit, alsoPaths, includeIgnored, exclude, prune, submodules, baseline, baselineDeps, dryRun, maxMB (adapter only) ok, dest, remotePath, selected, files, sentFiles, sentPaths, changedOnBox, uploadedBytes, skipped; when they apply, staleInDest, pruned, excluded, deps, uncommitted, commitSha, submodules, submodulesMissing, notes; with dryRun, dryRun, sendMB, totalMB and largest; with baseline, baseline and note

sandbox_sync (adapter only) uploads a local directory into /work/<dest>. Pass an absolute localPath; a relative one resolves against the adapter process's working directory, which the agent cannot see. dest is relative to /work: write app, not /work/app; a leading /work/ is dropped, so /work/app works too, and any other absolute path, or one that leaves /work, is refused before anything is sent.

In a git working tree it takes what git tracks plus untracked files .gitignore does not exclude, asks the box which of them differ from what is already in dest and uploads only those, so a dev server watching /work/<dest> sees just your edits; skipped lists the top-level names it left out. Outside git it takes every file except node_modules, .git, dist, build, coverage, .venv and similar, and with commit that commit's files; both are compared and sent the same way, so a second sync sends only what changed. No .git is sent. The result says what happened:

  • remotePath: where it landed in the box, /work/<dest>.
  • sentPaths: the files this call sent (the first 50; sentFiles counts them all), and changedOnBox: how many files the box actually rewrote. 0 means the box already had every one of them.
  • notes: anything to act on, such as a sent package.json, vite.config.*, tsconfig* or .env*, which makes a running dev server (Vite, webpack, Metro) reload the whole page or restart, losing page state such as language, route and injected data. Also:
    • Dependencies: node_modules is never sent. A folder with a package-lock.json, pnpm-lock.yaml or yarn.lock but no node_modules in the box is named with the command to install there, such as sandbox_build { "dir": "repo/sdk", "cmd": "npm ci", "out": ["node_modules"] }; for npm, one whose installed packages are missing or at other versions than the lockfile says how many and which. deps holds the same in fields (dir, lock, state missing or stale). "Cannot find module" errors there come from that, not from your change.
    • On the first sync into a dest: build settings the ignore rules kept out (tsconfig.json, .env.local, *.config.*), with how to send them (alsoPaths listing them, or includeIgnored), and that no .git was sent, so git commands there fail with "not a git repository".
    • A gradlew, mvnw or #! *.sh script sent without the executable bit (git records it as 100644): run it with bash, or chmod +x it in the box.
    • An upload retried once after fetch failed, a reset connection or HTTP 502/504.
  • When a sync fails, it says NOT SYNCED: /work/<dest> still has its old files (or only part of this sync) and lists the files it was sending. Commands you run there next see the old code, and the result of the next sandbox_exec on that box ends with a warning saying so, once: sync again before you trust a test run.

Size: a sync that would send more than 100 MB (size on disk) sends nothing; the error lists the largest folders, such as renderer/ 195.2 MB in 1203 files (renderer/dist-win/ 191.6 MB). Add such build output to .gitignore or exclude, send only your files with commit and alsoPaths, or pass maxMB when the size is expected. "dryRun": true compares with the box and returns what a sync would send (sentFiles, sentPaths, sendMB, largest, staleInDest, notes) without sending or deleting anything. While a sync runs, clients that ask for progress get a line every 10 seconds (hashing, comparing, megabytes uploaded). An upload where nothing moves for two minutes, or that the box does not answer within ten minutes of receiving it, fails with a message naming that step instead of hanging.

One file: when localPath is a file, dest is that file's path in the box: "localPath": "/abs/repo/renderer/.env", "dest": "renderer/.env" writes /work/renderer/.env. When dest ends in /, or is already a folder in the box, the file goes inside it under its own name. remotePath says which. commit, alsoPaths and prune need a directory.

Deletions: files in dest that localPath does not have (deleted or renamed locally, or no longer in the commit you send) stay in the box, where a type check or test run keeps reporting errors from them. The result lists them in staleInDest (count, and the first 20 paths; countIsPartial past 1000) and notes says so. "prune": true deletes them, and pruned says how many. Paths the local ignore rules skip (node_modules, build output, .env) are never counted or deleted, so what the box built for itself stays. prune needs localPath in a git working tree or commit, a dest below /work (not /work itself) and no includeIgnored. A fresh dest gives a clean tree too.

Options:

  • commit: send the tree of this git revision (HEAD, origin/main) instead of the working tree, so other people's half-finished files in a shared checkout stay out. A revision that does not exist is an error and nothing is sent. The result's commitSha is the commit sent.
  • alsoPaths: with commit, paths under localPath taken from the working tree instead of that tree, to test the commit plus your own edits. A directory replaces the commit's copy as a whole, with what a sync without commit would send from it: files you deleted or renamed there do not come back from the commit, and ignored files such as node_modules stay out unless includeIgnored is set. Without commit, alsoPaths sends only those paths (files or directories, such as the output of git diff --name-only), each to the same place under dest, picked the same way; staleInDest then looks only inside those directories. Files they import from elsewhere in the repo do not go with them: when the box has no full copy yet, send "commit": "HEAD" with these paths as alsoPaths instead of syncing one subfolder.
  • includeIgnored: also send files the ignore rules skip (dist, generated output), everything but .git. Off by default.
  • exclude: globs relative to localPath that this sync leaves alone both ways: not sent, not compared, and the box's copy is never listed in staleInDest or pruned, so a file you changed in the box (a capacitor.config.ts pointing at the dev server) keeps your change. A pattern without / matches that name at any depth (*.log, dist-win); one with / starts at localPath (android/app/build.gradle); ** spans folders. excluded lists what was held back.
  • dryRun: report what a sync would do without sending or deleting anything (above).
  • maxMB: the size limit for one sync, default 100 (above).
  • prune: delete what staleInDest lists (above).
  • submodules: with commit, also send each submodule at the commit the tree records (see below).
  • baseline: also send the tree of this git revision (HEAD, origin/main, your branch's merge base) to <dest>-baseline, in the same call, so both copies come from the same repo at the same moment. The result gains baseline (that copy's dest, commit and counts, or its error) and a note. See below.
  • baselineDeps: with baseline, copy each node_modules in dest into the baseline (default true; see below).

In a checkout that other people or conversations also edit (several sessions in one repo), a sync without commit sends their half-finished files too, and the box reports their errors as yours. Such a sync lists what differs from HEAD in uncommitted (count, and the first 20 paths with their git status code), with a note. Send "commit": "HEAD" and list your own uncommitted files in alsoPaths, or sync a git worktree of your own:

sandbox_sync { "id": "<id>", "localPath": "/absolute/path/to/repo", "dest": "repo", "commit": "HEAD", "alsoPaths": ["src/refund.ts", "src/refund.test.ts"] }

When a test fails and you do not know whether your change caused it, add baseline and run the same command in both copies:

sandbox_sync { "id": "<id>", "localPath": "/absolute/path/to/repo", "dest": "repo", "baseline": "origin/main" }
sandbox_exec { "id": "<id>", "cmd": "go test ./... 2>&1 | grep -E '^(--- FAIL|FAIL|ok)'", "cwd": "repo", "note": "run the tests with my change" }
sandbox_exec { "id": "<id>", "cmd": "go test ./... 2>&1 | grep -E '^(--- FAIL|FAIL|ok)'", "cwd": "repo-baseline", "note": "run the same tests before my change" }

What fails in both was failing before your change. One call does both runs and the comparison: sandbox_exec with "baseline": true and cwd inside dest runs the command in repo, then in repo-baseline, and returns onlyMine, alreadyFailing and fixedByMine with timings and test numbers ignored (sandbox_exec):

sandbox_exec { "id": "<id>", "cmd": "go test ./...", "cwd": "repo", "baseline": true, "note": "compare the test failures with and without my change" }

To show that a new test catches the bug, copy it into repo-baseline and watch it fail there. The baseline copy holds what that commit tracks, plus a copy of each node_modules in dest at the same place, made with hard links (no extra disk, a few seconds) where the baseline has none yet; baseline.depsCopied lists them. Each tree has its own folders and Vite cache, so both can run at once (one node_modules symlinked into both would make their Vite caches overwrite each other). When the baseline commit's lockfile wants other versions, the baseline's notes say so: run npm ci there, which replaces the copy. "baselineDeps": false skips the copy. Give the baseline a test database of its own (psbx-testdb up base) so the two runs do not clear each other's tables.

Git submodules:

  • Without commit, an initialized submodule is sent whole, as it is on disk: ignored files inside it (its node_modules, build output) included, and on every sync, not only when it changed. One that is not initialized arrives as an empty directory.
  • With commit, and in a baseline copy, a submodule is an empty directory (git archive does not descend into it) unless you pass "submodules": true. Then each submodule, nested ones included, is sent at the commit the tree records, taken from its local checkout: the result lists them in submodules (path, commit), and those it could not send in submodulesMissing with the reason (not initialized: run git submodule update --init; that commit is not in the local submodule: git fetch there). They do not stop the sync.
  • No .git is sent, so git commands in the synced copy (git submodule update, git describe) fail there; when the build needs them, git clone --recurse-submodules in the box instead.

Every tool and topic is listed in the tool reference.