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 88e2561815..af52bdcb23 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: cdfed1f250de6def39e386cf9fcd2e536c6c97e4 -2026-08-24-browser-token-authentication.zh.md: 29f3a6c0a2d83ed1c17b9c0bc5988c9472741f3f +2026-08-24-browser-token-authentication.md: 47d6afe1bf4cd8169eb4e8ec92a30bf0fee32c8a +2026-08-24-browser-token-authentication.zh.md: 13ecf55e41c7bb1455177bdd6a87a3a83d94ca6f 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 cdfed1f250..47d6afe1bf 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 @@ -12,9 +12,9 @@ The Web Host runs tool-capable Sessions with the current operating-system user's `dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md). -Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `/`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public. +Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public. -The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling. +The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation. 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 loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. 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. 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 29f3a6c0a2..13ecf55e41 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 @@ -12,9 +12,9 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session `dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。 -每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `/`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。 +每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。 -cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。 +cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作。 HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml new file mode 100644 index 0000000000..8f06bf7290 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-09-14-web-document-relative-app-routes.md +2026-09-14-web-document-relative-app-routes.md: fbf30253bbfcc23eb09847f93c1b1f047459d31b +2026-09-14-web-document-relative-app-routes.zh.md: c302ec88a4205239148132513d1ff2a4385033e8 diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md new file mode 100644 index 0000000000..fbf30253bb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md @@ -0,0 +1,47 @@ +# Agent Note: Web app-owned routes are document-relative + +Status: implemented + +English | [中文](2026-09-14-web-document-relative-app-routes.zh.md) + +## Problem + +Every browser reference to a shell-owned route was origin-root absolute: `/api/...` RPC, `/plugins/??...` plugin bundles, the Remote stream mux, the HMR event stream, and the launch entry's token-cleanup redirect to `/`. One listener served behind a prefix-stripping proxy (a mount such as `https://host/tools/dsh/` that forwards `/tools/dsh/...` as `/...`) therefore saw those requests at the origin root, where no route answers them: the shell, its plugin bundles, and its streams all missed. A single bundle has to serve both the origin root and any mount without a second build. + +The mount cannot be recovered from the transport. A configured base URL makes it deployment input, forwarded-prefix inference makes a request header authoritative for routing, and response rewriting in the proxy reimplements the same fact in fragments. The one place that already knows the mount is the served document itself. + +## Decision + +The served HTTP shell owns its document base. Its index carries exactly one ``, spliced in after the opening head tag once the index taps have run, so it precedes the boot manifest, every plugin-resource row, and any markup a tap inserted. The base freezes the entry directory: `/mount/` and `/mount/index.html` are the supported entries, and a document URL that later moves (an in-page `pushState`) does not move the base. Nothing infers a prefix, so deep direct loads and reloads are not entry points; a bare mount still needs the proxy's own normalization to its slash form. + +Browser references to those routes are relative to that document — `api/x`, `plugins/x` — while server route keys stay absolute pathnames: the RPC channel key `/api`, the `webServer` registration paths, and the response tables keyed by the combo route `/plugins/??...&rev=...`. A shared constant that names a key (`*_PATH`, `*_ENDPOINT`) is stripped at the boundary (`KEY.slice(1)`) before browser code uses it, and the browser form lives beside it as `*_ROUTE`. + +The Host composes browser references in one place per producer. Module graph rows and batch descriptors carry the document-relative combo reference; the source-map trailer carries the combo query alone, because a script's source-map reference resolves against that script's own directory (`.../plugins/`) rather than the document. The response table stays keyed by the absolute route, which is also what a request reaches after the proxy strips its prefix. + +The launch entry follows the same rule. `authenticatedUrl` adds the process token to the caller's URL and keeps its authority and mount, and the token exchange redirects to `./`, which removes the query token while preserving the request URL's directory. The Host/Origin fence and the cookie rules are unchanged. + +Consumers that need an absolute URL resolve against `document.baseURI`: the Gateway when it constructs its WebSocket URL and selects `ws:` or `wss:`, and the Inspector when it locates its own bundle from the boot graph. + +Desktop is the other document owner. Its window loads `dsh-app://app/`, an origin that serves the same dist at that root and forwards every app-owned path to the shell-owned Host, so its document directory already is the root and no base row is injected for it; it names that Host origin for the Gateway through the transport's `streamBaseUrl` instead. Its resources live at that root, which is also why the served HTTP shell's `./` does not apply to it. + +## Alternatives considered + +**A configured base URL as the routing base (`publicUrl`).** Rejected: the document already knows its own URL, so this adds a second, drift-prone source for a fact the page can read, and it would have to be plumbed to every consumer instead of one shell decision. The advertised public URL remains an operator-facing print/open input, not a routing input. + +**Inferring the mount from a forwarded-prefix header.** Rejected: it makes a request header authoritative for routing, so any client that sets it could move app-owned routes, and the proxy contract becomes server-visible configuration. + +**A shared `Connection.resolveUrl` or routes base.** The HTTP carrier's document base and the worker tunnel's Host-root mapping differ. Resolving every target in Connection would prepend a static preview path to worker-local routes or lose a served mount. Each carrier resolves its own targets; UI resource consumers use the document base. + +**An absolute `/` redirect with the proxy rewriting `Location`.** Rejected: the browser already knows the request's directory, and a configured redirect target cannot be right for both the loopback entry and a proxy mount. + +## Consequences + +One bundle serves the origin root and any mount, and the served document is the single source of the mount. The costs are explicit: the supported entries are only the dist root and the configured index path, a bare mount depends on the proxy's slash normalization, and a reload of a deep in-page path re-enters at that path rather than the frozen one. + +Routes owned by feature packages (open-in-app, deliverables, session-log export, uploads, markdown media) follow the same rule through their own `*_ROUTE` constants; the static gate that keeps browser sources on this rule is recorded with those changes. + +## Testing + +The frontend-static real-composition test serves the index through the Loader and asserts one `` that precedes an injected plugin-resource row and tap markup. The client-modules node-half suite asserts that graph rows, batch descriptors, and the source-map trailer are document-relative while the response table still serves them by route, and the client test roster plus the assembled-boot fixture carry the same relative form. `browser-auth.host.spec.ts` covers the `./` redirect and the authority- and mount-preserving `authenticatedUrl`; `frontend-static.spec.ts` and `apps/cli/tests/web-auth.e2e.ts` run the exchange through a real Loader composition and the built CLI. + +`apps/web/tests/public-mount.e2e.ts` is the browser proof: a real Chromium reaches one listener through the prefix-stripping proxy, the mounted WebSocket streams both ways, a `pushState` to a deeper path leaves `document.baseURI` at the mount, and a document-relative `fetch('api/session/create')` still succeeds beneath it. diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md new file mode 100644 index 0000000000..c302ec88a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Web 应用自有路由相对文档寻址 + +Status: implemented + +[English](2026-09-14-web-document-relative-app-routes.md) | 中文 + +## 问题 + +浏览器对外壳自有路由的每一处引用都以源站根绝对形式书写:`/api/...` RPC、`/plugins/??...` 插件 bundle、Remote 流 mux、HMR 事件流,以及启动入口清理 token 时重定向到的 `/`。当一个监听端口位于剥离前缀的代理之后(例如 `https://host/tools/dsh/` 这样的挂载,把 `/tools/dsh/...` 转发为 `/...`),这些请求会落在源站根,那里没有任何路由应答:外壳、插件 bundle、流全部落空。同一份 bundle 必须同时服务源站根与任一挂载,而不能为此再构建一次。 + +挂载点无法从传输层恢复。配置 base URL 会把它变成部署输入,转发前缀推断会让请求头对路由具有权威性,代理内改写在碎片里重复同一事实。唯一已经知道挂载点的地方,就是所服务的文档自身。 + +## 决策 + +所服务的 HTTP 外壳拥有自己的文档 base。其 index 携带唯一的 ``,在原始 index 转换执行后拼接于起始 head 标签之后,因此它先于启动 manifest、每一行插件资源,以及任何转换插入的标记。该 base 冻结入口目录:`/mount/` 与 `/mount/index.html` 是受支持的入口,而文档 URL 之后移动(页面内 `pushState`)不会移动 base。系统不推断任何前缀,因此深层直接加载与重新加载不是入口;裸挂载仍需依赖代理自身的规范化补齐尾部斜杠。 + +浏览器对这些路由的引用相对该文档——`api/x`、`plugins/x`——而服务端路由键保持绝对路径名:RPC channel 键 `/api`、`webServer` 注册路径,以及以 combo 路由 `/plugins/??...&rev=...` 为键的响应表。命名路由键的共享常量(`*_PATH`、`*_ENDPOINT`)在边界处被剥去前导斜杠(`KEY.slice(1)`)后浏览器代码才使用,浏览器形式以 `*_ROUTE` 与之并列。 + +宿主在每个生产者里只在一处组合浏览器引用。模块启动图 row 与批次描述符携带文档相对的 combo 引用;source-map trailer 只携带 combo 查询,因为脚本的 source-map 引用相对该脚本自身目录(`.../plugins/`)解析,而非相对文档。响应表仍以绝对路由为键,而请求在代理剥去前缀后到达的也正是该路由。 + +启动入口遵循同一规则。`authenticatedUrl` 把进程 token 加到调用方的 URL 上并保留其 authority 与挂载;token 交换重定向到 `./`,移除 query token 的同时保留请求 URL 的目录。Host/Origin 栅栏与 cookie 规则不变。 + +需要绝对 URL 的消费方相对 `document.baseURI` 解析:Gateway 构造 WebSocket URL 并选择 `ws:` 或 `wss:` 时,以及 Inspector 从启动图定位自身 bundle 时。 + +Desktop 是另一个文档所有者。它的窗口加载 `dsh-app://app/`:该来源在根目录提供同一份 dist,并把其他所有应用路径转发给它拥有的 Host,因此它的文档目录本就是根目录,无需为它注入 base 行;它改为通过传输层的 `streamBaseUrl` 为 Gateway 指定该 Host origin。它的资源就位于该根目录,这也是所服务 HTTP 外壳的 `./` 不适用于它的原因。 + +## 曾考虑的替代方案 + +**把配置 base URL 当作路由 base(`publicUrl`)。** 否决:文档已经知道自己的 URL,因此这是为页面本可读取的事实引入第二个易漂移来源,而且必须把它接入每个消费方,而不是只做一次外壳决策。对外公告的公开 URL 仍是面向运维的打印/打开输入,不是路由输入。 + +**从转发前缀请求头推断挂载点。** 否决:这会让请求头对路由具有权威性,任何设置该头的客户端都能挪动应用自有路由,代理约定也会变成服务端可见的配置。 + +**共享 `Connection.resolveUrl` 或 routes base。** HTTP 载体的文档基准与 worker tunnel 的 Host 根路径映射不同。在 Connection 中统一解析所有目标,会把静态 preview 路径加到 worker 本地路由之前,或丢失所服务的挂载。各载体解析自己的目标;UI 资源消费方使用文档基准。 + +**保留绝对 `/` 重定向并让代理改写 `Location`。** 否决:浏览器已经知道请求所在目录,而配置出来的重定向目标无法同时适用于 loopback 入口与代理挂载。 + +## 后果 + +一份 bundle 服务源站根与任一挂载,所服务的文档是挂载点的唯一来源。代价是显式的:受支持的入口只有 dist 根与配置的 index 路径;裸挂载依赖代理的斜杠规范化;页面内深层路径重新加载会在该路径重新进入,而非冻结目录。 + +功能包自有的路由(open-in-app、deliverables、会话日志导出、上传、markdown 媒体)通过各自的 `*_ROUTE` 常量遵循同一规则;让浏览器源码持续遵守该规则的静态门禁随那些改动一并记录。 + +## 测试 + +frontend-static 的真实组合测试通过 Loader 提供 index,并断言唯一的 `` 先于注入的插件资源行与转换标记。client-modules node 半侧测试断言启动图 row、批次描述符与 source-map trailer 均为文档相对,而响应表仍按路由提供它们,客户端测试 roster 与 assembled-boot fixture 携带同一相对形式。`browser-auth.host.spec.ts` 覆盖 `./` 重定向与保留 authority 和挂载的 `authenticatedUrl`;`frontend-static.spec.ts` 与 `apps/cli/tests/web-auth.e2e.ts` 分别经真实 Loader 组合与构建后的 CLI 运行该交换。 + +`apps/web/tests/public-mount.e2e.ts` 是浏览器证据:真实 Chromium 经剥离前缀的代理到达同一监听端口,挂载下的 WebSocket 双向成流,`pushState` 到更深路径后 `document.baseURI` 仍停在挂载点,而文档相对的 `fetch('api/session/create')` 在其下依然成功。 diff --git a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts index 0c5b4c26d4..b1f89c11d1 100644 --- a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts @@ -138,7 +138,7 @@ function start(f: ReturnType, extra: string[] = []) { expect(response.status).toBe(200) const html = await response.text() expect(html).toContain('__DSH_BOOT__') - const bundlePath = /')) + // A plugin row stands in for the Host's plugin-resource rows: the base the + // shell inserts must precede it, not merely exist. + const offRows = loaded.on('webserver/index-inject', (rows) => { + rows.push({ kind: 'script-preload', src: 'plugins/boot.js' }) + }) for (const path of ['/', '/index.html', '/?view=test']) { const got = await request(port, path, authenticated()) expect(got.status).toBe(200) expect(got.type).toBe('text/html; charset=utf-8') expect(got.body).toContain('__T__') expect(got.body).toContain('shell') + // The served document carries the entry-directory base exactly once, and + // it precedes every injected row and tap markup, so the shell's + // app-owned routes and the Host's resource rows resolve under one mount. + expect(got.body.match(/') + expect(base).toBeLessThan(got.body.indexOf('')) + expect(base).toBeLessThan(got.body.indexOf('')) } + offRows() expect(await request(port, '/', authenticated({ method: 'HEAD' }))).toEqual({ status: 200, type: 'text/html; charset=utf-8', diff --git a/packages/host/webserver/src/injections.ts b/packages/host/webserver/src/injections.ts index 5b431918f5..79734fd34a 100644 --- a/packages/host/webserver/src/injections.ts +++ b/packages/host/webserver/src/injections.ts @@ -20,7 +20,7 @@ export type IndexInjection = /** * External classic script, executed in table order: a parser-blocking tag * when served, an awaited fetch-and-execute in the worker form (whose - * loader resolves worker-only URLs such as `/plugins/...`). + * loader resolves worker-only references such as `plugins/...`). */ | { kind: 'script-src'; placement: IndexInjectionPlacement; src: string } /** Advisory preload for an external classic script; static workers may ignore it. */ diff --git a/packages/test-support/client-runtime/src/assembly/roster.ts b/packages/test-support/client-runtime/src/assembly/roster.ts index 82aa14df93..80b1c824b1 100644 --- a/packages/test-support/client-runtime/src/assembly/roster.ts +++ b/packages/test-support/client-runtime/src/assembly/roster.ts @@ -24,17 +24,17 @@ const LOCAL_REV = 'local' /** * Synthesize the raw `WebBootGraph` for `rows`: one `application` batch - * holding every row, `rev: 'local'`, placeholder `/plugins//client.js` - * URLs, since every module is seeded in process and never fetched. Validation - * stays with the production `parseBootManifest` inside the module system: - * duplicate names and an empty roster are rejected there, not here. + * holding every row, `rev: 'local'`, placeholder `plugins//client.js` + * references, since every module is seeded in process and never fetched. + * Validation stays with the production `parseBootManifest` inside the module + * system: duplicate names and an empty roster are rejected there, not here. * @param rows - roster rows in composition order. * @returns the unparsed graph, as `createClientModuleSystem` consumes it. */ export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph { const entries: WebBootEntry[] = rows.map(row => ({ id: row.name, - url: `/plugins/${row.name}/client.js`, + url: `plugins/${row.name}/client.js`, rev: LOCAL_REV, ...(row.inject.length > 0 ? { inject: [...row.inject] } : {}), ...(row.immediately ? { immediately: true } : {}), @@ -44,7 +44,7 @@ export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph entries, batches: [{ phase: 'application', - url: '/plugins/local.js', + url: 'plugins/local.js', rev: LOCAL_REV, entries: entries.map(entry => entry.id), }], diff --git a/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts index 999a99ea70..c3aded1f56 100644 --- a/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts @@ -25,8 +25,8 @@ const row = (name: string, immediately = false) => ({ name, inject: [], immediat function graph(names: readonly string[]): WebBootGraph { return { rev: 'local', - entries: names.map(id => ({ id, url: `/plugins/${id}/client.js`, rev: 'local' })), - batches: [{ phase: 'application', url: '/plugins/all.js', rev: 'local', entries: [...names] }], + entries: names.map(id => ({ id, url: `plugins/${id}/client.js`, rev: 'local' })), + batches: [{ phase: 'application', url: 'plugins/all.js', rev: 'local', entries: [...names] }], } } @@ -77,8 +77,8 @@ describe('createInProcessModules', () => { it('rejects loudly instead of fetching when a graph row has no loaded module', async () => { const system = createInProcessModules(graph(['ghost']), new Map()) - await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)') - await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)') + await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (plugins/all.js)') + await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (plugins/all.js)') }) it('exposes the graph parsed by the production validator as its manifest', () => { diff --git a/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts index d1ccea2644..7ec7bf9711 100644 --- a/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts @@ -11,22 +11,22 @@ const ROWS: readonly ClientRosterRow[] = [ ] describe('graphFromRoster', () => { - it('synthesizes one application batch over every row with placeholder URLs', () => { + it('synthesizes one application batch over every row with placeholder references', () => { expect(graphFromRoster(ROWS)).toEqual({ rev: 'local', entries: [ - { id: MODULES_PACKAGE, url: `/plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true }, - { id: '@x/a', url: '/plugins/@x/a/client.js', rev: 'local' }, - { id: '@x/b', url: '/plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true }, + { id: MODULES_PACKAGE, url: `plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true }, + { id: '@x/a', url: 'plugins/@x/a/client.js', rev: 'local' }, + { id: '@x/b', url: 'plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true }, ], - batches: [{ phase: 'application', url: '/plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }], + batches: [{ phase: 'application', url: 'plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }], }) }) it('parses through the production validator to plugin rows that mirror the roster, refusing duplicates and an empty roster', () => { const manifest = parseBootManifest(graphFromRoster(ROWS)) expect(manifest.plugins).toEqual(ROWS.map(row => ({ id: row.name, inject: [...row.inject], immediately: row.immediately }))) - expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/plugins/local.js', '/plugins/local.js', '/plugins/local.js']) + expect(manifest.modules.map(row => row.initialUrl)).toEqual(['plugins/local.js', 'plugins/local.js', 'plugins/local.js']) const row = ROWS[1]! expect(() => parseBootManifest(graphFromRoster([row, row]))).toThrow('duplicate graph entry "@x/a"') expect(() => parseBootManifest(graphFromRoster([]))).toThrow('must be a non-empty string array') diff --git a/tsconfig.host.json b/tsconfig.host.json index 11fa5efe32..57ba05d8a4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -12,6 +12,7 @@ "apps/web/tests/default-product-isolation.e2e.ts", "apps/web/tests/diff-context.e2e.ts", "apps/web/tests/scaffold.ts", + "apps/web/tests/prefix-proxy.ts", "apps/web/tests/auto-review-fixture.ts", "apps/web/tests/auto-review-denial.e2e.ts", "apps/web/tests/scaffold-generation.spec.ts", @@ -52,6 +53,7 @@ "apps/web/tests/onboarding-deepseek-config.e2e.ts", "apps/web/tests/onboarding-usable-provider.e2e.ts", "apps/web/tests/remote-welcome.e2e.ts", + "apps/web/tests/public-mount.e2e.ts", "apps/web/tests/workspace-new-session-folding.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/workspace-recency.e2e.ts",