Skip to content

chore(sync): merge upstream dev and audit v2.2.6 gaps - #477

Open
shuv1337 wants to merge 327 commits into
integration-v2from
sync/upstream-dev-audit-v2.2.6
Open

shuv1337 wants to merge 327 commits into
integration-v2from
sync/upstream-dev-audit-v2.2.6

Conversation

@shuv1337

@shuv1337 shuv1337 commented Nov 7, 2026

Copy link
Copy Markdown
Owner

chore(sync): merge upstream dev and audit v2.2.6 gaps

Base: integration-v2 · Head: sync/upstream-dev-audit-v2.2.6

Summary

This brings integration-v2 up to upstream OpenCode dev with one --no-ff merge at a frozen pin that contains v2.2.6. Every Shuvcode overlay is kept. It also fixes a cross-site guard bypass that the review found in upstream's anomalyco/opencode#62422 guard, and it sets the fork version to 2.2.6-shuv.1; this PR does not publish a release.

The v2.2.6 release commit is an audit reference only and is not merged. It changes only version fields and CHANGELOG.md, and this branch already contains every v2.2.6 code change.

Two low-severity findings are disclosed and not fixed, by the owner's decision: R2-F1 (unresolved upstream bypass) and R2-F2 (deferred release-tooling gap). Details are below.

SHAs

Ref SHA
Base, origin/integration-v2 c24144997b144545638085140ceb42f42a1a4998
Upstream dev pin, merged and frozen 64f2403ffa55b8bbbd7c05f79b630f866f0ae66a
Upstream dev tip at the last fetch, not merged c045c7cfdcac42db466b3559ea74d7cf02015126
Upstream v2.2.6 tag, audit reference, not merged dc2ffcf8bf26f9e669b6aa22b95f50e12be15e06
Guard fix 4bb0ded8a7f2959f03199461be9bc18a28559d6a
Reviewed and built fc9630a2b46083589a583cd7e0e5ba0095f98bfa
Final head 37da03f164b3eb7273cfebc8d7d1b9e52068b8d8

Reviewed SHA versus final head

  • fc9630a2b46083589a583cd7e0e5ba0095f98bfa is the last commit that changes product code or docs. It is the SHA the second review round reviewed, and the SHA every build, compiled-binary probe, smoke test and full e2e run below ran on.
  • 37da03f164b3eb7273cfebc8d7d1b9e52068b8d8 is the final head. It is one test-only commit on top of the reviewed SHA:
    • git diff --name-status fc9630a2b4..37da03f164 is M packages/server/test/request-site.test.ts (+53, −16), and nothing else: no fixture, snapshot, config, dependency, lockfile or production file.
    • Within that file it:
      • rewrites and renames the existing cross-site writes test, which now also tries renames and deletes and compares the session list with an earlier snapshot;
      • adds one normal-traffic test;
      • revises the test helpers, so each server also returns its origin and the session listing returns ids;
      • adapts one line of the spellings test.
    • No review round, build or e2e ran on it. Only the targeted checks listed under "Verification" did.

Commits (first parent)

  1. 9be458868bc90e440b3b219f064a62c2c5fca1ae chore(sync): merge upstream dev through 64f2403 (v2.2.6)
  2. bed62da58721d9fcf26da18be28a3ad3ea698fcd test(security): cover session_move authorization and lookalike API origins
  3. 597dc63c6d78565bf526a9e12ec416244b09a5c5 test(tui): serve the parent session in the subagent navigation fixture
  4. b2428136a3c57d21a9c04d49182c7eeed2e9ceee docs: note upstream removed the shuvcode/run export
  5. b3a198c0a4b0b1cd4630d1ec1a037e39b54a46b6 chore: set workspace versions to 2.2.6 and shuvcode to 2.2.6-shuv.1
  6. 4bb0ded8a7f2959f03199461be9bc18a28559d6a fix(server): match cross-site API paths the way the router does
  7. fc9630a2b46083589a583cd7e0e5ba0095f98bfa docs: add v2.2.6 and v2.2.6-shuv.1 release notes (reviewed SHA)
  8. 37da03f164b3eb7273cfebc8d7d1b9e52068b8d8 test(server): prove denied cross-site writes leave sessions unchanged (test-only, not reviewed)

From the base to the final head this is 325 commits, 3,902 files, +401,148 and −68,565 lines, almost all of it upstream. The fork's whole delta over the upstream pin is 623 files, +36,302 and −6,550 lines. The hand-written merge resolution (git show --remerge-diff 9be458868b) touches 28 files, +599 and −2,553 lines.

What the merge brings

  • Upstream security fixes the base lacked.
    • anomalyco/opencode#62422: sandboxed downloads, explicit response headers, and cross-site API rejection.
    • anomalyco/opencode#62204: session_move asks for external_directory before leaving the project.
    • Both have mutation tests on the reviewed SHA: reverting each fix makes its tests fail.
  • Every fork overlay is kept:
    • identity and branding;
    • the npm updater and the publish guard;
    • the fork plugins, with ClaudePlugin as the Anthropic provider and the hidden Claude Agent provider;
    • the shuv1337/shuvcode#434 run deadlines;
    • run with autostart: false;
    • shuv1337/shuvcode#475's fail-closed parent-agent deny inheritance;
    • the redirect guards.
  • Not merged: the 11 upstream dev commits after the pin, through c045c7cfdcac42db466b3559ea74d7cf02015126. None is a security fix; 84 of their 96 files are under services/site. Moving the pin needs a separate decision.

Cross-site guard fix (commit 6)

The bug. The guard from anomalyco/opencode#62422 tested a case-sensitive, undecoded /api prefix on the WHATWG pathname. The router dispatches with FindMyWay, which folds case, decodes percent escapes once and collapses duplicate slashes. As a result, spellings such as /API, /Api, /%61pi and //api reached API handlers without the cross-site check, on both ServerFetch and ServerProcess. The CLI binary never returned API data for those spellings: it served the web shell, or 405 for POST /API/session.

The fix. packages/server/src/request-site.ts now classifies paths with the router's own matcher (FindMyWay.make(), the configuration HttpRouter uses), keeping the artifact-view exemption.

Tests are in packages/server/test/request-site.test.ts and run through both ServerProcess and ServerFetch. This list describes the file at the final head; commit 8 changed the second and third items.

  • Cross-site reads under every spelling get 403. A second decoding and a malformed escape get 404.
  • Cross-site creates, renames and deletes under the routed spellings get 403, and the session list equals its earlier snapshot, ids and titles. This is the rewritten writes test.
  • Native and same-origin /api reads return the file. Native and same-origin creates, renames and deletes succeed, and the resulting session list is asserted exactly. This is the new normal-traffic test.
  • Native, same-origin and allowed-origin calls under /API and /%61pi still work, and the mixed-case artifact view is still exempt.
  • On ServerProcess, raw-socket request targets are classified the way the router reads them.

Mutation check. The pre-fix guard (byte-identical to the pin's), a lowercase-only patch and an unwired guard each fail the cross-site tests. The legitimate-traffic tests pass under every mutant.

Known issues, disclosed and not fixed

R2-F1 (low, UNRESOLVED): an absolute-form request target bypasses the guard's Origin check

The owner accepted this for this sync and decided not to patch it. It is upstream behavior: the pin's own guard gives the same result.

Mechanism.

  • For a cross-site non-navigation, the guard allows the request when its Origin's host equals request.headers.host.
  • Effect 4.1.0's Node server follows RFC 9112 §3.2.2: for an absolute-form target, it replaces headers.host with the target's authority.
  • So a target whose authority matches a forged Origin counts as same-host.

Repro, raw TCP to a ServerProcess.start server:

POST http://evil.example/api/session HTTP/1.1
host: 127.0.0.1:<port>
connection: close
authorization: Basic <valid credentials>
origin: https://evil.example
sec-fetch-site: cross-site
sec-fetch-mode: no-cors
content-type: application/json
content-length: <length>

{"location":{"directory":"<dir>"}}
Server Credentials Result
ServerProcess.start valid 200, session created (the list went from 0 to 1)
ServerProcess.start none 401, nothing created
Compiled shuvcode binary, a preview build of fc9630a2b4, in a network namespace (the reviewer's run) valid 200, session created (0 to 1)
ServerFetch.make UNTESTED for absolute-form targets

Controls with the same headers:

  • the origin-form POST /api/session gets 403;
  • an absolute-form target with authority 127.0.0.1 gets 403;
  • a cross-site absolute-form navigation gets 403.

A request reaches it only if all of these hold:

  1. Valid credentials. Every server requires a password, and the CLI generates one when none is configured.
  2. It arrives at the Node HTTP server (ServerProcess, which every CLI mode uses) with an absolute-form target.
  3. The target's authority equals the Origin's host, port included.
  4. It carries Sec-Fetch-Site: cross-site or same-site, and it is not a navigation.

Proxies and deployments:

  • Desktop browser plugin's forward proxy (packages/plugin-browser/src/proxy.ts). Tested: it rewrites absolute-form targets to origin-form before forwarding, so it did not deliver this request.
    • Tested through that proxy with valid credentials, an origin-form request whose Host (the page's target) equals its Origin was accepted (200, session created). This needs that hostname to resolve to the server's machine on the tunnel side.
    • Whether the desktop browser would attach credentials there was not tested.
  • pair --remote (ShuvTunnel) and desktop SSH forwarding pass bytes through unchanged, according to the code. Neither was tested.
  • Other forward proxies. No supported deployment we know of sends absolute-form targets to the server through one. A client or proxy that does delivers exactly the repro above.

This PR makes no claim that R2-F1 is unreachable from a browser. No browser was tested.

The 2.2.6-shuv.1 changelog line saying the CLI was not exposed refers to the path spellings fixed in commit 6. R2-F1 is a different mechanism, and it does reach the CLI binary.

A fix would have to stop trusting the rewritten Host for this check; request.originalUrl keeps the absolute form. No fix was designed or tested.

R2-F2 (low, DEFERRED): bare upstream versions are accepted on next and beta

Script.version (packages/script/src/index.ts:39-62) enforces the <upstream>-shuv.<n> shape only on the latest channel:

  • publish.yml with version: 2.2.6 and channel: next or beta resolves and builds a bare 2.2.6.
  • latest rejects it: "Release versions must be -shuv., got 2.2.6".

The gap has been unchanged since the base: no release-path file differs from it. It is deferred to separate release-tooling work, and next and beta behavior is unchanged in this PR.

Version: 2.2.6-shuv.1

  • Metadata. Every workspace is set to 2.2.6, and packages/cli/package.json is 2.2.6-shuv.1.
  • Changelog. CHANGELOG.md carries upstream's v2.2.6 section line for line. A v2.2.6-shuv.1 section above it notes the fork release and the guard fix as fork-specific hardening. The upstream release commit is not cherry-picked.
  • Built --version. The release-channel builds of fc9630a2b4 print shuvcode v2.2.6-shuv.1 and shuvcode-node v2.2.6-shuv.1.
  • Version resolution.
    • OPENCODE_CHANNEL=latest resolves 2.2.6-shuv.1; npm latest is 2.2.5-shuv.2.
    • An explicit OPENCODE_VERSION=2.2.6-shuv.1 resolves the same.
  • The release path cannot overwrite the CLI version:
    • build.ts and pack.ts write versions only into staged dist/*/package.json manifests, never into the source package.json.
    • Upstream's script/release.ts dispatches release.yml, which does not exist in this fork.

Verification

On the final head 37da03f164 (test-only; targeted checks only)

Check Command Result
Guard test file cd packages/server && bun test test/request-site.test.ts 10 pass, 0 fail
Guard test file, repeated cd packages/server && bun test test/request-site.test.ts --rerun-each 10 100 pass, 0 fail
Server suite cd packages/server && bun run test 135 pass, 1 skip, 0 fail (136 tests, 46 files)
Lint and typecheck bun run check oxlint 0 errors (550 warnings); Turbo typecheck 37 of 37 successful
Server typecheck cd packages/server && bun typecheck no type errors
Server typecheck with tsc cd packages/server && bun run typecheck:tsc exit 0; the server project was rebuilt, not skipped
Guard mutants the pre-fix, lowercase-only and unwired guards the cross-site write and read tests fail on both servers; the normal-traffic tests pass

The tests ran under a throwaway HOME and XDG roots. git status --short was empty afterwards. Builds, compiled-binary probes, smoke tests and e2e were not rerun on this commit. It changes only a server test file, which is not part of either build. For the app e2e, only two things were inspected:

  • no file under packages/app/e2e, and neither Playwright config, imports ServerProcess, ServerFetch, @opencode/server or RequestSite;
  • the suite runs against the mock server in packages/app/e2e/utils/mock-server.ts.

On the reviewed SHA fc9630a2b4

Check Result
Server suite 133 pass, 1 skip, 0 fail
Redirect guards (4 core files) 194 pass, 0 fail
Release scripts 20 pass, 12 skip (jj is not installed), 0 fail
bun run check, a forced uncached typecheck, the CI tsc typecheck, and the Effect rules exit 0
packages/client bun run generate exit 0, tree clean afterwards
Release-channel Bun and Node builds exit 0; --version is 2.2.6-shuv.1
Compiled service smoke, Bun and Node exit 0: election, readiness, MCP, auth, web shell, plugin discovery, 401 without credentials, SIGTERM stop
Guard on the compiled binary every cross-site spelling gets 403; legitimate traffic is unchanged
shuv1337/shuvcode#434 deadlines on the compiled binary 15 pass, 0 fail
run with autostart: false private serve --stdio child, no leftover process, no service registration
Security mutations (anomalyco/opencode#62422 fs and media, the guard, anomalyco/opencode#62204 session_move, shuv1337/shuvcode#475 deny inheritance) each fix's tests fail when it is reverted

Every compiled binary ran in a private network namespace, so none could bind the default service port 0x1337.

Package suites against the base (on round 1's b3a198c0a4; only server changed after it)

Every package suite has 0 failures on both sides, except these two, both judged pre-existing:

  • core: 11 GitLabPlugin OAuth tests on each side. They need local port 8080, which an unrelated host process holds. In a private network namespace the file passes 19 of 19 on both sides.
  • cli: "concurrent service processes elect one server", failed once on the final side under full-suite load. It is a readiness race that exists on the base by design: the registration is written about 66 ms before ready on both sides. Run alone it passes 10 of 10 on each side, and the full cli rerun passed on both.

E2E

Full app e2e on fc9630a2b4 (bun --cwd packages/app test:e2e:built): 746 pass, 3 fail out of 749. None of the three failures is specific to this branch:

Test Evidence
review.spec.ts:539 "file tree expands Windows paths and scrolls long names in both directions" Alone ×5: 5 of 5 on the final, base and pin. It passed in the full suite on b3a198c0a4, and packages/app is unchanged between the two. The test body is identical on all three. A load flake.
tabs.spec.ts:2068 "the inbox waits for initial requests and orders attention by waiting age" It failed the same way in the base's full suite in round 1. Alone ×5: 5 of 5 on the final, base and pin. inbox.tsx is identical to the pin. A load flake that predates this branch.
terminal.spec.ts:45 "after a reload, the close shortcut closes the restored terminal" Alternating --repeat-each=10 rounds: final 16 of 20, upstream pin 16 of 20, with the same failing assertion (the second pty_shell socket never connects). The base fails it the same way: 3 of 5 alone. An upstream flake; no terminal or PTY source differs from the pin.

In round 1 the final side had 748 pass and 1 fail, against 659 pass and 4 fail on the base. All four tests that failed there were separated the same way, as load or upstream flakes.

Unverified

  • End-to-end daemon-to-client run with a model reply is UNVERIFIED.
    • The compiled daemon smoke covers the persistent PTY round trip (attach, replay, controller change, exit code) and the shuv1337/shuvcode#434 stall deadline. No run reached a model reply.
    • With network, on the preview build of b3a198c0a4, the model call was refused with "OpenCode 1.18.0 or newer is required to use the free tier". The likely cause is the preview version string, but that is not proven.
    • The release-channel binary of fc9630a2b4 ran with loopback only, so run retried ECONNREFUSED until it was killed.
  • R2-F1 browser reachability is untested (see above).

Review

Two review rounds ran, with the same model choices in both: Claude Opus 5.5 and composer-2.5-fast. In round 2 the Opus seat was the round-1 agent resumed with its context, and the composer seat was a fresh agent.

  • Round 1, on b3a198c0a4, found the medium path-spelling bypass. Commit 6 fixes it, and both seats confirmed the fix in round 2.
  • Round 2, on fc9630a2b46083589a583cd7e0e5ba0095f98bfa, found R2-F1 and R2-F2.
  • The final head 37da03f164 was not reviewed.

Effort: the Opus seat inherited the parent's setting, whose exact value is unknown. composer-2.5-fast has no effort setting.

Feature flag

None. This is an upstream sync plus a security fix, and fixes do not get flags.

Hona and others added 30 commits November 4, 2026 21:07
thdxr and others added 26 commits November 6, 2026 15:41
Merges anomalyco/opencode dev at 64f2403 (merge base e1b3b05), which
carries the v2.2.6 code including the fs.read/media download headers and
cross-site API check (anomalyco#62422) and the session_move external_directory
check (anomalyco#62204).

Fork overlays re-applied at upstream's new owners:
- run: #434 setup and admission deadlines wrap switchAgent, switchModel and
  prompt admission inside the new lifecycle; the first execution-end wait is
  captured before consuming events so a fast terminal event is not missed.
- app: hidden-provider filters move into the connect controller and the
  directory cache getter; the Claude Agent setup view and its e2e test go.
- plugin-browser: unretrieved-command replay, inventory wait and the
  retired-session ban move into the attachment machine.
- core: subagent recovery reads and writes through part rows.
- identity: shuvcode pairing commands; fork CI keeps its six workflows.

bun.lock regenerated with bun install.

Co-authored-by: Cursor <cursoragent@cursor.com>
…igins

session_move now asks external_directory outside the project (anomalyco#62204): a rejection queues nothing, an approved move asks once, and a move into a project worktree does not ask. A child session cannot move past a parent agent's external_directory deny (#475). Cross-site API calls from lookalike localhost and opencode.ai hosts get 403 (anomalyco#62422).

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 5f6460d568bf652363614016664f72d2f137da1c)
Subagent sessions sync their family root for permissions and forms (#429), so the upstream test needs the parent session.

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit bc84f066a39a3c644641147dbf7ac46807ee6646)
Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 331c6a1ec3a07b0591bb6e81291c80be136060b5)
Metadata only. The upstream release commit dc2ffcf (v2.2.6) changes only version fields and CHANGELOG.md, and its parent 33f94fc is an ancestor of the merged upstream/dev 64f2403, so this tree already has every v2.2.6 code change. The release commit is not merged.

Co-authored-by: Cursor <cursoragent@cursor.com>
The cross-site guard decided whether a request targeted the API with a
case-sensitive WHATWG pathname prefix test, while the router folds case,
decodes percent escapes and collapses duplicate slashes. `/API/...`,
`/%61pi/...` and `//api/...` reached API handlers without the guard on
ServerFetch and on ServerProcess without the CLI web shell in front.

Classify the request URL with the router's own FindMyWay matcher in its
default configuration instead, keeping the artifact view exemption as a
route in the same matcher.

Co-authored-by: Cursor <cursoragent@cursor.com>
Bring over upstream's v2.2.6 section from the release commit's changelog text only, and add a fork section for the cross-site guard hardening.

Co-authored-by: Cursor <cursoragent@cursor.com>
Seed a session before the denied cross-site creates, updates and deletes and assert the list is unchanged, and cover plain /api reads, creates, updates and deletes from native clients and the same origin, through both ServerProcess and ServerFetch.

Co-authored-by: Cursor <cursoragent@cursor.com>
shuv1337 and others added 2 commits November 6, 2026 22:21
The upstream dev merge adds files that fail the fleet hygiene job on
PR #477: placeholder and story-fixture home paths, `@/home/<segment>`
import aliases, an upstream developer's path in a services/site code
comment, and intentional SC2043 single-value loops in
services/site/script/press-media.sh. Allowlist them by exact string or
single file instead of editing upstream files.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the whole-file exceptions from the previous commit:

- press-media.sh: define `set=locked` once instead of looping over a
  single value, so shellcheck scans the file with no SC2043 warning and
  no allowlist entry. The commands the script runs are unchanged.
- source-logo.ts: drop an upstream developer's worktree path from a
  code comment instead of exempting the file.
- Allow the sidebar alias imports and the story fixture home as exact
  text tokens (`/home/projects`, `/home/sessions`, `/Users/alex`)
  instead of exempting whole files.

Co-authored-by: Cursor <cursoragent@cursor.com>

shuv1337 commented Nov 7, 2026

Copy link
Copy Markdown
Owner Author

The ACP close path deletes a session when its message history is empty, but an accepted prompt can still be waiting in session_inbox with resume: false. Closing the session in that state appears to cascade-delete the queued prompt. Please include pending inbox work in the unused-session check, ideally with an atomic server-side delete-if-unused operation, and add a regression test for a queued prompt with empty message history. This is based on code tracing; I haven’t reproduced it at runtime.

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.