Skip to content

[PIR #1869] Local browser access to /ide/ on Tower: forward cookie and Open IDE link - #1872

Merged
amrmelsayed merged 32 commits into
mainfrom
builder/pir-1869
Nov 3, 2026
Merged

amrmelsayed merged 32 commits into
mainfrom
builder/pir-1869

Conversation

@amrmelsayed

@amrmelsayed amrmelsayed commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

PIR Review: Local browser access to /ide/ on Tower

Fixes #1869

Summary

A local browser can now open the Codev IDE through Tower. Each workspace card on the Tower dashboard has an Open IDE row, first on the card. It calls the key-gated POST /api/ide/session, which sets a codev-ide-session cookie: HttpOnly, SameSite=Strict, Path=/ide, with its value derived from the local key by HMAC. It then opens /ide/?folder=<workspace> in a new tab. The forward's single authorization check (decideForwardAuthorization) accepts that cookie only behind a cross-port Origin / Sec-Fetch-Site check; every other Tower route stays header-only.

The live browser run then showed that the workbench was bypassing Tower. Its sockets dialed the IDE server's own port, and the IDE token sat in a cookie that every localhost port could read. Amendment 1 fixed both, for local requests only:

  • The forward stamps x-forwarded-host with Tower's own Host, so the workbench connects through Tower.
  • It strips the IDE server's vscode-tkn Set-Cookie and injects the token into the workbench's configuration instead.
  • Tower's token replaces any browser-held copy.
  • Client-supplied x-forwarded-* headers are stripped.
  • The token is regenerated on every IDE server spawn.

Files Changed

  • codev-skeleton/resources/commands/agent-farm.md (+31 / -4)
  • codev/plans/1869-local-browser-access-to-ide-on.md (+395 / -0)
  • codev/projects/1869-local-browser-access-to-ide-on/status.yaml (+22 / -0)
  • codev/resources/arch.md (+1 / -1)
  • codev/resources/commands/agent-farm.md (+31 / -4)
  • codev/resources/lessons-learned.md (+6 / -0)
  • codev/state/pir-1869_thread.md (+29 / -0)
  • packages/codev/src/agent-farm/__tests__/ide-forward-integration.test.ts (+85 / -0)
  • packages/codev/src/agent-farm/__tests__/ide-forward.test.ts (+168 / -8)
  • packages/codev/src/agent-farm/__tests__/ide-server-preflight.test.ts (+32 / -1)
  • packages/codev/src/agent-farm/__tests__/pir-1869-ide-cookie-ws.test.ts (+185 / -0)
  • packages/codev/src/agent-farm/__tests__/pir-1869-ide-forward-cookie.test.ts (+215 / -0)
  • packages/codev/src/agent-farm/__tests__/tower-routes.test.ts (+127 / -0)
  • packages/codev/src/agent-farm/lib/ide-record.ts (+29 / -0)
  • packages/codev/src/agent-farm/servers/ide-forward.ts (+248 / -30)
  • packages/codev/src/agent-farm/servers/ide-server.ts (+4 / -2)
  • packages/codev/src/agent-farm/servers/tower-routes.ts (+87 / -5)
  • packages/codev/src/agent-farm/servers/tower-websocket.ts (+11 / -7)
  • packages/codev/src/agent-farm/utils/server-utils.ts (+150 / -25)
  • packages/codev/templates/tower.html (+66 / -4)

Commits

Test Results

  • pnpm build: ✓ pass
  • tsc --noEmit (packages/codev): ✓ clean
  • vitest (packages/codev): 7218 passed, 1 failed locally, 49 skipped. The one failure is protocol-drift-audit.test.ts ("registry unreachable"), caused by this machine's npm config (see Flaky Tests). It is unrelated, passes in CI, and was not skipped. New coverage:
    • pir-1869-ide-forward-cookie.test.ts: the seam, the cross-port guard, the derivation, and isRequestAllowed delegation.
    • pir-1869-ide-cookie-ws.test.ts: the real setupUpgradeHandler with real ws clients and a stub IDE server.
    • Additions to ide-forward.test.ts, ide-forward-integration.test.ts, ide-server-preflight.test.ts and tower-routes.test.ts.
  • CI on PR [PIR #1869] Local browser access to /ide/ on Tower: forward cookie and Open IDE link #1872: 7/7 green (Unit, CLI ubuntu/macOS, CLI Integration, Tower Integration, Package Install, Artifact-Canvas Browser).
  • Mutation check: with the cross-port guard disabled, the cross-port and Sec-Fetch-Site WebSocket tests fail.
  • Manual / Playwright, against this branch's real handleRequest + setupUpgradeHandler on a :4199 harness with the real IDE server on :8200 (live Tower untouched):
    • Open IDE opens the workbench with the Codev sidebar ("Codev: Connected"), and the tab has no opener.

    • After Open IDE and after a direct reload, the cookie jar holds only codev-ide-session (127.0.0.1, /ide, HttpOnly, Strict). No vscode-tkn.

    • Both workbench WebSockets are ws://127.0.0.1:4199/ide/… and stay open. None target :8200.

    • A page on another localhost port holding the cookie: its WebSocket to Tower is closed with 4401, and its credentialed fetch is blocked.

    • curl:

      Request Result
      bare /ide/ 401
      rebound Host 401
      cookie on a non-/ide route 401
      cookie with Origin: http://localhost:3000 401
      direct :8200 with the forward cookie 403
      key header with a cloud Origin 200
  • Security architect: approved the plan (M1-M4, S1-S4), the amendment (C1-C4, plus (a') local-only with four conditions), and the implementation at 95c01b1, re-running the six suites (252/252).
  • Owner: exercised Open IDE live on the harness and approved dev-approval on 2a9a55a, after asking for the IDE row to move above Overview.

Architecture Updates

COLD codev/resources/arch.md, invariant 9 (Tower API Authentication) now records the one ambient credential:

  • /ide accepts codev-ide-session only in decideForwardAuthorization, in the order Host → header → cookie with its Origin check.
  • Tower must never set Access-Control-Allow-Credentials.
  • For local requests, the forward stamps x-forwarded-host, keeps the IDE token out of the cookie jar (it goes into the config instead), replaces any browser-held vscode-tkn, and rotates the token on every spawn.
  • Client x-forwarded-* headers are stripped; the tunnel path keeps the Set-Cookie.

No HOT arch-critical.md change: this is a subsystem-scoped auth detail, and the hot file already points into arch.md for Tower auth.

Lessons Learned Updates

COLD codev/resources/lessons-learned.md:

  • Security:
    • An ambient credential needs an Origin check that a header never did. On loopback, ports are not part of a SameSite site.
    • Before stripping a credential that a third-party client is handed, find out how the client reads it: grep the shipped bundle.
    • A proxy that rewrites Host must stamp x-forwarded-host, or the backend's own URLs point past the proxy.
  • Testing:
    • The browser run found what 65 unit and integration tests could not. Assert the real WebSocket targets and the cookie jar contents.
    • A scoped @cluesmith:registry in the user npm config makes protocol-drift-audit fail every time. Work around it with NPM_CONFIG_USERCONFIG=<empty file>; never edit ~/.npmrc.

No HOT lessons-critical.md change: the existing hot lesson "verify the real user path end-to-end; derive the check from the user's action" already covers the general rule, and this PR is one more instance of it.

Things to Look At During PR Review

3-way consultation (single pass): Gemini skipped; Codex and Claude returned REQUEST_CHANGES

PIR runs the consultation once, so these fixes were not independently re-reviewed. The reviewer at the pr gate is the remaining check.

  • Codex: the forward relayed an upstream Access-Control-Allow-Credentials (breaks the M2 invariant). Fixed. STRIP_FROM_RESPONSE in ide-forward.ts drops it from HTTP responses and from the rebuilt 101, on both the local and tunnel paths. Tests: unit tests for both functions on both paths, plus an integration test where the stub upstream sends the header and Tower does not.

  • Codex: Open IDE on a recorded-but-dead server landed on a 502, because the forward only started an asynchronous respawn. Fixed. POST /api/ide/session now awaits ensureIdeServerLive before minting, so it respawns exactly as the forward would. It returns 503 ("IDE server unavailable: run afx ide start") when nothing can be made live, and the dashboard shows that as an error toast. Test: a route test asserting that the liveness check runs and that null returns 503 with no Set-Cookie.

  • Claude: the dashboard polled GET /api/ide every 5 s, which runs a blocking execSync lsof on Tower's event loop. Fixed. tower.html now checks IDE status on load and when the tab becomes visible again, never on the 5 s poll. Verified in Playwright: over about 12 s, /api/status was requested 3 times and /api/ide once.

  • Claude: ensureIdeConnectionToken has no production callers. Addressed. Its doc comment now marks it as a test seam; spawns use rotateIdeConnectionToken.

  • Claude: server-utils.ts pulled tower-client / codev-sdk in through ide-record. Fixed. IDE_DEFAULT_PREFIX and matchIdePrefix moved to a dependency-free lib/ide-prefix.ts, which ide-record.ts re-exports.

  • Claude: the local x-forwarded-proto is hardcoded to http. No change. Behind a non-tunnel HTTPS reverse proxy the workbench would build ws:// URLs. That case was already broken before this PR, and C1 forbids trusting a client-supplied x-forwarded-proto. Supporting a local TLS proxy is outside Local browser access to /ide/ on Tower: bootstrap the forward credential for a loopback browser and add an Open IDE link per workspace #1869.

  • Claude: the cross-port guard passes a request that carries neither Origin nor Sec-Fetch-Site. No change; this is intended. Top-level navigation needs it. WebSockets and cross-origin fetches always carry Origin, so the M1 hole stays closed. The remaining case is a cross-port subresource GET that the page cannot read.

  • Claude: Unit Tests was still pending on the reviewed head. It was re-checked on the final head before handoff.

  • The workbench HTML rewrite in servers/ide-forward.ts (injectWorkbenchConnectionToken, relayWorkbenchHtml). Tower now rewrites one third-party HTML page.

    • It rewrites only a non-tunnel, 200, text/html response with no Content-Encoding, within 2 MB. A chunked body that outgrows the bound mid-stream is relayed unchanged.
    • The token must match ^[A-Za-z0-9-]+$.
    • The config is decoded and must JSON.parse both before and after the edit, and {} is handled.
    • Otherwise the page is relayed unchanged and the workbench fails visibly with "auth mismatch".
    • It depends on the upstream meta tag id vscode-workbench-web-configuration, and on the client preferring options.connectionToken over the cookie, which I verified in the shipped bundle. An upstream change would surface as that visible "auth mismatch", not a silent hole.
  • The order in decideForwardAuthorization. A valid key header wins without the Origin check; that is what keeps tunnel requests, which carry a cloud Origin, working. Only the cookie branch carries the guard.

  • mergeConnectionTokenCookie now REPLACES an incoming vscode-tkn. Before this change it never overwrote one. The change is intentional (C2): Tower is the sole source of the token.

  • Rotation happens only on spawn, never on adopt (ide-server.ts). Rotating under a live server would lock Tower out of it.

  • The tunnel path is unchanged except C1/C2. The cloud workbench still gets the token through the server's Set-Cookie. Moving the cloud to the config-injected token is a follow-up filed by the architect.

  • Accepted residuals, documented in agent-farm.md:

    • On a multi-user host, another OS user can collect and replay the /ide cookie. The architect filed a follow-up.
    • A browser that already holds vscode-tkn from a hand-built ?tkn= URL keeps it until the next IDE server spawn.

How to Test Locally

  • View diff: VSCode sidebar → right-click builder pir-1869 → Review Diff
  • Run dev: afx dev pir-1869. To leave the live Tower untouched, run a harness that serves this branch's handleRequest / setupUpgradeHandler on another port, as was done for dev-approval.
  • What to verify:
    • Once afx ide start has recorded a server, the dashboard shows IDE as the first row on each running workspace card.
    • Open IDE opens the workbench with the Codev sidebar in a new tab.
    • DevTools → Application → Cookies shows only codev-ide-session (/ide, HttpOnly, Strict), and no vscode-tkn.
    • DevTools → Network → WS: every socket targets Tower's port, and none target :8200.
    • A direct reload of the workbench tab works.
    • curl http://127.0.0.1:4100/ide/ returns 401.

Flaky Tests

No tests were skipped. One test fails locally because of this machine's environment; it is not flaky in the usual sense.

  • packages/codev/src/__tests__/protocol-drift-audit.test.ts:178 ("the REAL default lookup is offline-tolerant AND bounded when the registry is unreachable").
    • Cause: a scoped @cluesmith:registry in the user npm config overrides the test's npm_config_registry, so npm view reaches the real registry. An empty npm cache does not help.
    • It passes in CI. Porch's local checks were run with NPM_CONFIG_USERCONFIG=<empty temp file>, an environment-only change, with no repo change and no skip.
    • The architect is filing the test-side fix: the test should neutralize the user config itself.

@amrmelsayed
amrmelsayed marked this pull request as ready for review November 3, 2026 02:25
@amrmelsayed

Copy link
Copy Markdown
Collaborator Author

Architect integration review (head d5b5ea8a6)

High-tier review (Tower security model, >500 lines): 3-way CMAP plus the security architect's delta read. Claude APPROVE, Codex APPROVE, Gemini skipped (agy unauthenticated on the architect's machine). Security seat: APPROVE on the implementation (95c01b1) and on the delta to this head. CI: 7/7 green on d5b5ea8a6; PR ready, mergeable.

Verified at the gate (PIR is single-pass, so the consultation dispositions were re-checked here rather than trusted):

  • The forward's single authorization seam is unchanged from the security-approved implementation apart from one import line; no second entry point into the HTTP or WebSocket forward (grep-verified by Claude); the /ide delegation precedes the public-route check.
  • Consultation fix 1 (upstream Access-Control-Allow-Credentials stripped on both paths, HTTP and 101) strengthens the M2 invariant and is tested end-to-end.
  • Consultation fix 2 (mint awaits a live IDE server) widens nothing: no record returns 503 and spawns nothing; a live server is only probed; a recorded-but-dead server gets the same locked respawn the forward already fires, with one shared in-flight promise, so concurrent mints yield one spawn and one token rotation.
  • Fixes 3 to 5 (IDE status off the 5 s poll, dependency-free lib/ide-prefix.ts, test-seam doc) are hygiene; both builder rebuttals (local x-forwarded-proto fixed to http; requests carrying neither Origin nor Sec-Fetch-Site pass the guard) are accepted by security with reasoning recorded.
  • Docs are byte-identical across codev/ and codev-skeleton/.

Non-blocking follow-ups (architect files; not for this PR):

  • Preflight or boot assertion on the upstream vscode-workbench-web-configuration meta id, so upstream drift in the workbench HTML is detected rather than silently falling back (Claude, Codex).
  • SSE-driven IDE-availability refresh on the dashboard; today a foreground dashboard misses a new afx ide start until reload or re-focus (Claude).
  • Extract IDE credential policy from server-utils.ts into a dependency-free lib/ide-auth.ts (Claude).
  • A HOT-tier anchor for the Allow-Credentials invariant, as a clause on an existing fact (the hot file is capped) (Claude).
  • The cloud config-injection follow-up should note that spawn rotation invalidates a live cloud workbench's cookie copy until reload (Claude).
  • Pre-existing: tower-server.ts logs a full req.url on the boot-not-ready 503 path, outside this PR's pathname-only sanitization. Negligible today.
  • Already recorded: Tower loopback trust on multi-user hosts; move the cloud workbench to the config-injected token; dashboard frame-ancestors hygiene.

Gate: ready for the owner's pr word.

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.

Local browser access to /ide/ on Tower: bootstrap the forward credential for a loopback browser and add an Open IDE link per workspace

1 participant