Skip to content

fix(sandbox-runtime): seed OpenCode global config dir to skip cold-start reify - #790

Merged
ColeMurray merged 2 commits into
mainfrom
fix/opencode-global-config-reify
Jun 19, 2026
Merged

fix(sandbox-runtime): seed OpenCode global config dir to skip cold-start reify#790
ColeMurray merged 2 commits into
mainfrom
fix/opencode-global-config-reify

Conversation

@ColeMurray

@ColeMurray ColeMurray commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Summary

A user's first prompt on a freshly-spawned sandbox can hard-fail with "Execution failed" when
the bridge's POST /session to the in-sandbox OpenCode server times out (30 s httpx.ReadTimeout).
This seeds OpenCode's global config dir (~/.config/opencode) with the plugin dependency tree
we already stage, so OpenCode's cold-start npm install is a no-op and the first request no longer
blocks on it.

Root cause (confirmed on a live sandbox)

POST /session runs OpenCode's per-directory bootstrap. When a plugin is configured (an OpenAI-OAuth
session deploys codex-auth-plugin.js, and the repo may ship its own .opencode/plugins/*.ts),
plugin.init() calls config.waitForDependencies(), which makes the request block on a forked
npm install @opencode-ai/plugin for every directory in OpenCode's config search path.

That search path includes Global.Path.config = ~/.config/opencode, which OpenCode mkdirs
empty on every startup. We pre-stage /app/opencode-deps and copy it into the repo's .opencode/
(_install_tools), but we never seed the global dir — so it has no node_modules, OpenCode's
checkNodeModules reifies it (a real arborist.reify() npm install, no internal timeout), and on a
slow/cold install that exceeds the bridge's 30 s budget the prompt fails.

Verified on a running sandbox — ~/.config/opencode/ contains:

{ "dependencies": { "@opencode-ai/plugin": "1.14.41" } }

plus node_modules/ and a 14K package-lock.json. That package.json has no name/type — it
is arborist's synthesized manifest, not our staged file ({"name":"opencode-tools","type":"module",…}),
proving OpenCode reified this directory itself. (Global npm install -g lands in /usr/lib/node_modules;
_install_tools targets the repo's .opencode/ — neither writes here.)

This is not the previously-suspected _install_tools mixed-tree bug: the incident repo committed
only .opencode/plugins/skill-audit.ts (no .opencode/package.json), so its repo .opencode/ is
seeded consistently and does not reify — the unseeded global dir is the culprit.

Fix

entrypoint.py:

  • Extract the existing deps-copy into a shared _stage_opencode_deps(deps_cache, dest_dir).
  • Add _seed_global_opencode_deps() — resolves OpenCode's global config dir the way OpenCode does
    (OPENCODE_CONFIG_DIR$XDG_CONFIG_HOME/opencode~/.config/opencode) and copies the staged
    tree there only if it has no node_modules (never clobbers a real/existing config).
  • Call it from start_opencode right after _install_tools, best-effort (a failure only degrades
    to the slower reify, so it must not crash startup).

It's a plain file copy, runs on every serving boot (fresh / repo-image / snapshot, and Daytona since
the entrypoint is shared), and is robust to HOME/XDG_CONFIG_HOME changes.

Why this approach

It removes the specific directory that actually reified, for every session, at zero per-session
cost. For the reported incident it is complete on its own (the repo's .opencode/ was already
consistent). Considered, deliberately not in this PR (tracked as follow-ups):

  • A bridge-side dedicated POST /session timeout + retry (defense in depth) — recommended next.
  • A general "warm the bootstrap before ready" step that pays any reify off the prompt path —
    useful for repos that ship their own .opencode/ npm deps.

Testing

  • New unit tests in test_tool_installation.py:
    • TestResolveGlobalConfigDirOPENCODE_CONFIG_DIR override, XDG_CONFIG_HOME, and ~/.config
      fallback.
    • TestSeedGlobalOpencodeDeps — seeds an empty global dir; no-ops when node_modules already
      present (never clobbers); no-ops when the staging is absent.
  • pytest tests/ — 359 passed.
  • ruff check / ruff format --check — clean.
  • mypy src/ — no new errors (mypy-neutral vs main).

Summary by CodeRabbit

Release Notes

  • New Features
    • Enhanced OpenCode dependency staging by reusing cached package*.json and node_modules when absent, and optionally seeding them into the standard global configuration directory when empty.
  • Bug Fixes
    • Improved startup resilience by running global seeding in best-effort mode and continuing even if seeding fails.
  • Tests
    • Added coverage for global config directory resolution (environment/XDG/home fallback) and seeding behavior under empty, partially populated, and missing staging-cache scenarios.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3ed7c21d-06f9-4099-9bb8-444e1ebb1293

📥 Commits

Reviewing files that changed from the base of the PR and between 05a9593d261d5cd517aa62fb3398ed4c2dc594af and 5eb0bbd.

📒 Files selected for processing (2)
  • packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py
  • packages/sandbox-runtime/tests/test_tool_installation.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py
  • packages/sandbox-runtime/tests/test_tool_installation.py

📝 Walkthrough

Walkthrough

SandboxSupervisor refactors OpenCode plugin dependency staging and global seeding. _stage_opencode_deps() copies pre-built deps to .opencode only when absent. _resolve_opencode_global_config_dir() and _seed_global_opencode_deps() populate the global config directory using environment fallbacks. _prepare_opencode_filesystem() orchestrates staging, best-effort global seeding, and skills/bin installation. start_opencode() calls the new orchestration function. Tests validate config dir resolution and all seeding scenarios.

Changes

OpenCode Global Dependency Seeding

Layer / File(s) Summary
Core helpers and filesystem preparation wiring
packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py
Introduces _stage_opencode_deps() to copy package.json, package-lock.json, and node_modules from /app/opencode-deps into .opencode only when absent. Adds _resolve_opencode_global_config_dir() implementing OPENCODE_CONFIG_DIR$XDG_CONFIG_HOME/opencode~/.config/opencode fallback. Adds _seed_global_opencode_deps() to populate the global config directory when node_modules is absent, with best-effort error handling that logs warnings on failure. Introduces _prepare_opencode_filesystem() to orchestrate tool staging, global seeding, and skills/bin installation. Wires start_opencode() to call _prepare_opencode_filesystem(workdir) instead of separate install functions.
Tests for config dir resolution and global seeding
packages/sandbox-runtime/tests/test_tool_installation.py
Adds _make_opencode_deps_staging() fixture to create a fake /app/opencode-deps cache. Adds TestResolveGlobalConfigDir covering OPENCODE_CONFIG_DIR precedence, $XDG_CONFIG_HOME/opencode fallback, and ~/.config/opencode final fallback. Adds TestSeedGlobalOpencodeDeps with tests for seeding an empty global config dir, preserving pre-populated directories, skipping when package.json exists without node_modules, and no-op when staging cache is missing.

Possibly related PRs

  • ColeMurray/background-agents#491: Introduced the image-prebaked /app/opencode-deps artifacts and the original _install_tools copy behavior that this PR refactors and extends with global seeding.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐇 Hop, hop! The plugins are staged,
In .opencode they're carefully paged.
If OPENCODE_CONFIG_DIR shows the way,
Or XDG_CONFIG_HOME saves the day,
The global node_modules will stay! 🌱

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: seeding OpenCode's global config directory to avoid cold-start timeouts. It directly relates to the primary objective of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/opencode-global-config-reify

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown

Terraform Validation Results

Step Status
Format
Init
Validate

Note: Terraform plan was skipped because secrets are not configured. This is expected for external contributors. See docs/GETTING_STARTED.md for setup instructions.

Pushed by: @ColeMurray, Action: pull_request

@open-inspect open-inspect Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

PR #790, fix(sandbox-runtime): seed OpenCode global config dir to skip cold-start reify, by @ColeMurray. Reviewed 2 changed files (+154/-7); the change extracts the OpenCode dependency staging helper and best-effort seeds the global config directory before starting OpenCode.

Critical Issues

None found.

Suggestions

None blocking. The implementation keeps the global seeding best-effort and preserves existing config when node_modules already exists, which matches the intended safety boundary.

Nitpicks

None.

Positive Feedback

  • The global config directory resolution is explicit and covered for OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME, and home fallback.
  • The copy logic remains non-clobbering and reuses the repo-local staging path instead of duplicating file-copy behavior.
  • Tests cover the important safety cases: empty global dir seeding, existing node_modules no-op, and missing staging no-op.

Questions

None.

Verification

  • Reviewed the full PR diff with gh pr diff 790.
  • Created an isolated PR worktree to avoid the dirty local main worktree.
  • python -m py_compile packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py packages/sandbox-runtime/tests/test_tool_installation.py passed.
  • Could not run targeted pytest or ruff locally because those commands are not installed in this environment.

Verdict

Approve.

open-inspect[bot]
open-inspect Bot previously requested changes Jun 19, 2026

@open-inspect open-inspect Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep maintainability review: I found a contract/abstraction issue in the dependency seeding helper reuse, plus an orchestration concern in start_opencode(). No file crosses the 1k-line threshold because of this PR, but entrypoint.py is already very large, so new startup branches should be held to a high bar.

Comment thread packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py
Comment thread packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py (1)

513-531: 💤 Low value

Consider logging when skipping due to existing node_modules.

The early return at line 526 when node_modules exists is correct defensive behavior, but adding a debug log would improve observability when troubleshooting why global deps weren't seeded.

📊 Suggested logging enhancement
     config_dir = self._resolve_opencode_global_config_dir()
     if (config_dir / "node_modules").exists():
+        self.log.debug("opencode.global_deps_skip", reason="already_populated", config_dir=str(config_dir))
         return  # already seeded, or a real global config — never clobber
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py` around lines 513
- 531, The _seed_global_opencode_deps method returns early without logging when
node_modules already exists at the config_dir location. Add a debug-level log
statement before the early return (in the conditional checking if config_dir /
"node_modules" exists) to record that global deps seeding was skipped, including
the config_dir path in the log output for better observability during
troubleshooting.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py`:
- Around line 513-531: The _seed_global_opencode_deps method returns early
without logging when node_modules already exists at the config_dir location. Add
a debug-level log statement before the early return (in the conditional checking
if config_dir / "node_modules" exists) to record that global deps seeding was
skipped, including the config_dir path in the log output for better
observability during troubleshooting.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e89c6d9d-4441-40e9-8c47-c1b8803b9338

📥 Commits

Reviewing files that changed from the base of the PR and between 9999f36 and 382b5fa.

📒 Files selected for processing (2)
  • packages/sandbox-runtime/src/sandbox_runtime/entrypoint.py
  • packages/sandbox-runtime/tests/test_tool_installation.py

@github-actions

Copy link
Copy Markdown

Terraform Validation Results

Step Status
Format
Init
Validate

Note: Terraform plan was skipped because secrets are not configured. This is expected for external contributors. See docs/GETTING_STARTED.md for setup instructions.

Pushed by: @ColeMurray, Action: pull_request

Address open-inspect[bot] review on #790:

- _seed_global_opencode_deps seeds only a pristine global config dir: skip
  (debug-log opencode.global_deps_skip) when it already has node_modules OR a
  package.json, so our cached modules never land against a foreign manifest.
- Extract _prepare_opencode_filesystem(workdir) to own tools/deps/skills/bin
  plus the best-effort global seed, collapsing the try/except out of
  start_opencode().
- Add test for the manifest-without-node_modules skip path.
@ColeMurray
ColeMurray force-pushed the fix/opencode-global-config-reify branch from 05a9593 to 5eb0bbd Compare June 19, 2026 23:49
@github-actions

Copy link
Copy Markdown

Terraform Validation Results

Step Status
Format
Init
Validate

Note: Terraform plan was skipped because secrets are not configured. This is expected for external contributors. See docs/GETTING_STARTED.md for setup instructions.

Pushed by: @ColeMurray, Action: pull_request

@ColeMurray
ColeMurray merged commit a4aceb3 into main Jun 19, 2026
18 checks passed
@ColeMurray
ColeMurray deleted the fix/opencode-global-config-reify branch June 19, 2026 23:52
ColeMurray added a commit that referenced this pull request Jun 20, 2026
…795)

## Summary

Follow-up to #790. That PR fixed the user-visible cold-start
`ReadTimeout` (#767) by seeding OpenCode's **global** config dir
(`~/.config/opencode`) so the first `POST /session` no longer reifies it
— but it did the seeding by **copying `node_modules` at boot**, which is
a multi-second cost on every sandbox start (observed as ~9–19s in the
`opencode.start → opencode.global_deps_seeded` window).

This moves that work to **image-build time**: bake the staged plugin
tree into the global config dir once, so the runtime seed becomes a
no-op.

## Change

**`base.py`** — after staging `/app/opencode-deps`, also copy it into
the global config dir at build time (the image fixes `HOME=/root`, so
the dir is always `/root/.config/opencode`):

```dockerfile
mkdir -p /root/.config/opencode
cp -a /app/opencode-deps/. /root/.config/opencode/
```

`CACHE_BUSTER` is bumped `v51 → v52-bake-opencode-global-deps` to
rebuild the base image and stamp a distinct `SANDBOX_VERSION` (so we can
confirm from telemetry which sandboxes run the baked image; per
`manager.py:416`, repo-images/snapshots don't auto-rebuild on a bump and
pick it up as they refresh).

**`entrypoint.py`** — `_seed_global_opencode_deps()` is now a documented
**fallback**: it skips when `node_modules` is already present (which it
now is, thanks to the bake). `OpenCode`'s startup `mkdir(recursive)`
won't clear a populated dir, so the baked tree survives and still avoids
the reify. The seed stays for environments where the baked dir isn't
present (e.g. a different `HOME`).

Added timing/visibility so the boot cost is measurable (this was the
other half of the ask):

- `opencode.repo_deps_staged` `duration_ms` — the pre-existing copy of
the same tree into the **repo's** `.opencode/` (the remaining boot cost
after this change).
- `opencode.global_deps_seeded` `duration_ms` — the fallback seed, when
it actually runs.
- `opencode.global_deps_skip` `reason=already_present|foreign_manifest`
— promoted to `info` so the baked steady-state is visible each boot.

## Effect

- **Fresh base-image boots:** the global seed is a no-op
(`global_deps_skip reason=already_present`) → that ~9s+ copy is gone
from boot.
- **Repo-images / snapshots:** pick up the baked dir as they
rebuild/refresh; until then the runtime fallback keeps them correct.
- No runtime `CACHE_BUSTER` dependency — the bake is build-time content;
nothing per-session changes.

## Testing

- `ruff check` / `ruff format --check` — clean (sandbox-runtime +
`base.py`).
- `pytest tests/` — 360 passed.
- `mypy src/` — no new errors (mypy-neutral vs `main`: 12 ↔ 12).
- `python -m py_compile base.py` — valid.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Enhanced logging and performance metrics for dependency operations
with duration tracking
* Improved observability of dependency staging and global configuration
seeding processes

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant