Skip to content

fix: cloud machines install the host's OpenCode generation - #258

Merged
aicodeking merged 7 commits into
aicodeking:mainfrom
hongkongkiwi:cloud-opencode-generation
Nov 2, 2026
Merged

aicodeking merged 7 commits into
aicodeking:mainfrom
hongkongkiwi:cloud-opencode-generation

Conversation

@hongkongkiwi

Copy link
Copy Markdown
Contributor

What and why

Fixes #209: a cloud machine installed npm opencode-ai (OpenCode v1; latest 1.19.5, no 2.x exists) even when the host ran OpenCode v2, so the sandbox's harness never advertised the model picked from the host's v2 catalog and every first run died with The harness does not advertise the selected setting. about 15 seconds in. Switching the task's model to Default unblocked it, which is how the reporter isolated the harness, not the machine.

#215 pinned the install to the host's version, but for a v2 host that pin produces opencode-ai@2.1.2: an npm version that does not exist, so the install now fails outright instead of installing the wrong generation. The package itself has to follow the host's major generation. This PR is stacked on #215 (its head, 54164e8); only the two commits shown as "commits" below are new.

What (in src-tauri/src/cloud_agents/setup.rs)

  • opencode_package(version) returns @opencode/cli for a host major of 2 or higher and opencode-ai otherwise, pinned to the host's exact version when parse_version yields one (@opencode/cli@2.1.2, opencode-ai@1.19.5). npm facts checked today: opencode-ai publishes only 1.x, @opencode/cli only 2.x, and both expose the same opencode bin, so mark_runtime's resolution is unchanged.
  • host_generation(version) reads the major from the parsed pin, falling back to a new looser parse_generation so a bare 2, v2 or 2.x still selects the right package without inventing a pin. Anything unparsable keeps the v1 package and latest, as before.
  • The runtime marker is keyed by generation (opencode-2, opencode-1), so a machine provisioned for one generation never reuses another's marker; the plain opencode marker written by older builds is likewise never read by a generation-scoped lookup. Other harnesses keep their own name as the marker id.
  • cached_program and mark_runtime take the runtime id in place of the harness name; behavior for claude/glm/mimo/codex is byte-identical.
  • Review fix: the marker cache gate now compares against the parsed pin instead of the raw host output. A prefixed line like OpenCode 2.1.2 or a bare 2 could never equal the machine CLI's own --version line, so the gate missed and every run reinstalled through npm.

What (in src-tauri/src/providers/acp.rs)

  • choice_request now reports The harness does not advertise the selected <setting> "<value>". It offers: <values>. Refresh its models or choose the harness default. The setting name comes from the option's id, else its category, else the word "setting"; up to 12 offered values are listed, then , …, or nothing when the harness offers none. The generic sentence named nothing, which is what left the reporter guessing across model, mode and effort.

How it was tested

  • 8 new tests, authored independently of the fix by a separate test-author pass and qualified failing-first against stubs of the old behavior before implementation. Verbatim failures included left: "opencode-ai@2.1.2" / right: "@opencode/cli@2.1.2" and all three choice_request tests panicking on the old generic line. Coverage: package selection across real --version outputs (2.1.2, v2.1.2, OpenCode 2.1.2, 2.2.0-beta.3, bare 2/v2/2.x, 1.19.5, None, "", latest, junk), generation-scoped marker ids, marker read/write scoped to the id (legacy plain opencode never satisfies a v2 lookup), the 12-value cap with zero-padded ids so no substring false positives pass, and the id-then-category fallback.

  • The cache-gate seam test is qualified by construction: the direct cached_program call with the raw host line returns None in the same test, which is exactly the miss the unfixed call site produced (running the unfixed path end to end would have performed a real network npm install).

  • cargo test --locked --manifest-path src-tauri/Cargo.toml --lib: 1796 passed, 0 failed, 74 ignored (live smokes). cargo clippy --locked --all-targets -- -D warnings: clean. Rust-only change; no frontend surface touched, so the npm/Playwright gates are not exercised by this diff.

  • Review passes: one deep review (approve; two nits ignored with reasons: a u32-overflow version string falls back to the v1 package exactly as the base commit did and npm 404s clearly, and values beyond the pre-existing 10,000-entry catalog cap were already rejected before this change) and one independent quick pass whose single finding is the cache-gate fix above; both ran without seeing each other's findings.

  • npm run build (typecheck + bundle) — N/A, Rust-only

  • npm test — N/A, Rust-only

  • cargo test --manifest-path src-tauri/Cargo.toml (lib suite, locked; clippy clean)

  • Relevant Playwright tests — N/A, no UI changed

Checklist

  • Commit messages follow <type>: <description>
  • Docs in docs/ updated if behavior changed — nothing to update: docs/agent-updates.md already documents both packages per generation, and no doc states the installed package or marker name
  • Preview mocks added in src/preview.ts for new IPC commands — N/A, no new IPC commands
  • No telemetry, secrets or personal paths added

- The secure store rejects control characters, so saving the multi-line
  private key always failed a provider's first cloud task with "That
  secret looks empty or malformed." (issue aicodeking#206); the keypair is now one
  base64-encoded secret holding both halves.
- The public line is no longer re-derived with ssh-keygen -y on every
  run, which failed inside the app and read as a corrupt key.
- A serialized key_pair keeps concurrent task preparations from racing
  the read against the save; an undecodable value regenerates a fresh
  pair instead of blocking every task; the key file's 0600 mode is
  re-asserted on every write.
Independently authored failing-first against the unfixed main tip by a
separate test-author session (qualified fail-first there with exactly
'That secret looks empty or malformed.'), then merged onto the fix:
the saved value must pass the secure store's own validation and the
materialized file must derive the returned public line via ssh-keygen.

The two lifecycle tests share one locked store dir (the secret store's
dir and test map are process-globals).
…race

POST /v2/account/keys answers {"ssh_key": {...}}, not {"key": ...}, so a
successful registration read as failure once the earlier fixes let tasks
reach it. Parse ssh_key (key kept as a tolerated alternate), and when the
create is refused or answers without the key, re-list the account and
reuse the registered key instead of failing or registering a duplicate.
decode_pair now also treats a decodable but bogus private half as an
unusable pair, so it self-heals like any other.
- sync::upload_file and credentials::upload_file split every file into
  46 KiB pieces before the provider upload, so VPS machines paid one
  ssh exec per piece (a 135 KB checkout bundle cost minutes; a 64 MiB
  state archive, hundreds). The whole file now goes to the sandbox in
  one call: one scp on the SSH-family providers, their own single-call
  file APIs elsewhere, and the chunked trait default still covers
  providers without one (issue aicodeking#206).
- Cloud OpenCode installs at the exact version this computer runs
  instead of npm latest, whose model catalog differs between releases
  (a 2.1.2 host got a 2.2.0 machine that refused deepseek/deepseek-flash).
  The runtime marker re-checks the program's own --version, so a host
  upgrade reinstalls once and older markers compare correctly.
- OpenCode 2.x keeps provider credentials in its database (auth.json is
  ignored there), so the host's 'opencode auth export --standalone'
  payload is imported on the machine and its staging file removed by an
  EXIT trap; the auth.json copy stays for 1.x hosts.
- providers::run_provider runs an installed provider CLI and captures
  stdout, drained on a thread so output past the OS pipe buffer cannot
  stall the poll loop.
- Tests: one-upload-per-file sync round trip, marker version pinning,
  npm pin parsing, and the export/import login flow against a fake
  machine running real sh. cargo test --locked: 1721 passed, 74
  ignored; clippy -D warnings clean.
… errors name the mismatch

OpenCode v2 ships as the separate npm package @opencode/cli; opencode-ai
publishes only 1.x, so the version pin from the previous fix resolved to a
package that does not exist for v2 hosts and the machine could never match
the host's model catalog (issue aicodeking#209). The package and the runtime marker
now follow the host's major generation, with a looser parse so a bare 2 or
v2 still selects the right package, and a rejected session/set_config_option
names the setting, its rejected value and the values the harness offers.
The marker lookup compared the machine CLI's own --version line with the
raw host output, so a prefixed line (OpenCode 2.1.2) or a bare major could
never match a freshly installed CLI and every run reinstalled. It now asks
for the parsed pin, which is what the CLI actually prints (review finding).
@aicodeking
aicodeking merged commit 80d99bd into aicodeking:main Nov 2, 2026
3 checks passed
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.

Cloud agents install OpenCode v1 on sandboxes even when the host runs OpenCode v2

2 participants