diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index e8c68740aa..0691ce60ab 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index fb55a35018..515990b81f 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index 6cd7711739..8066e2c3f6 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.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。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index 87578885ce..26aa2d3d3d 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/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) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index d338b43b55..72b8317fe4 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/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({ diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index ea7ee009d5..a1f198ca93 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/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 { - const response = await fetch(`${baseUrl}/api/session/list`, { +async function livePreset(scaffold: WebScaffold): Promise { + 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 }) diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index 0bbc3fa210..d8fe3935c8 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/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) diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index fcf02290fd..cad3d05aa0 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/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() diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts index dcd4f93cf9..63d8d729e0 100644 --- a/apps/web/tests/bash-abort-row.e2e.ts +++ b/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() diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index cd15f2e054..8130e70308 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/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) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 03198a23a8..c7d6c7de50 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/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) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 7c70741296..287e3a61f8 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -166,7 +166,7 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise { 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 diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index fcdbb721a6..58210a6437 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/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) diff --git a/apps/web/tests/cold-blank-session.e2e.ts b/apps/web/tests/cold-blank-session.e2e.ts index 87eaf848db..b9e64960d3 100644 --- a/apps/web/tests/cold-blank-session.e2e.ts +++ b/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) diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index eeb9931473..0913d82e90 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -907,7 +907,7 @@ async function openPerformancePage( world: PerformanceWorld, expectedSessions: number, ): Promise { - 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) diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index dd6ae8085e..04b13e206e 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/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) diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts index 99b746d97b..a4905b86e0 100644 --- a/apps/web/tests/composer-tab-geometry.e2e.ts +++ b/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 }) diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index c9805a4df8..460acf9163 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/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) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index e6e3fe6f5e..6dfebd0e98 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/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) diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts index 8d35d0d1ea..2310e392e0 100644 --- a/apps/web/tests/declared-reasoning.e2e.ts +++ b/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) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 34a9fab2f9..d095448a79 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/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. diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 9ff90d2dca..d53338e1a1 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/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) diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index f017d9289d..242b28188c 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/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. diff --git a/apps/web/tests/github-ready-review.e2e.ts b/apps/web/tests/github-ready-review.e2e.ts index af3af1c0f2..4d473ddf13 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/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) diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index a775143da6..631367886b 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/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) diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 537ab8095b..605c45e1bb 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/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) diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index f73c100827..346593529b 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/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) } diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 35bea3b085..e1b62632aa 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/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() diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index ced590a45d..553b1565cf 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/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) diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts index 83963ea36e..b99f7f90d4 100644 --- a/apps/web/tests/markdown-cjk-strong.e2e.ts +++ b/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) diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 57e5910813..54783b48a5 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/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) diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts index e216e5b6d0..be3e30a380 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/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) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 901a425f43..77e583bf45 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/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 }) diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index 08f4ca2705..c5e3e22a60 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/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) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 58e54965fb..de849baea3 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/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) diff --git a/apps/web/tests/message-feedback-layout.e2e.ts b/apps/web/tests/message-feedback-layout.e2e.ts index ed03d14f8d..5ab5cb0102 100644 --- a/apps/web/tests/message-feedback-layout.e2e.ts +++ b/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) diff --git a/apps/web/tests/message-feedback-protocol.snapshot.ts b/apps/web/tests/message-feedback-protocol.snapshot.ts index 02cebabcc0..e9dfa197b3 100644 --- a/apps/web/tests/message-feedback-protocol.snapshot.ts +++ b/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 => { 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({ diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index f6c3262c5f..02e4b62951 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/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) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index f727c619f9..e802b981bf 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/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) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 1da479e432..6ba5df86c9 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/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') diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 59b7e3c987..82079543d3 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/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) diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts index a1d97993b7..d16ed51822 100644 --- a/apps/web/tests/onboarding-usable-provider.e2e.ts +++ b/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) diff --git a/apps/web/tests/permission-policy-context.e2e.ts b/apps/web/tests/permission-policy-context.e2e.ts index 06817d7df5..5ff6141af8 100644 --- a/apps/web/tests/permission-policy-context.e2e.ts +++ b/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) diff --git a/apps/web/tests/plan-control-row.e2e.ts b/apps/web/tests/plan-control-row.e2e.ts index 49a3ce06dc..dd55656d61 100644 --- a/apps/web/tests/plan-control-row.e2e.ts +++ b/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) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 5c4082747f..8b4dc3afd4 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/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) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 1877d71e7c..0235f687de 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/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) diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts index badd9b845e..f89cfe53b3 100644 --- a/apps/web/tests/produced-file-mentions.e2e.ts +++ b/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) diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 2dcc92f65e..3c6ebbf526 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/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) diff --git a/apps/web/tests/pwsh-terminal.e2e.ts b/apps/web/tests/pwsh-terminal.e2e.ts index 4f56a422a9..85ce829904 100644 --- a/apps/web/tests/pwsh-terminal.e2e.ts +++ b/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) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index f0325a9e63..b63815975a 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/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) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 3dfeee4f4a..d026aa1110 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/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')) diff --git a/apps/web/tests/rail-search-expand.e2e.ts b/apps/web/tests/rail-search-expand.e2e.ts index 74f7cb12c9..bf520f3ebb 100644 --- a/apps/web/tests/rail-search-expand.e2e.ts +++ b/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) diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index c6d8bfb60f..1e052462f5 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/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') diff --git a/apps/web/tests/remote-welcome.e2e.ts b/apps/web/tests/remote-welcome.e2e.ts index 256afaeede..b7b013dc2d 100644 --- a/apps/web/tests/remote-welcome.e2e.ts +++ b/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) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index fa1ee79079..30644607f5 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/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) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a4e1adc07b..bde659deec 100644 --- a/apps/web/tests/scaffold.ts +++ b/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 /** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */ whenTurnSettled(timeoutMs?: number): Promise /** @@ -559,6 +563,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { + 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. diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 65192533c5..9b0b407464 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/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)) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index ad4af3b3da..07d5810185 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/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({ diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 35de5ac4f2..27521b8a57 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/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() diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index 3c2761e72d..899bf57e19 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/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 diff --git a/apps/web/tests/sidebar-subagent-activity.e2e.ts b/apps/web/tests/sidebar-subagent-activity.e2e.ts index ed087f0b8f..43fbd22f05 100644 --- a/apps/web/tests/sidebar-subagent-activity.e2e.ts +++ b/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) diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index a938ed65d8..de0a838417 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/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) diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index 6e41ba9035..25914d5fa0 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/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() diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index 767146d2b9..04c7d6880c 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/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) diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index d85343d7f9..7cfc3e68b2 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/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) diff --git a/apps/web/tests/startup-rpc-budget.e2e.ts b/apps/web/tests/startup-rpc-budget.e2e.ts index b9e06fcb3b..59faec6512 100644 --- a/apps/web/tests/startup-rpc-budget.e2e.ts +++ b/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 }) diff --git a/apps/web/tests/stats-paged-history.e2e.ts b/apps/web/tests/stats-paged-history.e2e.ts index 845b9a3593..1e6682489d 100644 --- a/apps/web/tests/stats-paged-history.e2e.ts +++ b/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) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 111b49f705..30a4afb832 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/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 }) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 0be7e1f05e..a786b2c6cf 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/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) diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index aa985f6601..e9ef24ca4f 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/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) diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index b3b9a53037..6edb81e404 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -23,8 +23,8 @@ const WAKING = 'And add one concrete example.' type RpcResult = { 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(baseUrl: string, method: string, payload: unknown): Promise> { - const response = await fetch(`${baseUrl}/api/${method}`, { +async function rpc(scaffold: WebScaffold, method: string, payload: unknown): Promise> { + const response = await scaffold.hostFetch(`/api/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -39,9 +39,9 @@ async function rpc(baseUrl: string, method: string, payload: unknown): Promis } /** POST one generated Session Remote unary through the API Gateway carrier. */ -async function sessionRemote(baseUrl: string, method: string, request: unknown): Promise> { +async function sessionRemote(scaffold: WebScaffold, method: string, request: unknown): Promise> { 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', diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index 6629bb0b5d..e0f2b5bb66 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/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. diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index ce848fa96b..b2ea2baf88 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/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) } diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index 3299b1b163..7237670f0b 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/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) diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index cf5999b626..f3d9e2c8b3 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/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) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 2dbd8cd1f4..a8345c45d3 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/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) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d23a564580..e32509f544 100644 --- a/docs/module-graph.i18n.yaml +++ b/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 diff --git a/docs/module-graph.md b/docs/module-graph.md index 3fce900694..cc8eaf8b49 100644 --- a/docs/module-graph.md +++ b/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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index f7431bd5a4..73ba908145 100644 --- a/docs/module-graph.zh.md +++ b/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) | diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 232cf9b2f6..92c5c7c621 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/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('shell') + } + }) + 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 = { diff --git a/packages/bundle/web-app/tests/browser-open.spec.ts b/packages/bundle/web-app/tests/browser-open.spec.ts index 0e2e22649d..5c1560c445 100644 --- a/packages/bundle/web-app/tests/browser-open.spec.ts +++ b/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 + requestRejection(): Promise + 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) }) }) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 90f7063bf7..4376147e47 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -286,6 +286,7 @@ describe('web-app runtime glue', () => { const tornSettlement = new Promise((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)) diff --git a/packages/client/connection/src/browser-auth.ts b/packages/client/connection/src/browser-auth.ts index 7c57622bb2..1f2bd8768a 100644 --- a/packages/client/connection/src/browser-auth.ts +++ b/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 { 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 { 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) { diff --git a/packages/client/connection/tests/api-request-trust.host.spec.ts b/packages/client/connection/tests/api-request-trust.host.spec.ts index f145230a8b..359a461e94 100644 --- a/packages/client/connection/tests/api-request-trust.host.spec.ts +++ b/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() diff --git a/packages/client/connection/tests/browser-auth.host.spec.ts b/packages/client/connection/tests/browser-auth.host.spec.ts index 21ce8f592b..6a62d6401c 100644 --- a/packages/client/connection/tests/browser-auth.host.spec.ts +++ b/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) diff --git a/packages/experimental/webworker-runtime/src/transport/tunnel.ts b/packages/experimental/webworker-runtime/src/transport/tunnel.ts index b181e89687..dc6608f975 100644 --- a/packages/experimental/webworker-runtime/src/transport/tunnel.ts +++ b/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 /** Boot payload for `GET /__boot__`: the structured index injection table. */ @@ -118,12 +116,12 @@ export interface TunnelServerOptions { readonly requestListener: () => Promise /** * 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 /** * 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 } diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index bbbf6a3900..27ffb27214 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -356,9 +356,9 @@ function requireLoweredImage(vfs: MemoryVfs, path: string): void { * endpoints (`/api//`) 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. diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts index f9107e1037..0a32e77753 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts +++ b/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): 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()