Pārlūkot izejas kodu

fix(web): cover authenticated host runtimes

Tianyi Cui 1 mēnesi atpakaļ
vecāks
revīzija
ce031ddd16
88 mainītis faili ar 303 papildinājumiem un 144 dzēšanām
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml
  2. 3 1
      .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
  3. 3 1
      .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md
  4. 1 1
      apps/web/tests/access-confirmation.e2e.ts
  5. 2 2
      apps/web/tests/agent-preset-authoring.e2e.ts
  6. 6 6
      apps/web/tests/agent-preset-selection.e2e.ts
  7. 1 1
      apps/web/tests/approval-composer.e2e.ts
  8. 1 1
      apps/web/tests/background-job-list.e2e.ts
  9. 1 1
      apps/web/tests/bash-abort-row.e2e.ts
  10. 1 1
      apps/web/tests/chat-continuous-conversation.e2e.ts
  11. 1 1
      apps/web/tests/chat-long-interactions.e2e.ts
  12. 1 1
      apps/web/tests/chat-scroll-contract.e2e.ts
  13. 1 1
      apps/web/tests/code-mode-round.e2e.ts
  14. 1 1
      apps/web/tests/cold-blank-session.e2e.ts
  15. 2 2
      apps/web/tests/complex-history.perf.ts
  16. 1 1
      apps/web/tests/composer-draft-scroll.e2e.ts
  17. 1 1
      apps/web/tests/composer-tab-geometry.e2e.ts
  18. 1 1
      apps/web/tests/conversation-column-overflow.e2e.ts
  19. 1 1
      apps/web/tests/cordis-tool-round.e2e.ts
  20. 1 1
      apps/web/tests/declared-reasoning.e2e.ts
  21. 1 1
      apps/web/tests/default-model.e2e.ts
  22. 1 1
      apps/web/tests/details-session-lifecycle.e2e.ts
  23. 1 1
      apps/web/tests/feedback-command.e2e.ts
  24. 1 1
      apps/web/tests/github-ready-review.e2e.ts
  25. 2 0
      apps/web/tests/goal-bar.e2e.ts
  26. 1 1
      apps/web/tests/goal-command-presentation.e2e.ts
  27. 1 1
      apps/web/tests/goal-multi-turn-actions.e2e.ts
  28. 2 2
      apps/web/tests/lifecycle-chrome.e2e.ts
  29. 1 1
      apps/web/tests/live-interactions.e2e.ts
  30. 1 1
      apps/web/tests/markdown-cjk-strong.e2e.ts
  31. 1 1
      apps/web/tests/markdown-images.e2e.ts
  32. 1 1
      apps/web/tests/markdown-inline-code-links.e2e.ts
  33. 2 2
      apps/web/tests/markdown-wide-table.e2e.ts
  34. 1 1
      apps/web/tests/math-rendering.e2e.ts
  35. 1 1
      apps/web/tests/message-actions.e2e.ts
  36. 1 1
      apps/web/tests/message-feedback-layout.e2e.ts
  37. 1 1
      apps/web/tests/message-feedback-protocol.snapshot.ts
  38. 1 1
      apps/web/tests/message-feedback.e2e.ts
  39. 1 1
      apps/web/tests/models-settings.e2e.ts
  40. 2 2
      apps/web/tests/navigation-panes.e2e.ts
  41. 1 1
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  42. 1 1
      apps/web/tests/onboarding-usable-provider.e2e.ts
  43. 1 1
      apps/web/tests/permission-policy-context.e2e.ts
  44. 1 1
      apps/web/tests/plan-control-row.e2e.ts
  45. 1 1
      apps/web/tests/plan-review.e2e.ts
  46. 1 1
      apps/web/tests/plugin-config.e2e.ts
  47. 1 1
      apps/web/tests/produced-file-mentions.e2e.ts
  48. 1 1
      apps/web/tests/produced-files.e2e.ts
  49. 1 1
      apps/web/tests/pwsh-terminal.e2e.ts
  50. 1 1
      apps/web/tests/question-composer.e2e.ts
  51. 2 2
      apps/web/tests/queue-actions.e2e.ts
  52. 1 1
      apps/web/tests/rail-search-expand.e2e.ts
  53. 1 1
      apps/web/tests/reference-composer.e2e.ts
  54. 1 1
      apps/web/tests/remote-welcome.e2e.ts
  55. 1 1
      apps/web/tests/replay-round-trip.e2e.ts
  56. 25 1
      apps/web/tests/scaffold.ts
  57. 1 1
      apps/web/tests/schedule-after.e2e.ts
  58. 5 1
      apps/web/tests/seeded-history.e2e.ts
  59. 6 6
      apps/web/tests/settings-chrome.e2e.ts
  60. 1 1
      apps/web/tests/sidebar-scrollbar.e2e.ts
  61. 1 1
      apps/web/tests/sidebar-subagent-activity.e2e.ts
  62. 1 1
      apps/web/tests/skill-invocation-policy.e2e.ts
  63. 1 1
      apps/web/tests/skill-tool-row.e2e.ts
  64. 1 1
      apps/web/tests/skill-user-invoke.e2e.ts
  65. 1 1
      apps/web/tests/startup-auto-selection.e2e.ts
  66. 1 1
      apps/web/tests/startup-rpc-budget.e2e.ts
  67. 1 1
      apps/web/tests/stats-paged-history.e2e.ts
  68. 4 4
      apps/web/tests/steering.e2e.ts
  69. 1 1
      apps/web/tests/subagent-conversation.e2e.ts
  70. 1 1
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  71. 8 8
      apps/web/tests/subagent-interrupt.e2e.ts
  72. 1 1
      apps/web/tests/trajectory-virtualization.e2e.ts
  73. 1 1
      apps/web/tests/turn-tail-actions.e2e.ts
  74. 1 1
      apps/web/tests/web-search-round.e2e.ts
  75. 1 1
      apps/web/tests/workflow-run.e2e.ts
  76. 1 1
      apps/web/tests/workspace-management.e2e.ts
  77. 2 2
      docs/module-graph.i18n.yaml
  78. 6 4
      docs/module-graph.md
  79. 6 4
      docs/module-graph.zh.md
  80. 32 1
      packages/api/remotes/tests/built-lib.e2e.ts
  81. 26 1
      packages/bundle/web-app/tests/browser-open.spec.ts
  82. 1 0
      packages/bundle/web-app/tests/web-app.spec.ts
  83. 27 10
      packages/client/connection/src/browser-auth.ts
  84. 7 0
      packages/client/connection/tests/api-request-trust.host.spec.ts
  85. 10 3
      packages/client/connection/tests/browser-auth.host.spec.ts
  86. 11 14
      packages/experimental/webworker-runtime/src/transport/tunnel.ts
  87. 3 3
      packages/experimental/webworker-runtime/src/worker-host.ts
  88. 36 0
      packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
-2026-08-24-browser-token-authentication.md: fb55a35018b7c8df45213f9b9e59dccc3512c0ab
-2026-08-24-browser-token-authentication.zh.md: 6cd77117395ff753d36d39da0ec247e064bdb393
+2026-08-24-browser-token-authentication.md: 515990b81f3c92b7f3c422dcc40acfed1e5996ac
+2026-08-24-browser-token-authentication.zh.md: 8066e2c3f62245779c6a03b5160c41e4bbc94ba5

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md

@@ -18,11 +18,13 @@ The cookie is a signed, authority-bound bearer. Its deterministic name and signe
 
 The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection reads the record for each verification, so deletion or replacement revokes every existing cookie without restarting the process. A missing record is recreated only by a valid process-token exchange. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
 
+The in-page Web Worker preview exposes no network socket. Its page-owned `postMessage` tunnel enters the real route first, then retries a 401 or 403 through the worker-local fetch handler. This keeps Connection interceptors while limiting the authentication bypass to the page that created the Host worker.
+
 The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not imply supported network deployment, TLS, forwarding-header interpretation, or proxy configuration.
 
 ## Verification
 
-Unit coverage pins token comparison, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, and invalid durable records. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart.
+Unit coverage pins token comparison, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, and invalid durable records. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart.
 
 ## Alternatives considered
 

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md

@@ -18,11 +18,13 @@ cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payloa
 
 HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 每次校验都读取记录,因此删除或替换记录无需重启进程即可撤销全部既有 cookie。缺失记录只能由有效进程令牌交换重新创建。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
 
+页内 Web Worker preview 不暴露网络 socket。其由页面持有的 `postMessage` tunnel 先进入真实 route,收到 401 或 403 后再经 worker 本地 fetch handler 重试。这样既保留 Connection interceptor,又把认证绕过限制在创建 Host worker 的页面内。
+
 随附 CLI 继续拒绝 `--host 0.0.0.0`。认证不代表支持网络部署、TLS、转发 header 解释或代理配置。
 
 ## 验证
 
-单元覆盖固定令牌比较、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除及无效持久记录。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。
+单元覆盖固定令牌比较、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除及无效持久记录。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。
 
 ## 曾考虑的替代方案
 

+ 1 - 1
apps/web/tests/access-confirmation.e2e.ts

@@ -35,7 +35,7 @@ describe('web e2e: Full access confirmation', () => {
     // callback.
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
   }, 120_000)

+ 2 - 2
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -69,7 +69,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     // The scenario asserts the shipped Chinese copy, so the browser asks for it.
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 
@@ -257,7 +257,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     await dialog.waitFor({ state: 'detached', timeout: 10_000 })
     await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 })
     await expect.poll(async () => {
-      const response = await fetch(`${scaffold.baseUrl}/api/session/list`, {
+      const response = await scaffold.hostFetch('/api/session/list', {
         method: 'POST',
         headers: { 'content-type': 'application/json' },
         body: JSON.stringify({

+ 6 - 6
apps/web/tests/agent-preset-selection.e2e.ts

@@ -137,11 +137,11 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
  * produced. Addressed by id rather than by scanning the serialized list: the
  * seeded session records `minimal` too, so a substring match over the whole
  * list answers before the switch has landed.
- * @param baseUrl - the scaffold's origin.
+ * @param scaffold - authenticated Web Host scaffold.
  * @returns the live session's preset, or undefined before it is listed.
  */
-async function livePreset(baseUrl: string): Promise<string | undefined> {
-  const response = await fetch(`${baseUrl}/api/session/list`, {
+async function livePreset(scaffold: WebScaffold): Promise<string | undefined> {
+  const response = await scaffold.hostFetch('/api/session/list', {
     method: 'POST',
     headers: { 'content-type': 'application/json' },
     body: JSON.stringify({
@@ -190,7 +190,7 @@ describe('web e2e: agent-preset selection', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 
@@ -234,7 +234,7 @@ describe('web e2e: agent-preset selection', () => {
 
     // The chip stages; the blank session the workspace connect produced is
     // what the stage lands on. The host's own answer is what comes back.
-    await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal')
+    await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
   })
 
   it('re-reads the slash catalog through the composition the switch installed', async () => {
@@ -264,7 +264,7 @@ describe('web e2e: agent-preset selection', () => {
     // instead of leaving the session reading the narrower composition.
     await page.getByRole('button', { name: 'Minimal mode' }).click()
     await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click()
-    await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard')
+    await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
 
     await composer.fill('/')
     await expect.poll(() => menuOptions(page), { timeout: 15_000 })

+ 1 - 1
apps/web/tests/approval-composer.e2e.ts

@@ -43,7 +43,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/background-job-list.e2e.ts

@@ -56,7 +56,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
 
     const groupRow = page.locator('[role="treeitem"]').first()

+ 1 - 1
apps/web/tests/bash-abort-row.e2e.ts

@@ -34,7 +34,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
 
     const groupRow = page.locator('[role="treeitem"]').first()

+ 1 - 1
apps/web/tests/chat-continuous-conversation.e2e.ts

@@ -197,7 +197,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
     page.on('console', (message) => {
       if (message.type() === 'warning') consoleWarnings.push(message.text())
     })
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e')
   }, 120_000)

+ 1 - 1
apps/web/tests/chat-long-interactions.e2e.ts

@@ -155,7 +155,7 @@ describe('web e2e: long Chat interaction contract', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser, 900)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await openSeed(page)
   }, 120_000)

+ 1 - 1
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -166,7 +166,7 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWor
     scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
     page = await newEnglishPage(browser, 900)
     const tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Session-list bootstrap can replace the controlled search state. Wait
     // for the seeded baseline before openSeed starts the lazy content query

+ 1 - 1
apps/web/tests/code-mode-round.e2e.ts

@@ -38,7 +38,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/cold-blank-session.e2e.ts

@@ -39,7 +39,7 @@ describe('web e2e: cold blank Session visibility', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 2 - 2
apps/web/tests/complex-history.perf.ts

@@ -907,7 +907,7 @@ async function openPerformancePage(
   world: PerformanceWorld,
   expectedSessions: number,
 ): Promise<Locator> {
-  await world.page.goto(world.scaffold.baseUrl, { waitUntil: 'load' })
+  await world.page.goto(world.scaffold.authenticatedUrl, { waitUntil: 'load' })
   await world.page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   const group = world.page.getByRole('treeitem').first()
   await expect.poll(() => group.textContent(), { timeout: 30_000 })
@@ -1389,7 +1389,7 @@ describe('manual web performance: complex workspace and history', () => {
     })
     let testFailure: unknown
     try {
-      await world.page.goto(world.scaffold.baseUrl, { waitUntil: 'load' })
+      await world.page.goto(world.scaffold.authenticatedUrl, { waitUntil: 'load' })
       await world.page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await connectFreshWorkspace(world.page, world.scaffold.workspaceCwd, 'continuous-conversation-perf')
       const cdp = await world.page.context().newCDPSession(world.page)

+ 1 - 1
apps/web/tests/composer-draft-scroll.e2e.ts

@@ -195,7 +195,7 @@ describe('web e2e: composer draft scrolling', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
     await page.locator('textarea:enabled').first().fill(DRAFT)

+ 1 - 1
apps/web/tests/composer-tab-geometry.e2e.ts

@@ -248,7 +248,7 @@ describe('web e2e: input card position across view tabs', () => {
     browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
     page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await openSeededSession(page)
     await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })

+ 1 - 1
apps/web/tests/conversation-column-overflow.e2e.ts

@@ -208,7 +208,7 @@ describe('web e2e: the conversation column scrolls on one axis', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser, 900)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[data-conversation-scroll] [class*="heroGlow"]', { timeout: 30_000 })
   }, 180_000)
 

+ 1 - 1
apps/web/tests/cordis-tool-round.e2e.ts

@@ -91,7 +91,7 @@ describe('web e2e: Cordis tools use their owned cards', () => {
       })
     })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/declared-reasoning.e2e.ts

@@ -51,7 +51,7 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
     browser = await chromium.launch()
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/default-model.e2e.ts

@@ -81,7 +81,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
     browser = await chromium.launch()
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // The composer's seats only exist once a workspace is connected: without
     // one the input is the locked placeholder and no session scope is open.

+ 1 - 1
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -78,7 +78,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await appFrame(page).waitFor({ timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/feedback-command.e2e.ts

@@ -45,7 +45,7 @@ describe('web e2e: /feedback command acknowledgement', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connecting a workspace births the blank session whose
     // live composer accepts the slash line.

+ 1 - 1
apps/web/tests/github-ready-review.e2e.ts

@@ -98,7 +98,7 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => {
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
     await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 60_000)
 

+ 2 - 0
apps/web/tests/goal-bar.e2e.ts

@@ -30,6 +30,8 @@ describe('web e2e: goal bar clear convergence', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
+    const login = await page.context().request.get(scaffold.authenticatedUrl, { maxRedirects: 0 })
+    expect(login.status()).toBe(303)
     await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)

+ 1 - 1
apps/web/tests/goal-command-presentation.e2e.ts

@@ -33,7 +33,7 @@ describe('web e2e: /goal human transcript presentation', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/goal-multi-turn-actions.e2e.ts

@@ -117,7 +117,7 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }

+ 2 - 2
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -53,7 +53,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
@@ -103,7 +103,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     const activePage = await newEnglishPage(browser)
     const activeTripwire = watchConsole(activePage)
     try {
-      await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
+      await activePage.goto(activeScaffold.authenticatedUrl, { waitUntil: 'load' })
       await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
       const input = activePage.locator('textarea').first()

+ 1 - 1
apps/web/tests/live-interactions.e2e.ts

@@ -104,7 +104,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
     await connectFreshWorkspace(page, scaffold.workspaceCwd)

+ 1 - 1
apps/web/tests/markdown-cjk-strong.e2e.ts

@@ -96,7 +96,7 @@ describe('web e2e: CJK-adjacent Markdown strong emphasis', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/markdown-images.e2e.ts

@@ -153,7 +153,7 @@ describe('web e2e: remote Markdown image rendering', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/markdown-inline-code-links.e2e.ts

@@ -95,7 +95,7 @@ describe('web e2e: Markdown inline-code links', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 2 - 2
apps/web/tests/markdown-wide-table.e2e.ts

@@ -253,7 +253,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     const groupRow = page.locator('[role="treeitem"]').first()
     await groupRow.waitFor({ timeout: 15_000 })
@@ -423,7 +423,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
     const hidpiTripwire = watchConsole(hidpiPage)
     try {
       onTestFailed(() => saveFailureShot(hidpiPage, 'web-e2e-markdown-wide-table-hidpi'))
-      await hidpiPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
+      await hidpiPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
       await hidpiPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       const groupRow = hidpiPage.locator('[role="treeitem"]').first()
       await groupRow.waitFor({ timeout: 15_000 })

+ 1 - 1
apps/web/tests/math-rendering.e2e.ts

@@ -97,7 +97,7 @@ describe('web e2e: settled Markdown math rendering', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/message-actions.e2e.ts

@@ -87,7 +87,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/message-feedback-layout.e2e.ts

@@ -207,7 +207,7 @@ describe('web e2e: the feedback note editor floats above the column', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser, 900)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 180_000)
 

+ 1 - 1
apps/web/tests/message-feedback-protocol.snapshot.ts

@@ -65,7 +65,7 @@ describe('message feedback Host Remote protocol', () => {
     const exchanges: ProtocolExchange[] = []
     const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise<unknown> => {
       const payload = { args: { request } }
-      const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, {
+      const response = await scaffold.hostFetch(`/api/${endpoint}`, {
         method: 'POST',
         headers: { 'content-type': 'application/json' },
         body: JSON.stringify({

+ 1 - 1
apps/web/tests/message-feedback.e2e.ts

@@ -32,7 +32,7 @@ describe('web e2e: durable per-message feedback', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/models-settings.e2e.ts

@@ -47,7 +47,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     // The scenario asserts the shipped Chinese copy, so the browser asks for it.
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 2 - 2
apps/web/tests/navigation-panes.e2e.ts

@@ -114,7 +114,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     // Workspace stream settles through the user-visible Ungrouped barrier.
     const sessionBaseline = baselineResponse(page)
     const [, sessionResponse] = await Promise.all([
-      page.goto(scaffold.baseUrl, { waitUntil: 'load' }),
+      page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }),
       sessionBaseline,
     ])
     await assertBaselineSucceeded(sessionResponse, 'session.list')
@@ -327,7 +327,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     })
     const observerSessionBaseline = baselineResponse(observer)
     const [, observerSessionResponse] = await Promise.all([
-      observer.goto(scaffold.baseUrl, { waitUntil: 'load' }),
+      observer.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }),
       observerSessionBaseline,
     ])
     await assertBaselineSucceeded(observerSessionResponse, 'observer session.list')

+ 1 - 1
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -38,7 +38,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
     tripwire = watchConsole(page)
     page.on('console', message => browserConsole.push(message.text()))
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/onboarding-usable-provider.e2e.ts

@@ -33,7 +33,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
     // The scenario asserts the shipped Chinese copy, so the browser asks for it.
     page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/permission-policy-context.e2e.ts

@@ -77,7 +77,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/plan-control-row.e2e.ts

@@ -64,7 +64,7 @@ describe('web e2e: plan chip click area at the narrow viewport', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser, VIEWPORT.height)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
     await page.setViewportSize(VIEWPORT)

+ 1 - 1
apps/web/tests/plan-review.e2e.ts

@@ -53,7 +53,7 @@ describe('web e2e: plan review takeover round trip', () => {
     // golden pins one language.
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/plugin-config.e2e.ts

@@ -33,7 +33,7 @@ describe('web e2e: plugin configuration section', () => {
     // derives from it, as the rest of the settings surface does.
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/produced-file-mentions.e2e.ts

@@ -127,7 +127,7 @@ describe('web e2e: inline-code mentions of produced files', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/produced-files.e2e.ts

@@ -118,7 +118,7 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
     // the assertion itself narrows the conversation after navigation.
     await page.setViewportSize({ width: 1280, height: 900 })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/pwsh-terminal.e2e.ts

@@ -55,7 +55,7 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas
     await seedSession(scaffold, fixture, SEED_ID)
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
 

+ 1 - 1
apps/web/tests/question-composer.e2e.ts

@@ -75,7 +75,7 @@ describe('web e2e: resident question composer round trip', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
     await connectFreshWorkspace(page, scaffold.workspaceCwd)

+ 2 - 2
apps/web/tests/queue-actions.e2e.ts

@@ -81,7 +81,7 @@ describe('web e2e: queue row actions', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     const tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
     onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
@@ -191,7 +191,7 @@ describe('web e2e: queue row actions', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     const tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
     onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout'))

+ 1 - 1
apps/web/tests/rail-search-expand.e2e.ts

@@ -30,7 +30,7 @@ describe('web e2e: rail search click survives its own document-level bubble', ()
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/reference-composer.e2e.ts

@@ -147,7 +147,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
     await writeFile(join(scaffold.workspaceCwd, 'workspace', 'reference.txt'), 'reference fixture\n')

+ 1 - 1
apps/web/tests/remote-welcome.e2e.ts

@@ -29,7 +29,7 @@ describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => {
       locale: ZH_BROWSER_LOCALE,
     })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('#root', { timeout: 30_000 })
   }, 120_000)
 

+ 1 - 1
apps/web/tests/replay-round-trip.e2e.ts

@@ -50,7 +50,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
     await connectFreshWorkspace(page, scaffold.workspaceCwd)

+ 25 - 1
apps/web/tests/scaffold.ts

@@ -205,6 +205,8 @@ export interface WebScaffold {
   mode: WebSnapshotMode
   /** Browser-facing origin for the bound test server. */
   baseUrl: string
+  /** Process-token URL that establishes this scaffold's browser session. */
+  authenticatedUrl: string
   /** Settled root context (the in-process readiness barrier; headless event subscription is its sanctioned use). */
   ctx: Context
   /** Temp project directory sessions run in (shell/fs tool cwd). */
@@ -213,6 +215,8 @@ export interface WebScaffold {
   persistenceRoot: string
   /** Isolated harness home the settings/credentials rows write ($DSH_HOME double). */
   harnessHome: string
+  /** Send a browser-equivalent Host request with this scaffold's authenticated cookie. */
+  hostFetch(path: string, init?: RequestInit): Promise<Response>
   /** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */
   whenTurnSettled(timeoutMs?: number): Promise<SessionId>
   /**
@@ -559,6 +563,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     observedSessions.set(session.id, session)
   })
   let port = 0
+  let baseUrl = ''
+  let authenticatedUrl = ''
+  let cookieHeader = ''
   let replayHandle: ReplayHandle | undefined
   try {
     process.chdir(workspaceCwd)
@@ -663,6 +670,17 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
         new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
       ), 'web e2e scaffold: route-only adapter')
     }
+    baseUrl = `http://${browserHost}:${String(port)}`
+    authenticatedUrl = ctx.connection.authenticatedUrl(baseUrl)
+    const login = await fetch(authenticatedUrl, { redirect: 'manual' })
+    const setCookie = login.headers.get('set-cookie')
+    if (login.status !== 303 || login.headers.get('location') !== '/' || setCookie === null) {
+      throw new Error('web e2e scaffold: browser token exchange did not return its session cookie')
+    }
+    cookieHeader = setCookie.split(';', 1)[0] ?? ''
+    if (cookieHeader.length === 0) {
+      throw new Error('web e2e scaffold: browser token exchange returned an empty session cookie')
+    }
   } catch (error) {
     if (process.cwd() !== originalCwd) process.chdir(originalCwd)
     const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
@@ -679,10 +697,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   return {
     harnessHome,
     mode,
-    baseUrl: `http://${browserHost}:${port}`,
+    baseUrl,
+    authenticatedUrl,
     ctx,
     workspaceCwd,
     persistenceRoot,
+    hostFetch(path: string, init: RequestInit = {}): Promise<Response> {
+      const headers = new Headers(init.headers)
+      headers.set('cookie', cookieHeader)
+      return fetch(new URL(path, baseUrl), { ...init, headers })
+    },
     // Barrier stack: the in-process turn/end identifies the session, its
     // explicit flush makes the transcript durable, and the caller's browser
     // settled-poll comes last because host completion strictly precedes render.

+ 1 - 1
apps/web/tests/schedule-after.e2e.ts

@@ -235,7 +235,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
     })
     await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
     expect(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone))

+ 5 - 1
apps/web/tests/seeded-history.e2e.ts

@@ -208,7 +208,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 
@@ -288,6 +288,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // only — the prompt and full tool output must stay on screen.
     expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
 
+    await expect.poll(
+      () => scaffold.ctx.agents.get(SessionId(SEED_ID)) !== undefined,
+      { timeout: 10_000 },
+    ).toBe(true)
     const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
     if (agent === undefined) throw new Error('seeded session did not attach an agent')
     agent.session.append('user/message', createUserMessage({

+ 6 - 6
apps/web/tests/settings-chrome.e2e.ts

@@ -42,7 +42,7 @@ describe('web e2e: settings modal and General preferences', () => {
     // the client derives from it (the English default has its own spec below).
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 
@@ -314,7 +314,7 @@ describe('web e2e: settings modal and General preferences', () => {
     try {
       expect(second.baseUrl).not.toBe(scaffold.baseUrl)
       await secondPage.emulateMedia({ colorScheme: 'light' })
-      await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
+      await secondPage.goto(second.authenticatedUrl, { waitUntil: 'load' })
       await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true)
       const secondState = await readState(secondPage)
@@ -372,7 +372,7 @@ describe('web e2e: settings modal and General preferences', () => {
     const secondTripwire = watchConsole(secondPage)
     try {
       expect(second.baseUrl).not.toBe(scaffold.baseUrl)
-      await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
+      await secondPage.goto(second.authenticatedUrl, { waitUntil: 'load' })
       await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await secondPage.getByRole('button', { name: '设置', exact: true }).click()
       await secondPage.getByRole('dialog', { name: '设置' })
@@ -438,7 +438,7 @@ describe('web e2e: settings modal and General preferences', () => {
     const secondTripwire = watchConsole(secondPage)
     try {
       expect(second.baseUrl).not.toBe(scaffold.baseUrl)
-      await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
+      await secondPage.goto(second.authenticatedUrl, { waitUntil: 'load' })
       await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await secondPage.getByRole('button', { name: 'Settings', exact: true }).click()
       await secondPage.getByRole('dialog', { name: 'Settings' })
@@ -472,7 +472,7 @@ describe('web e2e: settings modal and General preferences', () => {
     const enTripwire = watchConsole(enPage)
     onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
     try {
-      await enPage.goto(fresh.baseUrl, { waitUntil: 'load' })
+      await enPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
       await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
       await enPage.getByRole('button', { name: 'Settings', exact: true }).click()
@@ -498,7 +498,7 @@ describe('web e2e: settings modal and General preferences', () => {
     const frTripwire = watchConsole(frPage)
     onTestFailed(() => saveFailureShot(frPage, 'web-e2e-settings-unshipped-language'))
     try {
-      await frPage.goto(fresh.baseUrl, { waitUntil: 'load' })
+      await frPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
       await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       expect(await frPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
       await frPage.getByRole('button', { name: 'Settings', exact: true }).click()

+ 1 - 1
apps/web/tests/sidebar-scrollbar.e2e.ts

@@ -292,7 +292,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
     // the list with room to spare.
     page = await newEnglishPage(browser, 800)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await expandSeededSessions(page)
     // Every assertion about a thumb colour needs a drawn thumb, and the column

+ 1 - 1
apps/web/tests/sidebar-subagent-activity.e2e.ts

@@ -111,7 +111,7 @@ describe('web e2e: sidebar subagent activity', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
     const workspace = await scaffold.ctx.workspaceRegistry.resolveByPath(cwd)

+ 1 - 1
apps/web/tests/skill-invocation-policy.e2e.ts

@@ -83,7 +83,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/skill-tool-row.e2e.ts

@@ -33,7 +33,7 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
 
     const groupRow = page.locator('[role="treeitem"]').first()

+ 1 - 1
apps/web/tests/skill-user-invoke.e2e.ts

@@ -79,7 +79,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/startup-auto-selection.e2e.ts

@@ -29,7 +29,7 @@ describe('web e2e: startup auto-selection', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 180_000)
 

+ 1 - 1
apps/web/tests/startup-rpc-budget.e2e.ts

@@ -33,7 +33,7 @@ describe('startup RPC budget', () => {
       const url = new URL(request.url())
       if (url.pathname.startsWith('/api/')) calls.push(url.pathname.slice('/api/'.length))
     })
-    await page.goto(scaffold.baseUrl)
+    await page.goto(scaffold.authenticatedUrl)
     // Boot settles when the workspace picker is interactive; the trailing wait
     // absorbs the first-connection reset wave the budget must include.
     await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 })

+ 1 - 1
apps/web/tests/stats-paged-history.e2e.ts

@@ -83,7 +83,7 @@ describe('web e2e: whole-session stats survive history paging', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 4 - 4
apps/web/tests/steering.e2e.ts

@@ -78,7 +78,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // Fresh world: connect a Workspace so the composer scenarios start live.
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
@@ -191,7 +191,7 @@ describe('web e2e: composer shortcut steers directly', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
@@ -248,7 +248,7 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)
@@ -314,7 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
     await page.getByText('Standard mode', { exact: true }).waitFor({ timeout: 10_000 })

+ 1 - 1
apps/web/tests/subagent-conversation.e2e.ts

@@ -88,7 +88,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       if (path.startsWith('/api/')) apiCalls.push(path)
     })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
 

+ 1 - 1
apps/web/tests/subagent-interrupt-ui.e2e.ts

@@ -134,7 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       if (path.startsWith('/api/')) apiCalls.push(path)
     })
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
 

+ 8 - 8
apps/web/tests/subagent-interrupt.e2e.ts

@@ -23,8 +23,8 @@ const WAKING = 'And add one concrete example.'
 type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
 
 /** POST one API Proxy unary RPC through the real HTTP carrier and unwrap its result. */
-async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<RpcResult<T>> {
-  const response = await fetch(`${baseUrl}/api/${method}`, {
+async function rpc<T>(scaffold: WebScaffold, method: string, payload: unknown): Promise<RpcResult<T>> {
+  const response = await scaffold.hostFetch(`/api/${method}`, {
     method: 'POST',
     headers: { 'content-type': 'application/json' },
     body: JSON.stringify({
@@ -39,9 +39,9 @@ async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promis
 }
 
 /** POST one generated Session Remote unary through the API Gateway carrier. */
-async function sessionRemote<T>(baseUrl: string, method: string, request: unknown): Promise<RpcResult<T>> {
+async function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
   const endpoint = `session/${method}`
-  const response = await fetch(`${baseUrl}/api/${endpoint}`, {
+  const response = await scaffold.hostFetch(`/api/${endpoint}`, {
     method: 'POST',
     headers: { 'content-type': 'application/json' },
     body: JSON.stringify({
@@ -108,7 +108,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
     })
 
     // A live parent Agent through the real API; no workspace or browser.
-    const created = await sessionRemote<{ sessionId: string }>(scaffold.baseUrl, 'create', {
+    const created = await sessionRemote<{ sessionId: string }>(scaffold, 'create', {
       cwd: scaffold.workspaceCwd,
     })
     if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`)
@@ -138,7 +138,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
 
   it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => {
     // Queue the follow-up while the turn is still open, then interrupt.
-    const queued = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
+    const queued = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', {
       parentSessionId: parentId,
       childSessionId: childId,
       mode: 'continuable',
@@ -147,7 +147,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
     expect(queued).toMatchObject({ ok: true })
 
     const settled = scaffold.whenTurnSettled()
-    const interrupted = await rpc<{ accepted: true }>(scaffold.baseUrl, 'subagent.interrupt', {
+    const interrupted = await rpc<{ accepted: true }>(scaffold, 'subagent.interrupt', {
       parentSessionId: parentId,
       childSessionId: childId,
       mode: 'continuable',
@@ -169,7 +169,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
 
     // Only an explicit waking send resumes the parked queue, FIFO, then the
     // child runs both turns to completion and settles.
-    const waking = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
+    const waking = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', {
       parentSessionId: parentId,
       childSessionId: childId,
       mode: 'continuable',

+ 1 - 1
apps/web/tests/trajectory-virtualization.e2e.ts

@@ -196,7 +196,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser, 900)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // The compact layout dropped group session counts; the seeded baseline is
     // the Ungrouped bucket once cold summaries load.

+ 1 - 1
apps/web/tests/turn-tail-actions.e2e.ts

@@ -80,7 +80,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }

+ 1 - 1
apps/web/tests/web-search-round.e2e.ts

@@ -160,7 +160,7 @@ describe('web e2e: shipped default web search', () => {
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/workflow-run.e2e.ts

@@ -59,7 +59,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
   }, 120_000)

+ 1 - 1
apps/web/tests/workspace-management.e2e.ts

@@ -124,7 +124,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
-    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
 

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 3fce90069441b683f449b4b16def95d8d9acc323
-module-graph.zh.md: f7431bd5a417ff6c5de4efe22a1d711611b1ffee
+module-graph.md: cc8eaf8b49dc95568d34a4d57e9cbff8d30656c1
+module-graph.zh.md: 73ba9081455a265194aae943fb96efc0ec95d38f

+ 6 - 4
docs/module-graph.md

@@ -399,8 +399,6 @@ flowchart TD
   pkg_subprocess_e2b --> pkg_invariants
   pkg_subprocess_e2b --> pkg_subprocess
   pkg_subprocess_e2b --> pkg_timeout
-  pkg_host_frontend_static --> pkg_host_webserver
-  pkg_host_frontend_static --> pkg_invariants
   pkg_host_plugin_inventory --> pkg_brand
   pkg_host_plugin_inventory --> pkg_invariants
   pkg_host_plugin_inventory --> pkg_typert_protocol
@@ -1076,6 +1074,7 @@ flowchart TD
   pkg_tool_session_query --> pkg_tools
   pkg_client_connection --> pkg_attachment
   pkg_client_connection --> pkg_commands
+  pkg_client_connection --> pkg_credentials
   pkg_client_connection --> pkg_host_apiproxy
   pkg_client_connection --> pkg_host_webserver
   pkg_client_connection --> pkg_invariants
@@ -1195,6 +1194,9 @@ flowchart TD
   pkg_experimental_agent_team --> pkg_session
   pkg_experimental_agent_team --> pkg_session_persistence
   pkg_experimental_agent_team --> pkg_subagent
+  pkg_host_frontend_static --> pkg_client_connection
+  pkg_host_frontend_static --> pkg_host_webserver
+  pkg_host_frontend_static --> pkg_invariants
   pkg_sdk_protocol --> pkg_invariants
   pkg_sdk_protocol --> pkg_llm
   pkg_sdk_protocol --> pkg_session
@@ -1696,7 +1698,6 @@ flowchart TD
 | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
-| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) |
 | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) |
@@ -1829,7 +1830,7 @@ flowchart TD
 | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
+| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
 | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) |
 | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
@@ -1845,6 +1846,7 @@ flowchart TD
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
+| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 6 - 4
docs/module-graph.zh.md

@@ -401,8 +401,6 @@ flowchart TD
   pkg_subprocess_e2b --> pkg_invariants
   pkg_subprocess_e2b --> pkg_subprocess
   pkg_subprocess_e2b --> pkg_timeout
-  pkg_host_frontend_static --> pkg_host_webserver
-  pkg_host_frontend_static --> pkg_invariants
   pkg_host_plugin_inventory --> pkg_brand
   pkg_host_plugin_inventory --> pkg_invariants
   pkg_host_plugin_inventory --> pkg_typert_protocol
@@ -1078,6 +1076,7 @@ flowchart TD
   pkg_tool_session_query --> pkg_tools
   pkg_client_connection --> pkg_attachment
   pkg_client_connection --> pkg_commands
+  pkg_client_connection --> pkg_credentials
   pkg_client_connection --> pkg_host_apiproxy
   pkg_client_connection --> pkg_host_webserver
   pkg_client_connection --> pkg_invariants
@@ -1197,6 +1196,9 @@ flowchart TD
   pkg_experimental_agent_team --> pkg_session
   pkg_experimental_agent_team --> pkg_session_persistence
   pkg_experimental_agent_team --> pkg_subagent
+  pkg_host_frontend_static --> pkg_client_connection
+  pkg_host_frontend_static --> pkg_host_webserver
+  pkg_host_frontend_static --> pkg_invariants
   pkg_sdk_protocol --> pkg_invariants
   pkg_sdk_protocol --> pkg_llm
   pkg_sdk_protocol --> pkg_session
@@ -1698,7 +1700,6 @@ flowchart TD
 | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
-| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) |
 | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) |
@@ -1831,7 +1832,7 @@ flowchart TD
 | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
+| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
 | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) |
 | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
@@ -1847,6 +1848,7 @@ flowchart TD
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
+| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 32 - 1
packages/api/remotes/tests/built-lib.e2e.ts

@@ -58,6 +58,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
       const { Session, SessionId } = await import(urls.session)
 
       const routes = []
+      const credentialRecords = new Map()
       const host = new Context()
       host.provide('webServer', {
         register(route) {
@@ -67,6 +68,15 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
         tapIndex() { return () => {} },
         port: 0,
       })
+      host.provide('credentials', {
+        readRecord(key) { return Promise.resolve(credentialRecords.get(key)) },
+        async modifyRecord(key, mutate) {
+          const current = credentialRecords.get(key)
+          const next = await mutate(current)
+          if (next !== undefined) credentialRecords.set(key, next)
+          return next ?? current
+        },
+      })
       await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply })
       await host.plugin(TypertRegistry)
       await host.plugin(AgentRegistry)
@@ -101,11 +111,32 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
       if (routes.length !== 1 || routes[0].path !== '/api') {
         throw new Error('Connection did not register exactly one /api route')
       }
-      const server = createServer((request, response) => { void routes[0].handler(request, response) })
+      const server = createServer((request, response) => {
+        if ((request.url ?? '/').startsWith('/?')) {
+          void host.connection.authorizeIndex(request, response).then(authorized => {
+            if (authorized) {
+              response.writeHead(200, { 'content-type': 'text/html' })
+              response.end('<body>shell</body>')
+            }
+          })
+          return
+        }
+        void routes[0].handler(request, response)
+      })
       await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
       const address = server.address()
       if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address')
       const origin = 'http://127.0.0.1:' + String(address.port)
+      const login = await fetch(host.connection.authenticatedUrl(origin), { redirect: 'manual' })
+      const setCookie = login.headers.get('set-cookie')
+      if (login.status !== 303 || setCookie === null) throw new Error('browser token exchange failed')
+      const cookie = setCookie.split(';', 1)[0]
+      const hostFetch = globalThis.fetch
+      globalThis.fetch = (input, init = {}) => {
+        const headers = new Headers(init.headers)
+        headers.set('cookie', cookie)
+        return hostFetch(input, { ...init, headers })
+      }
 
       const handoffs = new Map()
       globalThis.window = {

+ 26 - 1
packages/bundle/web-app/tests/browser-open.spec.ts

@@ -29,6 +29,7 @@ afterEach(async () => {
   vi.unstubAllEnvs()
   Reflect.deleteProperty(globalThis, '__dshWebAppApply')
   Reflect.deleteProperty(globalThis, '__dshWebServer')
+  Reflect.deleteProperty(globalThis, '__dshConnection')
 })
 
 describe('web app browser startup', () => {
@@ -42,8 +43,14 @@ describe('web app browser startup', () => {
     internals.resolveDistIndex = () => index
 
     const webserverModule = join(root, 'webserver.mjs')
+    const connectionModule = join(root, 'connection.mjs')
     const webAppModule = join(root, 'web-app.mjs')
     writeFileSync(webserverModule, 'export default globalThis.__dshWebServer\n')
+    writeFileSync(connectionModule, [
+      "export const inject = ['webServer']",
+      "export const apply = ctx => ctx.provide('connection', globalThis.__dshConnection)",
+      '',
+    ].join('\n'))
     writeFileSync(webAppModule, [
       "export const name = 'fixture-web-app'",
       "export const inject = ['webServer']",
@@ -57,6 +64,8 @@ describe('web app browser startup', () => {
       '  config:',
       '    host: 127.0.0.1',
       '    port: 0',
+      '- id: connection',
+      `  name: ${pathToFileURL(connectionModule).href}`,
       '- id: web-app',
       `  name: ${pathToFileURL(webAppModule).href}`,
       '  config:',
@@ -70,9 +79,25 @@ describe('web app browser startup', () => {
     const globals = globalThis as unknown as {
       __dshWebAppApply: typeof apply
       __dshWebServer: typeof WebServer
+      __dshConnection: {
+        authenticatedUrl(baseUrl: string): string
+        authorizeIndex(): Promise<boolean>
+        requestRejection(): Promise<undefined>
+        rpc: object
+      }
     }
     globals.__dshWebAppApply = apply
     globals.__dshWebServer = WebServer
+    globals.__dshConnection = {
+      authenticatedUrl: (baseUrl) => {
+        const url = new URL(baseUrl)
+        url.searchParams.set('token', 'fixture-token')
+        return url.href
+      },
+      authorizeIndex: () => Promise.resolve(true),
+      requestRejection: () => Promise.resolve(undefined),
+      rpc: {},
+    }
 
     let openedUrl: string | undefined
     let openedStatus: number | undefined
@@ -95,7 +120,7 @@ describe('web app browser startup', () => {
     await ctx.loader.await()
     await opened
 
-    expect(openedUrl).toBe(`http://127.0.0.1:${String(ctx.webServer.port)}`)
+    expect(openedUrl).toBe(`http://127.0.0.1:${String(ctx.webServer.port)}/?token=fixture-token`)
     expect(openedStatus).toBe(200)
   })
 })

+ 1 - 0
packages/bundle/web-app/tests/web-app.spec.ts

@@ -286,6 +286,7 @@ describe('web-app runtime glue', () => {
     const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
     provideLoader(torn, () => tornSettlement)
     apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
+    await new Promise(resolve => setTimeout(resolve, 0))
     await child.dispose() // the webServer service goes away
     releaseTorn!()
     await new Promise(resolve => setTimeout(resolve, 0))

+ 27 - 10
packages/client/connection/src/browser-auth.ts

@@ -16,6 +16,7 @@ const TOKEN_QUERY = 'token'
 const COOKIE_PREFIX = 'dsh-auth-'
 const COOKIE_PAYLOAD_VERSION = 1
 const STORED_SECRET_VERSION = 1
+const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/
 
 interface StoredSecretPayload {
   readonly version: typeof STORED_SECRET_VERSION
@@ -33,6 +34,20 @@ function isRecord(value: unknown): value is Record<string, unknown> {
   return typeof value === 'object' && value !== null && !Array.isArray(value)
 }
 
+function encodeBase64Url(value: Uint8Array): string {
+  return Buffer.from(value).toString('base64')
+    .replaceAll('+', '-')
+    .replaceAll('/', '_')
+    .replace(/=+$/u, '')
+}
+
+function decodeBase64Url(value: string): Buffer | undefined {
+  if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined
+  const padding = '='.repeat((4 - value.length % 4) % 4)
+  const decoded = Buffer.from(value.replaceAll('-', '+').replaceAll('_', '/') + padding, 'base64')
+  return encodeBase64Url(decoded) === value ? decoded : undefined
+}
+
 function header(
   headers: ConnectionTrustRequest['headers'],
   name: string,
@@ -55,8 +70,8 @@ function requestAuthority(headers: ConnectionTrustRequest['headers']): string |
 
 function canonicalSecret(value: unknown): Buffer | undefined {
   if (typeof value !== 'string') return undefined
-  const decoded = Buffer.from(value, 'base64url')
-  if (decoded.byteLength !== SECRET_BYTES || decoded.toString('base64url') !== value) return undefined
+  const decoded = decodeBase64Url(value)
+  if (decoded === undefined || decoded.byteLength !== SECRET_BYTES) return undefined
   return decoded
 }
 
@@ -80,7 +95,7 @@ function tokenMatches(actual: string, expected: string): boolean {
 }
 
 function cookieName(authority: string): string {
-  return COOKIE_PREFIX + createHash('sha256').update(authority).digest('base64url')
+  return COOKIE_PREFIX + encodeBase64Url(createHash('sha256').update(authority).digest())
 }
 
 /** Read the exact generated cookie without implementing general Cookie decoding. */
@@ -103,8 +118,8 @@ function signature(secret: Buffer, body: string): Buffer {
 }
 
 function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string {
-  const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
-  return `v1.${body}.${signature(secret, body).toString('base64url')}`
+  const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), 'utf8'))
+  return `v1.${body}.${encodeBase64Url(signature(secret, body))}`
 }
 
 function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined {
@@ -113,14 +128,16 @@ function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | und
   if (parts.length !== 3 || version !== 'v1' || body === undefined || encodedSignature === undefined) {
     return undefined
   }
-  const actualSignature = Buffer.from(encodedSignature, 'base64url')
-  if (actualSignature.toString('base64url') !== encodedSignature) return undefined
+  const actualSignature = decodeBase64Url(encodedSignature)
+  if (actualSignature === undefined) return undefined
   const expectedSignature = signature(secret, body)
   if (actualSignature.byteLength !== expectedSignature.byteLength
     || !timingSafeEqual(actualSignature, expectedSignature)) return undefined
   let decoded: unknown
   try {
-    decoded = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
+    const bodyBytes = decodeBase64Url(body)
+    if (bodyBytes === undefined) return undefined
+    decoded = JSON.parse(bodyBytes.toString('utf8'))
   } catch {
     return undefined
   }
@@ -139,7 +156,7 @@ function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | und
  * process restart.
  */
 export class BrowserAuth {
-  private readonly launchToken = randomBytes(SECRET_BYTES).toString('base64url')
+  private readonly launchToken = encodeBase64Url(randomBytes(SECRET_BYTES))
   private readonly maxAgeMilliseconds: number
 
   private constructor(
@@ -248,7 +265,7 @@ export class BrowserAuth {
   private async ensureSecret(): Promise<Buffer> {
     const generated: StoredSecretPayload = {
       version: STORED_SECRET_VERSION,
-      secret: randomBytes(SECRET_BYTES).toString('base64url'),
+      secret: encodeBase64Url(randomBytes(SECRET_BYTES)),
     }
     const record = await this.credentials.modifyRecord(AUTH_RECORD_KEY, (current) => {
       if (current !== undefined) {

+ 7 - 0
packages/client/connection/tests/api-request-trust.host.spec.ts

@@ -68,6 +68,13 @@ describe('isTrustedApiRequest', () => {
     expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
   })
 
+  it('reads Fetch Headers while preserving absent browser markers', () => {
+    expect(isTrustedApiRequest({ headers: new Headers({ host: '127.0.0.1:3080' }) }, [])).toBe(true)
+    expect(isTrustedApiRequest({
+      headers: new Headers({ host: '127.0.0.1:3080', origin: 'http://evil.example' }),
+    }, [])).toBe(false)
+  })
+
   it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
     for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
       expect(() => { assertTrustedAuthority(entry) }).not.toThrow()

+ 10 - 3
packages/client/connection/tests/browser-auth.host.spec.ts

@@ -31,15 +31,19 @@ class RecordCredentials {
 }
 
 function signedCookie(store: RecordCredentials, name: string, payload: unknown): string {
+  const body = typeof payload === 'string'
+    ? Buffer.from(payload, 'utf8').toString('base64url')
+    : Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
+  return signedBodyCookie(store, name, body)
+}
+
+function signedBodyCookie(store: RecordCredentials, name: string, body: string): string {
   const record = store.record
   if (record?.kind !== 'grant' || typeof record.payload !== 'object' || record.payload === null) {
     throw new Error('test credential store has no signing secret')
   }
   const secret: unknown = Reflect.get(record.payload, 'secret')
   if (typeof secret !== 'string') throw new Error('test credential record has no string secret')
-  const body = typeof payload === 'string'
-    ? Buffer.from(payload, 'utf8').toString('base64url')
-    : Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
   const signature = createHmac('sha256', Buffer.from(secret, 'base64url')).update(body).digest('base64url')
   return `${name}=v1.${body}.${signature}`
 }
@@ -169,6 +173,9 @@ describe('BrowserAuth', () => {
     expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false)
     expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false)
     expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false)
+    expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', {
+      cookie: signedBodyCookie(store, name, 'a'),
+    }))).toBe(false)
     expect(await auth.isAuthenticated({ headers: {} })).toBe(false)
     expect(await auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false)
     expect(await auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false)

+ 11 - 14
packages/experimental/webworker-runtime/src/transport/tunnel.ts

@@ -4,11 +4,9 @@
  *
  * - `GET /__boot__` answers from tunnel glue, never from the host API surface,
  *   because the page needs the boot payload before its Cordis tree exists.
- * - Privileged `/api` methods take that same direct entry: the browser strips the
- *   `host` header from the WHATWG `Request` the route lane rebuilds, so the
- *   privileged fence would answer 403 for every one of them. The method set is
- *   not restated here — an unexpected 403 from the route lane is retried on the
- *   direct lane, which keeps the split honest without a copied list.
+ * - Privileged `/api` methods take that same direct entry. The method set is not
+ *   restated here: a 401 or 403 from the route lane is retried on the direct
+ *   lane because the page owns the worker and needs no network authentication.
  * - Everything else is fed into the real webserver route table through the
  *   request listener the app's fake `node:http` captured, keeping the trust
  *   fences, byte limits, and status semantics intact.
@@ -88,7 +86,7 @@ export interface TunnelPort {
 export interface TunnelSeams {
   /**
    * Direct entry to the API fetch handler for privileged methods and any unary
-   * call the route lane refused with 403.
+   * call the route lane refused with 401 or 403.
    */
   readonly directFetch: (request: Request) => Promise<Response>
   /** Boot payload for `GET /__boot__`: the structured index injection table. */
@@ -118,12 +116,12 @@ export interface TunnelServerOptions {
   readonly requestListener: () => Promise<RequestListener>
   /**
    * Methods that skip the route lane outright. Supply the host's own privileged
-   * set when it is reachable; omitting it leaves the 403 retry as the mechanism.
+   * set when it is reachable; omitting it leaves the 401/403 retry as the mechanism.
    */
   readonly privilegedMethods?: ReadonlySet<string>
   /**
    * Escape hatch for the unary `/api` lane. `route` (default) keeps every fence
-   * and byte limit with a 403 retry on the direct lane; `direct` sends every
+   * and byte limit with a 401/403 retry on the direct lane; `direct` sends every
    * unary `/api` call straight to the fetch handler.
    */
   readonly unaryApiLane?: 'route' | 'direct'
@@ -135,7 +133,7 @@ interface InFlight {
 
 type QueuedFrame = TunnelRequestFrame | TunnelStreamOpenFrame
 
-/** Recorded response frames, so a 403 from the route lane can be discarded. */
+/** Recorded response frames, so a route-lane authentication refusal can be discarded. */
 class BufferedSink {
   private readonly calls: Array<() => void> = []
   private target: ResponseSink | undefined
@@ -228,7 +226,7 @@ export class TunnelServer {
    */
   serve(seams: TunnelSeams): void {
     this.seams = seams
-    console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === 'route' ? ' with 403 retry' : ''}, privileged set=${this.privilegedMethods === undefined ? 'none' : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`)
+    console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === 'route' ? ' with 401/403 retry' : ''}, privileged set=${this.privilegedMethods === undefined ? 'none' : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`)
     for (const frame of this.queue.splice(0)) this.dispatchFrame(frame)
   }
 
@@ -380,7 +378,7 @@ export class TunnelServer {
 
   /**
    * Unary `/api`: keep the route lane's fences, but fall back to the direct lane
-   * when the privileged fence refuses a request the page is entitled to make.
+   * when network authentication or trust rejects the worker-owning page.
    */
   private async serveApi(
     original: TunnelRequestFrame,
@@ -405,9 +403,8 @@ export class TunnelServer {
     if (outcome === 'aborted' || exchange.aborted) return
     // The decision happens at the first frame, before anything reaches the page:
     // the route lane streams its answers, so a refusal can carry a body too.
-    if (outcome.status === 403) {
-      // The privileged fence read a Request the browser stripped `host` from.
-      console.debug(`webworker tunnel: route lane refused ${method} with 403; answering on the direct lane`)
+    if (outcome.status === 401 || outcome.status === 403) {
+      console.debug(`webworker tunnel: route lane refused ${method} with ${String(outcome.status)}; answering on the direct lane`)
       await this.serveDirect(original, sink)
       return
     }

+ 3 - 3
packages/experimental/webworker-runtime/src/worker-host.ts

@@ -356,9 +356,9 @@ function requireLoweredImage(vfs: MemoryVfs, path: string): void {
  * endpoints (`/api/<service>/<method>`) are served by an interceptor the gateway
  * registers on the Connection service, and answer 404 from the core routes. The
  * Connection service composes both halves in `createSharedFetchHandler`, whose
- * fallback — not the composition — carries the privileged fence, so composing it
- * here keeps every interceptor while leaving out the fence the direct lane exists
- * to bypass.
+ * fallback — not the composition — carries network authentication and trust, so
+ * composing it here keeps every interceptor while leaving out the fences the
+ * worker-local direct lane exists to bypass.
  * @param ctx - Booted host context.
  * @param core - Fetch handler over the API surface.
  * @returns Handler covering interceptors and the core surface.

+ 36 - 0
packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts

@@ -24,6 +24,42 @@ function seams(openStream: TunnelSeams['openStream']): TunnelSeams {
   }
 }
 
+describe('worker tunnel unary authentication', () => {
+  it.each([401, 403])('retries a route-lane HTTP %s through the worker-local direct lane', async (status) => {
+    const frames: TunnelOutboundFrame[] = []
+    const directFetch = vi.fn(async () => new Response('direct answer', {
+      status: 200,
+      headers: { 'content-type': 'text/plain' },
+    }))
+    const server = new TunnelServer({
+      port: { postMessage: (frame) => { frames.push(frame) } },
+      requestListener: () => Promise.resolve((_req, response) => {
+        const res = response as {
+          writeHead(status: number, headers: Record<string, string>): void
+          end(body: string): void
+        }
+        res.writeHead(status, { 'content-type': 'text/plain' })
+        res.end('network request rejected')
+      }),
+    })
+    server.serve({
+      ...seams(async () => (async function *(): AsyncGenerator { yield undefined })()),
+      directFetch,
+    })
+
+    server.handleMessage({
+      t: 'req', id: status, method: 'POST', url: 'http://localhost/api/session/list', headers: {},
+    })
+
+    await vi.waitFor(() => { expect(frames).toHaveLength(1) })
+    const [frame] = frames
+    expect(frame).toMatchObject({ t: 'res', id: status, status: 200 })
+    if (frame?.t !== 'res' || frame.body === undefined) throw new Error('direct retry did not return one body')
+    expect(new TextDecoder().decode(frame.body)).toBe('direct answer')
+    expect(directFetch).toHaveBeenCalledOnce()
+  })
+})
+
 describe('worker tunnel logical streams', () => {
   it('drains a pre-boot open through the worker-local Gateway seam', async () => {
     const { server, frames } = harness()