diff --git a/.changeset/572-workbench-hmr-proxy.md b/.changeset/572-workbench-hmr-proxy.md new file mode 100644 index 000000000..811383c20 --- /dev/null +++ b/.changeset/572-workbench-hmr-proxy.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Let the documented contributor HMR loop complete a Workbench session: `agent-bundle dev --workbench-dev-origin ` (repeatable; `startDevServer({ workbenchDevOrigins })`) makes the foreground server accept session bootstrap, mutation, and project-event requests whose `Origin` is that explicitly listed loopback Rsbuild dev-server origin instead of answering `AB8003`, and `GET /api/project/session` reports the list as `devOrigins` so the Workbench UI served from that origin accepts the session. Values that are not loopback `http(s)` origins are refused before the server starts (`startDevServer` rejects with `AB8000`); without the flag the same-origin guard is unchanged, and the proxy never rewrites `Origin`. (#572) diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 682e80187..9b47d5e52 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -491,15 +491,27 @@ calls to the new epoch while an admitted call remains pinned to its original epo transport lets an initialized client issue later requests at the same fixed URL when the foreground server returns. -Contributor UI HMR is separate from a published workbench: start it only with a running foreground -server, for example +Contributor UI HMR is separate from a published workbench and takes two terminals: a foreground +server that allowlists the Rsbuild dev-server origin, and the Rsbuild dev server proxying `/api` to +it. ```sh -AGENT_BUNDLE_WORKBENCH_API_PROXY=http://127.0.0.1:3100 pnpm --filter agent-bundle-workbench dev +# Terminal A +npx agent-bundle dev --root . --port 3100 --no-open \ + --workbench-dev-origin http://localhost:3000 +# Terminal B +AGENT_BUNDLE_WORKBENCH_API_PROXY=http://127.0.0.1:3100 \ + pnpm --filter agent-bundle-workbench dev ``` -`packages/workbench/scripts/dev.mjs` requires that proxy URL. Published `agent-bundle dev` serves -prebuilt assets and project events; it does not run an Rsbuild development server. +Open `http://localhost:3000`. The proxy never rewrites `Origin`, so the foreground server admits +Workbench requests only from its own origin or the loopback origins listed with +`--workbench-dev-origin` (repeatable; `startDevServer({ workbenchDevOrigins })`); without the flag +the UI at `http://localhost:3000` fails at bootstrap with `AB8003`, and the allowlist is never on by +default. `packages/workbench/scripts/dev.mjs` requires that proxy URL. Published `agent-bundle dev` +serves prebuilt assets and project events; it does not run an Rsbuild development server. Ports, +proxy scope, and the iframe limitation are documented under +[Contributor UI HMR](https://scriptedalchemy.github.io/agent-bundle/guide/development/workbench#contributor-ui-hmr). ## Testing routes diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 4472da8f2..31c2d8255 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -188,6 +188,7 @@ interface DevCommandOptions { readonly open?: boolean; readonly port?: number; readonly root: string; + readonly workbenchDevOrigin: readonly string[]; } interface DevProxyCommandOptions { @@ -738,7 +739,8 @@ export const runCli = async ( .option('--no-agent-api', 'Disable the authenticated Agent API on /mcp') .option('--install-host ', 'Install and re-sync a development host (repeatable)', collectInstallHost, []) .option('--open', 'Open the workbench after the foreground server starts') - .option('--no-open', 'Do not open the workbench after the foreground server starts'); + .option('--no-open', 'Do not open the workbench after the foreground server starts') + .option('--workbench-dev-origin ', 'Accept Workbench UI requests from this loopback contributor HMR origin (repeatable)', collect, []); devCommand.action(async (options: DevCommandOptions) => { const { startDevServer: start } = await import('./api.ts'); const session = await (dependencies.startDevServer ?? start)({ @@ -747,6 +749,7 @@ export const runCli = async ( open: options.open === true, ...(options.port === undefined ? {} : { port: options.port }), root: options.root, + ...(options.workbenchDevOrigin.length === 0 ? {} : { workbenchDevOrigins: options.workbenchDevOrigin }), }); await show(`Development workbench at ${session.url}\n`); foreground = closeForegroundOnSignal(session, dependencies.signals ?? process, diagnostics); diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 3d48e2e76..9aef368e9 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -44,6 +44,24 @@ const instanceIdLengthLimit = 128; const loopbackHosts = new Set(['127.0.0.1', '::1']); const sseQueueByteLimit = 256 * 1024; +/** + * A serialized loopback http(s) origin such as `http://localhost:3000`: no + * path, query, hash, or credentials, and one of the hostnames a browser page + * on this machine can carry in `Origin`. `URL.hostname` brackets IPv6, so the + * bind host `::1` is read back as `[::1]`. + */ +const isLoopbackBrowserOrigin = (value: string): boolean => { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if (url.origin !== value || (url.protocol !== 'http:' && url.protocol !== 'https:')) return false; + const hostname = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname; + return hostname === 'localhost' || loopbackHosts.has(hostname); +}; + interface QueuedSseFrame { readonly bytes: number; readonly frame: string; @@ -175,6 +193,12 @@ export interface ForegroundServerOptions { readonly sessionToken?: string; /** Test-only foreground stream observation; production callers never supply this. */ readonly testing?: ForegroundServerTesting; + /** + * Contributor HMR only: browser origins of a separately started Workbench + * Rsbuild dev server that proxies /api here. Loopback http(s) origins only; + * never set by default. + */ + readonly workbenchDevOrigins?: readonly string[]; } type SkillRoute = @@ -392,6 +416,7 @@ export class ForegroundServer { readonly #sockets = new Set(); readonly #streamSubscriptions = new Set(); readonly #testing: ForegroundServerTesting | undefined; + readonly #workbenchDevOrigins: ReadonlySet; #closePromise: Promise | undefined; #closing = false; #listenStarted = false; @@ -412,6 +437,13 @@ export class ForegroundServer { if (instanceId.length === 0 || instanceId.length > instanceIdLengthLimit || instanceId.trim() !== instanceId) { throw new ForegroundServerError('AB8000', 'Foreground server instance ID must be a trimmed string between 1 and 128 characters.'); } + const workbenchDevOrigins = options.workbenchDevOrigins ?? []; + if (!workbenchDevOrigins.every(isLoopbackBrowserOrigin)) { + throw new ForegroundServerError( + 'AB8000', + 'Foreground server Workbench dev origins must be loopback http(s) origins such as http://localhost:3000.', + ); + } this.#agentApi = options.agentApi; this.#assets = options.assets; @@ -427,6 +459,7 @@ export class ForegroundServer { this.#skillDocuments = options.skillDocuments; this.#testing = options.testing; this.sessionToken = options.sessionToken ?? randomUUID(); + this.#workbenchDevOrigins = Object.freeze(new Set(workbenchDevOrigins)); this.#mcpAppRoutes = new McpAppRoutes({ authorize: (request) => this.#assertMutationSession(request), ...(options.mcpAppPreviews === undefined ? {} : { service: options.mcpAppPreviews }), @@ -735,15 +768,22 @@ export class ForegroundServer { } if (pathname === '/api/project/session') { if (method !== 'GET') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); - this.#assertSessionBootstrapOrigin(request); + this.#assertBrowserOrigin(request); const cookieName = this.#sessionCookieName(); + const devOrigins = [...this.#workbenchDevOrigins].sort((left, right) => left.localeCompare(right)); response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8', 'set-cookie': `${cookieName}=${this.sessionToken}; HttpOnly; SameSite=Strict; Path=/api`, 'x-content-type-options': 'nosniff', }); - response.end(JSON.stringify({ cookieName, instanceId: this.instanceId, origin: this.url, token: this.sessionToken })); + response.end(JSON.stringify({ + cookieName, + ...(devOrigins.length === 0 ? {} : { devOrigins }), + instanceId: this.instanceId, + origin: this.url, + token: this.sessionToken, + })); return; } if (pathname === '/api/project/rebuild') { @@ -801,10 +841,17 @@ export class ForegroundServer { } } - #assertSessionBootstrapOrigin(request: IncomingMessage): void { + /** This foreground origin, or an operator-listed Workbench dev-server origin whose pages reach /api through its proxy. */ + #isBrowserOrigin(origin: string): boolean { + return origin === this.url || this.#workbenchDevOrigins.has(origin); + } + + /** Browser routes require an accepted `Origin`; a missing one passes only with same-origin fetch provenance. */ + #assertBrowserOrigin(request: IncomingMessage): void { const origin = singleHeader(request.headers.origin); - if (origin === this.url) return; - if (origin === undefined && singleHeader(request.headers['sec-fetch-site']) === 'same-origin') return; + if (origin === undefined ? singleHeader(request.headers['sec-fetch-site']) === 'same-origin' : this.#isBrowserOrigin(origin)) { + return; + } throw requestError(diagnostic('AB8003', 'Request origin is not this foreground server.', 403)); } @@ -819,20 +866,14 @@ export class ForegroundServer { } #assertMutationSession(request: IncomingMessage): void { - const origin = singleHeader(request.headers.origin); - if (origin !== this.url && (origin !== undefined || singleHeader(request.headers['sec-fetch-site']) !== 'same-origin')) { - throw requestError(diagnostic('AB8003', 'Request origin is not this foreground server.', 403)); - } + this.#assertBrowserOrigin(request); if (singleHeader(request.headers['x-agent-bundle-session']) !== this.sessionToken) { throw requestError(diagnostic('AB8004', 'A valid same-session token is required.', 403)); } } #assertEventSession(request: IncomingMessage): void { - const origin = singleHeader(request.headers.origin); - if (origin !== this.url && (origin !== undefined || singleHeader(request.headers['sec-fetch-site']) !== 'same-origin')) { - throw requestError(diagnostic('AB8003', 'Request origin is not this foreground server.', 403)); - } + this.#assertBrowserOrigin(request); if (cookieValue(request, this.#sessionCookieName()) !== this.sessionToken) { throw requestError(diagnostic('AB8004', 'A valid foreground session cookie is required.', 403)); } diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 502eea373..fa6544af4 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -122,6 +122,8 @@ export interface StartDevServerOptions { readonly root: string; /** Test-only listener and sandbox factories; production always uses the built-in loopback services. */ readonly testing?: DevServerTesting; + /** Contributor HMR only: loopback origins of a Workbench Rsbuild dev server that proxies `/api` to this foreground server; never set by default. */ + readonly workbenchDevOrigins?: readonly string[]; } interface DevServerForeground { @@ -922,6 +924,9 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun routeManifest, ...(runtime === undefined ? {} : { runtime }), skillDocuments, + ...(options.workbenchDevOrigins === undefined || options.workbenchDevOrigins.length === 0 + ? {} + : { workbenchDevOrigins: options.workbenchDevOrigins }), }); clientSurfaces.bindHostOrigin(foreground.url); // Linearize Workbench-owned runtime proxy acquisition before Foreground diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 1a2b8d902..63178dadb 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -182,6 +182,60 @@ it('requires a server name for the nested development proxy command', async () = expect(result.stderr).toContain("required option '--server ' not specified"); }); +it('passes repeatable --workbench-dev-origin values to the public dev API and omits the option without the flag', async () => { + // The contributor HMR allowlist (#572) is explicit and never on by default: + // the CLI forwards exactly the listed origins, in order, and leaves the + // option absent (not an empty list) when the flag is not given. Validation + // belongs to the foreground server (AB8000), so cli.ts stays import-light. + const received: Parameters>[0][] = []; + const handlers = new Map void>(); + let closeCalls = 0; + const dependencies: CliDependencies = { + signals: { + once: (signal, listener) => { handlers.set(signal, listener); }, + removeListener: (signal) => { handlers.delete(signal); }, + }, + startDevServer: async (options) => { + received.push(options); + return { + close: async () => { closeCalls += 1; }, + openRuntimeClientSurface: async () => undefined, + status: () => ({}) as never, + url: 'http://127.0.0.1:4100', + }; + }, + }; + const stopForeground = async (): Promise => { + handlers.get('SIGINT')?.(); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + }; + + const listed = await runSourceCliWithOutput([ + 'dev', '--root', '/tmp/plugin', '--no-open', + '--workbench-dev-origin', 'http://localhost:3000', + '--workbench-dev-origin', 'http://127.0.0.1:3000', + ], dependencies); + await stopForeground(); + expect(listed).toEqual({ code: 0, stderr: '', stdout: 'Development workbench at http://127.0.0.1:4100\n' }); + expect(received).toEqual([expect.objectContaining({ + open: false, + root: '/tmp/plugin', + workbenchDevOrigins: ['http://localhost:3000', 'http://127.0.0.1:3000'], + })]); + + const unlisted = await runSourceCliWithOutput(['dev', '--root', '/tmp/plugin', '--no-open'], dependencies); + await stopForeground(); + expect(unlisted).toMatchObject({ code: 0, stderr: '' }); + expect(received).toHaveLength(2); + expect(received[1]).toMatchObject({ open: false, root: '/tmp/plugin' }); + expect(received[1]).not.toHaveProperty('workbenchDevOrigins'); + expect(closeCalls).toBe(2); + + const help = await runSourceCliWithOutput(['dev', '--help']); + expect(help.code).toBe(0); + expect(help.stdout).toContain('--workbench-dev-origin '); +}); + it('builds a selected target through the built executable from a path containing spaces', async () => { await buildCliPackage(); const project = await createCliProject(); diff --git a/packages/agent-bundle/tests/dev-server.test.ts b/packages/agent-bundle/tests/dev-server.test.ts index 663882738..f1ae3505c 100644 --- a/packages/agent-bundle/tests/dev-server.test.ts +++ b/packages/agent-bundle/tests/dev-server.test.ts @@ -719,6 +719,212 @@ it('requires the same origin and session token before a browser can request a re } }); +const workbenchDevOrigin = 'http://localhost:3000'; +const workbenchDevOriginMessage = + 'Foreground server Workbench dev origins must be loopback http(s) origins such as http://localhost:3000.'; +const foreignOriginDiagnostic = Object.freeze({ + diagnostic: { code: 'AB8003', message: 'Request origin is not this foreground server.' }, +}); +const sessionCookieNamePattern = /^agent-bundle-foreground-session-[a-f0-9]{32}$/u; + +interface BrowserRouteProbe { + readonly body: unknown; + readonly contentType: string | null; + readonly status: number; +} + +const bootstrapAs = (server: ForegroundServer, origin: string): Promise => + fetch(`${server.url}/api/project/session`, { headers: { origin } }); + +const rebuildAs = (server: ForegroundServer, origin: string, token = server.sessionToken): Promise => + fetch(`${server.url}/api/project/rebuild`, { + body: '{"paths":["src/skills/review/SKILL.md"]}', + headers: { 'content-type': 'application/json', origin, 'x-agent-bundle-session': token }, + method: 'POST', + }); + +/** Opens the event stream as a page on `origin` and, once admitted, leaves like a page navigating away. */ +const openEventsAs = async (server: ForegroundServer, origin: string, cookieName: string): Promise => { + const controller = new AbortController(); + const response = await fetch(`${server.url}/api/project/events`, { + headers: { cookie: `${cookieName}=${server.sessionToken}`, origin }, + signal: controller.signal, + }); + const contentType = response.headers.get('content-type'); + if (response.status !== 200) return { body: await response.json(), contentType, status: response.status }; + controller.abort(); + await response.text().catch(() => undefined); + return { body: undefined, contentType, status: 200 }; +}; + +const expectForeignOrigin = async (server: ForegroundServer, origin: string, cookieName: string): Promise => { + const bootstrap = await bootstrapAs(server, origin); + expect(bootstrap.status).toBe(403); + await expect(bootstrap.json()).resolves.toEqual(foreignOriginDiagnostic); + + const rebuild = await rebuildAs(server, origin); + expect(rebuild.status).toBe(403); + await expect(rebuild.json()).resolves.toEqual(foreignOriginDiagnostic); + + expect(await openEventsAs(server, origin, cookieName)).toEqual({ + body: foreignOriginDiagnostic, + contentType: 'application/json; charset=utf-8', + status: 403, + }); +}; + +it('keeps refusing a Workbench dev-server origin on every browser route until an operator lists it', async () => { + const coordinator = new RecordingCoordinator(); + const server = await startForegroundServer({ + coordinator, + eventHub: new ProjectEventHub(), + instanceId: 'test-instance-id', + port: 0, + sessionToken: 'test-session-token', + }); + + try { + const bootstrap = await bootstrapAs(server, server.url); + expect(bootstrap.status).toBe(200); + const body: unknown = await bootstrap.json(); + expect(body).toEqual({ + cookieName: expect.stringMatching(sessionCookieNamePattern), + instanceId: 'test-instance-id', + origin: server.url, + token: 'test-session-token', + }); + expect(body).not.toHaveProperty('devOrigins'); + const { cookieName } = body as { readonly cookieName: string }; + + await expectForeignOrigin(server, workbenchDevOrigin, cookieName); + expect(coordinator.invalidations).toEqual([]); + } finally { + await server.close(); + } +}); + +it('admits pages served by a listed Workbench dev-server origin and advertises that allowlist at bootstrap', async () => { + const coordinator = new RecordingCoordinator(); + const server = await startForegroundServer({ + coordinator, + eventHub: new ProjectEventHub(), + instanceId: 'test-instance-id', + port: 0, + sessionToken: 'test-session-token', + workbenchDevOrigins: [workbenchDevOrigin], + }); + + try { + const bootstrap = await bootstrapAs(server, workbenchDevOrigin); + expect(bootstrap.status).toBe(200); + const body: unknown = await bootstrap.json(); + expect(body).toEqual({ + cookieName: expect.stringMatching(sessionCookieNamePattern), + devOrigins: [workbenchDevOrigin], + instanceId: 'test-instance-id', + origin: server.url, + token: 'test-session-token', + }); + const { cookieName } = body as { readonly cookieName: string }; + expect(bootstrap.headers.get('set-cookie')).toBe(`${cookieName}=test-session-token; HttpOnly; SameSite=Strict; Path=/api`); + + const advertised = { + cookieName, + devOrigins: [workbenchDevOrigin], + instanceId: 'test-instance-id', + origin: server.url, + token: 'test-session-token', + }; + const ownOrigin = await bootstrapAs(server, server.url); + expect(ownOrigin.status).toBe(200); + await expect(ownOrigin.json()).resolves.toEqual(advertised); + const headerless = await fetch(`${server.url}/api/project/session`, { headers: { 'sec-fetch-site': 'same-origin' } }); + expect(headerless.status).toBe(200); + await expect(headerless.json()).resolves.toEqual(advertised); + + const rebuild = await rebuildAs(server, workbenchDevOrigin); + expect(rebuild.status).toBe(200); + expect(coordinator.invalidations).toEqual([expect.objectContaining({ + paths: ['src/skills/review/SKILL.md'], + reason: 'manual', + })]); + + const missingToken = await fetch(`${server.url}/api/project/rebuild`, { + body: '{"paths":["src/skills/review/SKILL.md"]}', + headers: { 'content-type': 'application/json', origin: workbenchDevOrigin }, + method: 'POST', + }); + expect(missingToken.status).toBe(403); + await expect(missingToken.json()).resolves.toEqual({ + diagnostic: { code: 'AB8004', message: 'A valid same-session token is required.' }, + }); + + const events = await openEventsAs(server, workbenchDevOrigin, cookieName); + expect(events.status).toBe(200); + expect(events.contentType).toMatch(/^text\/event-stream/u); + + for (const origin of ['http://localhost:3001', 'http://127.0.0.1:3000', 'http://invalid.example']) { + await expectForeignOrigin(server, origin, cookieName); + } + expect(coordinator.invalidations).toHaveLength(1); + } finally { + await server.close(); + } +}); + +for (const value of [ + 'http://evil.example:3000', 'http://localhost:3000/', 'http://localhost:3000/path', 'http://localhost:3000?x=1', + 'localhost:3000', 'http://user:pw@localhost:3000', 'ws://localhost:3000', 'http://10.0.0.1:3000', '', +]) { + it(`refuses the Workbench dev origin ${JSON.stringify(value)} before starting a coordinator`, async () => { + const coordinator = new RecordingCoordinator(); + await expect(startForegroundServer({ coordinator, eventHub: new ProjectEventHub(), workbenchDevOrigins: [value] })) + .rejects.toMatchObject({ code: 'AB8000', message: workbenchDevOriginMessage }); + expect(coordinator.startCalls).toBe(0); + }); +} + +it('accepts each loopback http(s) spelling as a Workbench dev origin and advertises them deduplicated and sorted', async () => { + const accepted = ['https://localhost:3000', 'http://127.0.0.1:3000', 'http://[::1]:3000', workbenchDevOrigin]; + const server = await startForegroundServer({ + coordinator: new RecordingCoordinator(), + eventHub: new ProjectEventHub(), + port: 0, + workbenchDevOrigins: [...accepted, workbenchDevOrigin], + }); + + try { + const devOrigins = [...accepted].sort((left, right) => left.localeCompare(right)); + for (const origin of accepted) { + const bootstrap = await bootstrapAs(server, origin); + expect(bootstrap.status).toBe(200); + await expect(bootstrap.json()).resolves.toMatchObject({ devOrigins, origin: server.url }); + } + } finally { + await server.close(); + } +}); + +it('treats an empty Workbench dev origin list as the default guard', async () => { + const server = await startForegroundServer({ + coordinator: new RecordingCoordinator(), + eventHub: new ProjectEventHub(), + port: 0, + workbenchDevOrigins: [], + }); + + try { + const bootstrap = await bootstrapAs(server, server.url); + expect(bootstrap.status).toBe(200); + const body: unknown = await bootstrap.json(); + expect(body).not.toHaveProperty('devOrigins'); + const { cookieName } = body as { readonly cookieName: string }; + await expectForeignOrigin(server, workbenchDevOrigin, cookieName); + } finally { + await server.close(); + } +}); + it('applies the established foreground origin and token guard to MCP session creation', async () => { const opens: unknown[] = []; const mcpSessions = { diff --git a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts index 73b620f7a..4ee9e0977 100644 --- a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts +++ b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts @@ -1,12 +1,12 @@ import { execFile as executeFile } from 'node:child_process'; import { access, cp, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from '@rstest/core'; +import { availablePort } from './support/available-port.ts'; import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; const execFile = promisify(executeFile); @@ -22,21 +22,6 @@ const buildPackage = async (): Promise => { await built; }; -const availablePort = async (): Promise => { - const server = createServer(); - await new Promise((resolvePromise, rejectPromise) => { - server.once('error', rejectPromise); - server.listen({ host: '127.0.0.1', port: 0 }, resolvePromise); - }); - const address = server.address(); - if (address === null || typeof address === 'string') throw new Error('Expected a TCP address.'); - await new Promise((resolvePromise, rejectPromise) => server.close((error) => { - if (error === undefined) resolvePromise(); - else rejectPromise(error); - })); - return address.port; -}; - describe.sequential('workbench package build', () => { it('copies stable prebuilt workbench assets and the exact app-renderer license into the package distribution', async () => { await buildPackage(); diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index d8dc5c3cb..0be4a79ac 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -1712,6 +1712,40 @@ it('gives the foreground Eval route and lifecycle lanes the same project-owned s } }, 60_000); +it('forwards only a non-empty workbenchDevOrigins list to the foreground server', async () => { + // Contributor HMR allowlist (#572): the public API forwards exactly the + // listed origins and leaves the foreground option absent — not an empty + // list — when the caller omits it or passes [], so the same-origin guard is + // unchanged unless a caller opts in explicitly. + const project = await createProjectFixture(); + const sandboxFailure = new Error('stop after foreground composition'); + const captureForeground = async (workbenchDevOrigins: readonly string[] | undefined): Promise => { + let foreground: ForegroundServerOptions | undefined; + await expect(startDevServer({ + open: false, + port: 0, + root: project.root, + testing: { + createSandboxProxy: async () => { throw sandboxFailure; }, + startForegroundServer: async (options) => { + foreground = options; + return { close: () => options.coordinator.close(), url: 'http://127.0.0.1:49128' }; + }, + }, + ...(workbenchDevOrigins === undefined ? {} : { workbenchDevOrigins }), + })).rejects.toBe(sandboxFailure); + if (foreground === undefined) throw new Error('Foreground server options were not captured.'); + return foreground; + }; + try { + expect((await captureForeground(['http://localhost:3000'])).workbenchDevOrigins).toEqual(['http://localhost:3000']); + expect(await captureForeground([])).not.toHaveProperty('workbenchDevOrigins'); + expect(await captureForeground(undefined)).not.toHaveProperty('workbenchDevOrigins'); + } finally { + await removeProjectFixture(project.root); + } +}, 60_000); + it('keeps MCP and coordinator cleanup failures structural while releasing both resources', async () => { const mcpFailure = new Error('MCP cleanup failed.'); const coordinatorFailure = new Error('Coordinator cleanup failed.'); diff --git a/packages/agent-bundle/tests/support/available-port.ts b/packages/agent-bundle/tests/support/available-port.ts new file mode 100644 index 000000000..7bbd252e6 --- /dev/null +++ b/packages/agent-bundle/tests/support/available-port.ts @@ -0,0 +1,22 @@ +import { createServer } from 'node:net'; + +/** + * Reserves a free TCP port on `host` by binding port 0 and releasing it, for a + * server that must be told its port before it exists — a packed CLI's `--port`, + * or a dev-server origin that is allowlisted before the dev server starts. + * Probe the host the server will bind: `localhost` may resolve to `::1`, where + * a port that is free on 127.0.0.1 can still be taken. + */ +export const availablePort = async (host = '127.0.0.1'): Promise => { + const probe = createServer(); + await new Promise((resolvePromise, rejectPromise) => { + probe.once('error', rejectPromise); + probe.listen({ host, port: 0 }, resolvePromise); + }); + const address = probe.address(); + if (address === null || typeof address === 'string') throw new Error('Expected a TCP address.'); + await new Promise((resolvePromise, rejectPromise) => { + probe.close((error) => error === undefined ? resolvePromise() : rejectPromise(error)); + }); + return address.port; +}; diff --git a/packages/workbench/rsbuild.config.ts b/packages/workbench/rsbuild.config.ts index 7a01b7aa6..27cca975d 100644 --- a/packages/workbench/rsbuild.config.ts +++ b/packages/workbench/rsbuild.config.ts @@ -8,7 +8,10 @@ const sourceRoot = resolve(import.meta.dirname, 'src'); /** * The contributor dev process proxies to a separately started foreground * server. Production assets never proxy: they are served by that foreground - * server directly from the published package. + * server directly from the published package. The proxy leaves the browser's + * `Origin` untouched (Rsbuild's default `changeOrigin` rewrites only `Host`), + * so the foreground must allowlist this dev origin; `strictPort` fails loudly + * on a busy port instead of silently moving the UI to one it has not allowed. */ export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_WORKBENCH_API_PROXY) => ({ html: { @@ -42,6 +45,7 @@ export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_ proxy: { '/api': { target: apiProxyTarget }, }, + strictPort: true, }, }), }); diff --git a/packages/workbench/src/mcp/mcp-route-client.ts b/packages/workbench/src/mcp/mcp-route-client.ts index 0aaecf53f..ac58e9ac2 100644 --- a/packages/workbench/src/mcp/mcp-route-client.ts +++ b/packages/workbench/src/mcp/mcp-route-client.ts @@ -456,6 +456,24 @@ const foregroundRoute = (path: string): string => { return path; }; +/** A serialized origin: `new URL(value).origin === value`, so no path, trailing slash, credentials, or bare host. */ +const isSerializedOrigin = (value: unknown): value is string => { + if (typeof value !== 'string') return false; + try { + return new URL(value).origin === value; + } catch { + return false; + } +}; + +/** + * Contributor dev-server origins the foreground server allowlisted for the Workbench HMR loop (#572). + * The bootstrap body carries the key only while that loopback-only allowlist is non-empty, so an empty + * list is as invalid as a non-list. + */ +const isDevOriginList = (value: unknown): value is readonly string[] => + Array.isArray(value) && value.length > 0 && value.every(isSerializedOrigin); + /** Memory-only authentication shared by foreground browser route clients. */ export class ForegroundRouteClient implements ForegroundRequestAuthority { readonly #fetch: typeof fetch; @@ -607,7 +625,9 @@ export class ForegroundRouteClient implements ForegroundRequestAuthority { if (this.#bootstrapSuperseded(request, authenticationGeneration)) return this.#supersededSnapshot(); if (!response.ok) throw ForegroundRouteClientError.fromResponse(body, response.status); if ( - !isRecord(body) || !hasExactKeys(body, ['cookieName', 'instanceId', 'origin', 'token']) || + !isRecord(body) || + (!hasExactKeys(body, ['cookieName', 'instanceId', 'origin', 'token']) && + !hasExactKeys(body, ['cookieName', 'devOrigins', 'instanceId', 'origin', 'token'])) || typeof body.cookieName !== 'string' || !/^agent-bundle-foreground-session-[a-f0-9]{32}$/u.test(body.cookieName) || typeof body.instanceId !== 'string' || body.instanceId.length === 0 || body.instanceId.length > 128 || body.instanceId.trim() !== body.instanceId || typeof body.origin !== 'string' || @@ -615,6 +635,10 @@ export class ForegroundRouteClient implements ForegroundRequestAuthority { ) { throw new ForegroundRouteClientError('AB8019', 'Foreground session bootstrap returned an invalid response.', response.status); } + const devOrigins = Object.hasOwn(body, 'devOrigins') ? body.devOrigins : undefined; + if (devOrigins !== undefined && !isDevOriginList(devOrigins)) { + throw new ForegroundRouteClientError('AB8019', 'Foreground session bootstrap returned an invalid response.', response.status); + } let origin: URL; try { origin = new URL(body.origin); @@ -623,7 +647,10 @@ export class ForegroundRouteClient implements ForegroundRequestAuthority { } if (origin.origin !== body.origin) throw new ForegroundRouteClientError('AB8019', 'Foreground session bootstrap returned an invalid origin.', response.status); const browserOrigin = globalThis.location?.origin; - if (browserOrigin !== undefined && browserOrigin !== 'null' && browserOrigin !== body.origin) { + if ( + browserOrigin !== undefined && browserOrigin !== 'null' && browserOrigin !== body.origin && + devOrigins?.includes(browserOrigin) !== true + ) { throw new ForegroundRouteClientError('AB8003', 'Foreground session bootstrap origin does not match this browser.', response.status); } const previous = this.#snapshot; diff --git a/packages/workbench/tests/comparisons-page-client-scope-browser.test.ts b/packages/workbench/tests/comparisons-page-client-scope-browser.test.ts index 8c38b99ab..d21497ebe 100644 --- a/packages/workbench/tests/comparisons-page-client-scope-browser.test.ts +++ b/packages/workbench/tests/comparisons-page-client-scope-browser.test.ts @@ -6,10 +6,9 @@ import { join, relative } from 'node:path'; import { expect, test, type PlaywrightOptions } from '@rstest/playwright'; import { createRsbuild } from '@rsbuild/core'; -import { pluginReact } from '@rsbuild/plugin-react'; import { closeServer } from './support/http.ts'; -import { workbenchBrowserAliases } from './support/workbench-browser-modules.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions, browserTrace } from './support/workbench-e2e.ts'; import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; @@ -66,22 +65,7 @@ const mountedComparisonsFixture = async (): Promise<{ readonly close: () => Prom '};', ].join('\n')); const rsbuild = await createRsbuild({ - config: { - output: { - cleanDistPath: false, - distPath: { css: 'assets', js: 'assets', root: dist }, - filename: { css: '[name].css', js: '[name].js' }, - filenameHash: false, - }, - plugins: [pluginReact()], - resolve: { - alias: workbenchBrowserAliases, - }, - source: { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { page: entry }, - }, - }, + config: createWorkbenchFixtureConfig({ distRoot: dist, entry: { page: entry } }), cwd: workspaceRoot, }); const build = await rsbuild.build(); diff --git a/packages/workbench/tests/contributor-hmr.e2e.test.ts b/packages/workbench/tests/contributor-hmr.e2e.test.ts new file mode 100644 index 000000000..5811bf4c4 --- /dev/null +++ b/packages/workbench/tests/contributor-hmr.e2e.test.ts @@ -0,0 +1,168 @@ +import { join } from 'node:path'; + +import { expect } from '@rstest/playwright'; +import { createRsbuild, type Rspack, type StartDevServerResult } from '@rsbuild/core'; + +import { createProjectFixture, removeProjectFixture, type ProjectFixture } from '../../agent-bundle/tests/helpers/project-fixture.ts'; +import { availablePort } from '../../agent-bundle/tests/support/available-port.ts'; +import { within } from '../../agent-bundle/tests/support/eventually.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; +import { createWorkbenchConfig } from '../rsbuild.config.ts'; +import { + buildWorkbench, + e2e, + startWorkbenchDevServer, + withWorkbenchServer, + workbenchUrl, + workspaceRoot, + type WorkbenchServer, +} from './support/workbench-e2e.ts'; + +/** + * The documented contributor HMR loop (#572 §3 P1): + * + * agent-bundle dev --workbench-dev-origin http://localhost:3000 + * AGENT_BUNDLE_WORKBENCH_API_PROXY= pnpm --filter agent-bundle-workbench dev + * + * The browser page lives on the Rsbuild dev origin while every `/api` request + * is proxied to the foreground server, so the page origin and the foreground + * origin differ. This proves that loop completes a Workbench session through + * the DOCUMENTED proxy config (`createWorkbenchConfig`, no header rewriting): + * + * 1. Bootstrap: the Overview renders on the dev origin — the client accepts + * the `devOrigins` disclosed by `GET /api/project/session`, and the + * cookie-authenticated event stream connects through the proxy (the + * connection gate would otherwise replace the page). + * 2. Mutation: a real `POST /api/project/rebuild` carrying the browser's own + * `Origin: http://localhost:` is admitted (200, no request error). + * 3. No laundering: the proxy forwards `Origin` untouched, so a request that + * reaches the dev server with a foreign loopback origin is still refused + * by the foreground with AB8003, while the allowlisted origin is disclosed + * as `devOrigins` next to the unchanged foreground `origin`. + * + * Deliberately not covered: MCP App preview and runtime client-surface + * iframes stay bound to the foreground origin (their sandbox and proxy + * bindings are created from the foreground URL), so the contributor loop does + * not exercise them through the dev origin. + */ + +const workbenchRoot = join(workspaceRoot, 'packages', 'workbench'); +const browserTimeout = 15_000 * timeScale; +/** Rsbuild's first dev compile of the Workbench app; the browser budget starts only after it. */ +const compileTimeout = 90_000; +/** Rsbuild's default `server.host`, and the hostname the documented loop puts in the browser. */ +const devHost = 'localhost'; + +interface ContributorLoop { + readonly dev: StartDevServerResult; + readonly devOrigin: string; + readonly foreground: WorkbenchServer; +} + +const startContributorLoop = async (project: ProjectFixture): Promise => { + // The port is reserved before the dev server exists — the contributor loop's + // own order (`--workbench-dev-origin http://localhost:3000`, then `rsbuild + // dev`) — on the host Rsbuild will bind, or `strictPort` could fail. + const port = await availablePort(devHost); + const devOrigin = `http://${devHost}:${port}`; + const foreground = await startWorkbenchDevServer(project, { workbenchDevOrigins: [devOrigin] }); + try { + const documented = createWorkbenchConfig(foreground.url); + if (!('server' in documented)) throw new Error('The documented Workbench config did not configure the /api proxy.'); + const rsbuild = await createRsbuild({ + config: { + ...documented, + logLevel: 'warn', + mode: 'development', + // The documented `strictPort` stays: the port is the one the foreground allowlisted. + server: { ...documented.server, host: devHost, open: false, port, printUrls: false }, + }, + cwd: workbenchRoot, + }); + const firstCompile = new Promise((resolvePromise) => { + rsbuild.onDevCompileDone(({ isFirstCompile, stats }) => { + if (isFirstCompile) resolvePromise(stats); + }); + }); + const dev = await rsbuild.startDevServer(); + try { + const stats = await within(firstCompile, compileTimeout); + if (stats.hasErrors()) { + throw new Error(`Workbench dev compile failed:\n${stats.toString({ all: false, colors: false, errors: true })}`); + } + } catch (error) { + await Promise.allSettled([dev.server.close()]); + throw error; + } + return { dev, devOrigin, foreground }; + } catch (error) { + await Promise.allSettled([foreground.close()]); + throw error; + } +}; + +/** The dev server closes first: its proxy holds upstream connections into the foreground. */ +const closeContributorLoop = async ({ dev, foreground }: ContributorLoop): Promise => { + const [devClosed] = await Promise.allSettled([dev.server.close()]); + await foreground.close(); + if (devClosed?.status === 'rejected') throw devClosed.reason; +}; + +const sessionThroughProxy = (devOrigin: string, origin?: string): Promise => + fetch(`${devOrigin}/api/project/session`, origin === undefined ? {} : { headers: { origin } }); + +e2e('completes a Workbench session through the documented contributor HMR proxy', { timeout: 180_000 }, async ({ page }) => { + await buildWorkbench(); + await withWorkbenchServer({ + close: closeContributorLoop, + createProject: () => createProjectFixture(), + dispose: (project) => removeProjectFixture(project.root), + start: startContributorLoop, + }, async ({ devOrigin, foreground }) => { + expect(devOrigin).not.toBe(foreground.url); + const pageErrors: Error[] = []; + page.on('pageerror', (error) => pageErrors.push(error)); + + // 1. Bootstrap through the proxy: the client-side origin check accepts the dev origin. + await page.goto(workbenchUrl(devOrigin, 'overview')); + try { + await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: browserTimeout }); + } catch (reason) { + throw new Error( + `Overview did not render on the dev origin ${page.url()}.\n${await page.locator('body').innerText()}`, + { cause: reason }, + ); + } + expect(new URL(page.url()).origin).toBe(devOrigin); + await expect(page.getByRole('heading', { name: /^Foreground connection/u })).toHaveCount(0); + await expect(page.getByRole('status').filter({ hasText: 'Foreground server connected' })).toBeVisible(); + await expect(page.locator('.build-health')).toContainText('Current build', { timeout: browserTimeout }); + + // 2. A mutation carrying the browser's real Origin header, admitted through the allowlist. + const rebuild = page.getByRole('button', { name: 'Rebuild' }); + const rebuildResponse = page.waitForResponse((candidate) => + candidate.request().method() === 'POST' && candidate.url() === `${devOrigin}/api/project/rebuild`); + await rebuild.click(); + const response = await rebuildResponse; + expect(response.status()).toBe(200); + expect((await response.request().allHeaders())['origin']).toBe(devOrigin); + await expect(rebuild).toBeEnabled({ timeout: browserTimeout }); + await expect(page.getByRole('alert')).toHaveCount(0); + await expect(page.locator('.build-health')).toContainText('Current build', { timeout: browserTimeout }); + await expect(page.getByRole('status').filter({ hasText: 'Foreground server connected' })).toBeVisible(); + expect(foreground.status().artifact.state).toBe('active'); + expect(pageErrors).toEqual([]); + + // 3. The proxy does not launder origins: the foreground still decides per Origin, + // and a request without one gains no same-origin provenance on the way through. + const foreign = await sessionThroughProxy(devOrigin, 'http://127.0.0.1:65500'); + expect(foreign.status).toBe(403); + expect(await foreign.json()).toMatchObject({ diagnostic: { code: 'AB8003' } }); + const anonymous = await sessionThroughProxy(devOrigin); + expect(anonymous.status).toBe(403); + expect(await anonymous.json()).toMatchObject({ diagnostic: { code: 'AB8003' } }); + const admitted = await sessionThroughProxy(devOrigin, devOrigin); + expect(admitted.status).toBe(200); + expect(await admitted.json()).toMatchObject({ devOrigins: [devOrigin], origin: foreground.url }); + }); +}); diff --git a/packages/workbench/tests/discovery-atoms-disposal.test.ts b/packages/workbench/tests/discovery-atoms-disposal.test.ts index 244ad742a..e6c3eb730 100644 --- a/packages/workbench/tests/discovery-atoms-disposal.test.ts +++ b/packages/workbench/tests/discovery-atoms-disposal.test.ts @@ -2,11 +2,11 @@ import { createServer } from 'node:http'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { join, normalize, relative } from 'node:path'; -import { createRsbuild, type RsbuildConfig } from '@rsbuild/core'; +import { createRsbuild } from '@rsbuild/core'; import { chromium } from 'playwright'; import { describe, expect, it } from '@rstest/core'; -import { createWorkbenchConfig } from '../rsbuild.config.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions } from './support/workbench-e2e.ts'; declare global { @@ -183,13 +183,10 @@ describe('Discovery atoms', () => { const entry = join(temp, 'discovery-atoms-fixture.tsx'); const output = join(temp, 'dist'); await writeFile(entry, fixtureSource(root)); - const config: RsbuildConfig = createWorkbenchConfig(); - config.source = { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { 'discovery-atoms-fixture': entry }, - }; - config.output = { ...config.output, distPath: { root: output } }; - const rsbuild = await createRsbuild({ config }); + const rsbuild = await createRsbuild({ + config: createWorkbenchFixtureConfig({ distRoot: output, entry: { 'discovery-atoms-fixture': entry } }), + cwd: root, + }); const buildResult = await rsbuild.build(); await buildResult.close(); const { server, url } = await startStaticServer(output); diff --git a/packages/workbench/tests/evals-real.e2e.test.ts b/packages/workbench/tests/evals-real.e2e.test.ts index d20ffa379..1e2564380 100644 --- a/packages/workbench/tests/evals-real.e2e.test.ts +++ b/packages/workbench/tests/evals-real.e2e.test.ts @@ -6,7 +6,6 @@ import { join, relative } from 'node:path'; import { expect } from '@rstest/playwright'; import { createRsbuild } from '@rsbuild/core'; -import { pluginReact } from '@rsbuild/plugin-react'; import { createWorkbenchAssetSource } from '../../agent-bundle/src/dev/workbench-assets.ts'; import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; @@ -14,7 +13,7 @@ import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/t import { seedEvalProject, writeEvalSuite } from '../../agent-bundle/tests/support/eval-project.ts'; import { closeServer } from './support/http.ts'; import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; -import { workbenchBrowserAliases } from './support/workbench-browser-modules.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { buildWorkbench, e2e, workbenchAssets, workspaceRoot, workbenchUrl } from './support/workbench-e2e.ts'; const evalsPage = join(workspaceRoot, 'packages', 'workbench', 'src', 'evals', 'evals-page.tsx'); @@ -89,22 +88,7 @@ const mountedEvalClientScopeFixture = async (): Promise<{ readonly close: () => '', ].join('\n')); const rsbuild = await createRsbuild({ - config: { - output: { - cleanDistPath: false, - distPath: { css: 'assets', js: 'assets', root: dist }, - filename: { css: '[name].css', js: '[name].js' }, - filenameHash: false, - }, - plugins: [pluginReact()], - resolve: { - alias: workbenchBrowserAliases, - }, - source: { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { page: entry }, - }, - }, + config: createWorkbenchFixtureConfig({ distRoot: dist, entry: { page: entry } }), cwd: workspaceRoot, }); const build = await rsbuild.build(); diff --git a/packages/workbench/tests/lifecycles-page.browser.test.tsx b/packages/workbench/tests/lifecycles-page.browser.test.tsx index e5bcad269..5d8eb9a53 100644 --- a/packages/workbench/tests/lifecycles-page.browser.test.tsx +++ b/packages/workbench/tests/lifecycles-page.browser.test.tsx @@ -5,13 +5,12 @@ import { tmpdir } from 'node:os'; import { join, relative } from 'node:path'; import { createRsbuild } from '@rsbuild/core'; -import { pluginReact } from '@rsbuild/plugin-react'; import { afterAll, beforeAll } from '@rstest/core'; import { expect, test, type PlaywrightOptions } from '@rstest/playwright'; import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; import { closeServer } from './support/http.ts'; -import { workbenchBrowserAliases } from './support/workbench-browser-modules.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions, browserTrace } from './support/workbench-e2e.ts'; const workspaceRoot = process.cwd(); @@ -47,20 +46,7 @@ const buildFixture = async (): Promise Promise; ur const root = await mkdtemp(join(tmpdir(), 'agent-bundle-lifecycles-browser-')); const dist = join(root, 'dist'); const rsbuild = await createRsbuild({ - config: { - output: { - cleanDistPath: false, - distPath: { css: 'assets', js: 'assets', root: dist }, - filename: { css: '[name].css', js: '[name].js' }, - filenameHash: false, - }, - plugins: [pluginReact()], - resolve: { alias: workbenchBrowserAliases }, - source: { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { page: fixtureEntry }, - }, - }, + config: createWorkbenchFixtureConfig({ distRoot: dist, entry: { page: fixtureEntry } }), cwd: workspaceRoot, }); const build = await rsbuild.build(); diff --git a/packages/workbench/tests/mcp-app-frame.test.ts b/packages/workbench/tests/mcp-app-frame.test.ts index 02c634675..aa28a37c2 100644 --- a/packages/workbench/tests/mcp-app-frame.test.ts +++ b/packages/workbench/tests/mcp-app-frame.test.ts @@ -8,9 +8,8 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from '@rstest/core'; import { createRsbuild } from '@rsbuild/core'; -import { pluginReact } from '@rsbuild/plugin-react'; -import { workbenchBrowserAliases } from './support/workbench-browser-modules.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions } from './support/workbench-e2e.ts'; import { chromium } from 'playwright'; @@ -129,27 +128,7 @@ const mountedSecureRendererFixture = async () => { '', ].join('\n')); const rsbuild = await createRsbuild({ - config: { - output: { - cleanDistPath: false, - distPath: { css: 'assets', js: 'assets', root: dist }, - filename: { css: '[name].css', js: '[name].js' }, - filenameHash: false, - }, - plugins: [pluginReact()], - resolve: { - alias: { ...workbenchBrowserAliases }, - }, - source: { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { renderer: entry }, - }, - tools: { - rspack: { - resolve: { extensionAlias: { '.js': ['.js', '.ts', '.tsx'], '.jsx': ['.jsx', '.tsx'] } }, - }, - }, - }, + config: createWorkbenchFixtureConfig({ distRoot: dist, entry: { renderer: entry } }), cwd: workspaceRoot, }); const build = await rsbuild.build(); diff --git a/packages/workbench/tests/mcp-app-preview-browser.test.ts b/packages/workbench/tests/mcp-app-preview-browser.test.ts index 03aac44a1..51e8a2b65 100644 --- a/packages/workbench/tests/mcp-app-preview-browser.test.ts +++ b/packages/workbench/tests/mcp-app-preview-browser.test.ts @@ -6,10 +6,9 @@ import { tmpdir } from 'node:os'; import { describe, expect, it } from '@rstest/core'; import { createRsbuild } from '@rsbuild/core'; -import { pluginReact } from '@rsbuild/plugin-react'; import { chromium } from 'playwright'; -import { workbenchBrowserAliases } from './support/workbench-browser-modules.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions } from './support/workbench-e2e.ts'; const workspaceRoot = join(import.meta.dirname, '..', '..', '..'); @@ -74,22 +73,7 @@ const mountedPreviewFixture = async () => { '', ].join('\n')); const rsbuild = await createRsbuild({ - config: { - output: { - cleanDistPath: false, - distPath: { css: 'assets', js: 'assets', root: dist }, - filename: { css: '[name].css', js: '[name].js' }, - filenameHash: false, - }, - plugins: [pluginReact()], - resolve: { - alias: workbenchBrowserAliases, - }, - source: { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { preview: entry }, - }, - }, + config: createWorkbenchFixtureConfig({ distRoot: dist, entry: { preview: entry } }), cwd: workspaceRoot, }); const build = await rsbuild.build(); diff --git a/packages/workbench/tests/mcp-json-input.test.ts b/packages/workbench/tests/mcp-json-input.test.ts index 61e226968..709f8300f 100644 --- a/packages/workbench/tests/mcp-json-input.test.ts +++ b/packages/workbench/tests/mcp-json-input.test.ts @@ -7,9 +7,8 @@ import { join, relative } from 'node:path'; import { tmpdir } from 'node:os'; import { createRsbuild } from '@rsbuild/core'; -import { pluginReact } from '@rsbuild/plugin-react'; -import { workbenchBrowserAliases } from './support/workbench-browser-modules.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions } from './support/workbench-e2e.ts'; import { chromium } from 'playwright'; import { describe, expect, it } from '@rstest/core'; @@ -46,22 +45,7 @@ const mountedInputFixture = async (source: readonly string[]) => { const dist = join(root, 'dist'); await writeFile(entry, source.join('\n')); const rsbuild = await createRsbuild({ - config: { - output: { - cleanDistPath: false, - distPath: { css: 'assets', js: 'assets', root: dist }, - filename: { css: '[name].css', js: '[name].js' }, - filenameHash: false, - }, - plugins: [pluginReact()], - resolve: { - alias: { ...workbenchBrowserAliases }, - }, - source: { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { input: entry }, - }, - }, + config: createWorkbenchFixtureConfig({ distRoot: dist, entry: { input: entry } }), cwd: workspaceRoot, }); const build = await rsbuild.build(); diff --git a/packages/workbench/tests/mcp-page-app-browser.test.ts b/packages/workbench/tests/mcp-page-app-browser.test.ts index b57614cb4..27f22fb7b 100644 --- a/packages/workbench/tests/mcp-page-app-browser.test.ts +++ b/packages/workbench/tests/mcp-page-app-browser.test.ts @@ -6,11 +6,10 @@ import { tmpdir } from 'node:os'; import { describe, expect, it } from '@rstest/core'; import { createRsbuild } from '@rsbuild/core'; -import { pluginReact } from '@rsbuild/plugin-react'; import { chromium, type Page } from 'playwright'; import { closeServer } from './support/http.ts'; -import { workbenchBrowserAliases } from './support/workbench-browser-modules.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions } from './support/workbench-e2e.ts'; const workspaceRoot = join(import.meta.dirname, '..', '..', '..'); @@ -148,22 +147,7 @@ const mountedPageFixture = async (mode: 'artifact' | 'runtime' | 'runtime-direct ]), ].join('\n')); const rsbuild = await createRsbuild({ - config: { - output: { - cleanDistPath: false, - distPath: { css: 'assets', js: 'assets', root: dist }, - filename: { css: '[name].css', js: '[name].js' }, - filenameHash: false, - }, - plugins: [pluginReact()], - resolve: { - alias: workbenchBrowserAliases, - }, - source: { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { page: entry }, - }, - }, + config: createWorkbenchFixtureConfig({ distRoot: dist, entry: { page: entry } }), cwd: workspaceRoot, }); const build = await rsbuild.build(); diff --git a/packages/workbench/tests/mcp-route-client.test.ts b/packages/workbench/tests/mcp-route-client.test.ts index fd175fb9c..b0d120f1c 100644 --- a/packages/workbench/tests/mcp-route-client.test.ts +++ b/packages/workbench/tests/mcp-route-client.test.ts @@ -71,6 +71,8 @@ const invalidSessionBodies: readonly [string, unknown][] = [ ['a versioned payload', { cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', instanceId: 'foreground-instance-a', origin: 'http://127.0.0.1:4100', schemaVersion: 1, token: 'foreground-secret' }], ['an unexpected payload field', { cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', instanceId: 'foreground-instance-a', origin: 'http://127.0.0.1:4100', scope: 'workbench', token: 'foreground-secret' }], ['a malformed payload', { cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', instanceId: 'foreground-instance-a', origin: 'http://127.0.0.1:4100' }], + ['an unexpected payload field alongside a dev-server allowlist', { cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', devOrigins: ['http://localhost:3000'], instanceId: 'foreground-instance-a', origin: 'http://127.0.0.1:4100', scope: 'workbench', token: 'foreground-secret' }], + ['a dev-server allowlist in place of the token', { cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', devOrigins: ['http://localhost:3000'], instanceId: 'foreground-instance-a', origin: 'http://127.0.0.1:4100' }], ]; it('advances the foreground generation only when the server instance changes', async () => { @@ -177,6 +179,85 @@ for (const [description, body] of invalidSessionBodies) { }); } +const sessionBody = Object.freeze({ + cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', + instanceId: 'foreground-instance-a', + origin: 'http://127.0.0.1:4100', + token: 'token-a', +}); + +/** The bootstrap body a foreground server sends while its contributor dev-server allowlist is non-empty (#572). */ +const devSessionBody = Object.freeze({ ...sessionBody, devOrigins: ['http://localhost:3000'] }); + +const sessionBootstrap = (body: unknown): ForegroundRouteClient => new ForegroundRouteClient({ + fetch: async (input) => { + if (String(input) === '/api/project/session') return json(body); + throw new Error(`Unexpected foreground request: ${String(input)}`); + }, +}); + +/** Stubs the browser origin the Node unit pool lacks, then removes the stub (or restores a prior descriptor). */ +const withBrowserOrigin = async (origin: string, run: () => Promise): Promise => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'location'); + Object.defineProperty(globalThis, 'location', { configurable: true, value: { origin } }); + try { + await run(); + } finally { + if (previous === undefined) Reflect.deleteProperty(globalThis, 'location'); + else Object.defineProperty(globalThis, 'location', previous); + } +}; + +it('admits a browser served from an allowlisted contributor dev-server origin', async () => { + await withBrowserOrigin('http://localhost:3000', async () => { + const foreground = sessionBootstrap(devSessionBody); + + await expect(foreground.sessionSnapshot()).resolves.toEqual({ + cookieName: sessionBody.cookieName, + generation: 0, + instanceId: 'foreground-instance-a', + origin: 'http://127.0.0.1:4100', + token: 'token-a', + }); + await expect(foreground.sessionOrigin()).resolves.toBe('http://127.0.0.1:4100'); + }); +}); + +it('rejects a browser origin outside the contributor dev-server allowlist', async () => { + await withBrowserOrigin('http://localhost:3001', async () => { + await expect(sessionBootstrap(devSessionBody).sessionSnapshot()).rejects.toMatchObject({ code: 'AB8003' }); + }); +}); + +it('still rejects a foreign browser origin when the bootstrap carries no dev-server allowlist', async () => { + await withBrowserOrigin('http://localhost:3000', async () => { + await expect(sessionBootstrap(sessionBody).sessionSnapshot()).rejects.toMatchObject({ code: 'AB8003' }); + }); +}); + +it('admits the foreground origin itself without a dev-server allowlist', async () => { + await withBrowserOrigin('http://127.0.0.1:4100', async () => { + await expect(sessionBootstrap(sessionBody).sessionSnapshot()).resolves.toMatchObject({ origin: 'http://127.0.0.1:4100', token: 'token-a' }); + }); +}); + +const invalidDevOrigins: readonly [string, unknown][] = [ + ['an empty allowlist', []], + ['a bare host', ['localhost:3000']], + ['a trailing slash', ['http://localhost:3000/']], + ['a string instead of a list', 'http://localhost:3000'], + ['a number', [1]], + ['a number among valid origins', ['http://localhost:3000', 7]], +]; + +for (const [description, devOrigins] of invalidDevOrigins) { + it(`rejects a dev-server allowlist with ${description}`, async () => { + await withBrowserOrigin('http://localhost:3000', async () => { + await expect(sessionBootstrap({ ...sessionBody, devOrigins }).sessionSnapshot()).rejects.toMatchObject({ code: 'AB8019' }); + }); + }); +} + const foregroundSession = Object.freeze({ cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', instanceId: 'foreground-instance-a', diff --git a/packages/workbench/tests/packed-release.e2e.test.ts b/packages/workbench/tests/packed-release.e2e.test.ts index 25c86fe3c..79a827bb9 100644 --- a/packages/workbench/tests/packed-release.e2e.test.ts +++ b/packages/workbench/tests/packed-release.e2e.test.ts @@ -14,10 +14,10 @@ import { validateOutageLedger, type ConsoleErrorRecord, } from './support/packed-outage-ledger.ts'; +import { availablePort } from '../../agent-bundle/tests/support/available-port.ts'; import { cachedNpmInstallArguments, sharedPackedTarball } from '../../agent-bundle/tests/support/shared-pack.ts'; import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; import { - availablePort, awaitReady, closeChild, descendantProcessIds, diff --git a/packages/workbench/tests/route-editor-atoms-disposal.test.ts b/packages/workbench/tests/route-editor-atoms-disposal.test.ts index 134326a7e..ee544db2d 100644 --- a/packages/workbench/tests/route-editor-atoms-disposal.test.ts +++ b/packages/workbench/tests/route-editor-atoms-disposal.test.ts @@ -2,11 +2,11 @@ import { createServer } from 'node:http'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { join, normalize, relative } from 'node:path'; -import { createRsbuild, type RsbuildConfig } from '@rsbuild/core'; +import { createRsbuild } from '@rsbuild/core'; import { chromium } from 'playwright'; import { describe, expect, it } from '@rstest/core'; -import { createWorkbenchConfig } from '../rsbuild.config.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions } from './support/workbench-e2e.ts'; declare global { @@ -131,13 +131,10 @@ describe('Route editor atoms', () => { const entry = join(temp, 'route-editor-atoms-fixture.tsx'); const output = join(temp, 'dist'); await writeFile(entry, fixtureSource(root)); - const config: RsbuildConfig = createWorkbenchConfig(); - config.source = { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { 'route-editor-atoms-fixture': entry }, - }; - config.output = { ...config.output, distPath: { root: output } }; - const rsbuild = await createRsbuild({ config }); + const rsbuild = await createRsbuild({ + config: createWorkbenchFixtureConfig({ distRoot: output, entry: { 'route-editor-atoms-fixture': entry } }), + cwd: root, + }); const buildResult = await rsbuild.build(); await buildResult.close(); const { server, url } = await startStaticServer(output); diff --git a/packages/workbench/tests/rsbuild-workbench.test.ts b/packages/workbench/tests/rsbuild-workbench.test.ts index 8ea98f961..0cd9f7df0 100644 --- a/packages/workbench/tests/rsbuild-workbench.test.ts +++ b/packages/workbench/tests/rsbuild-workbench.test.ts @@ -40,16 +40,29 @@ it('builds workbench assets with stable unhashed names', () => { it('proxies every typed foreground API route only when a contributor supplies its live foreground target', () => { const configured = createWorkbenchConfig('http://127.0.0.1:3100'); - // Rsbuild 2.x enables changeOrigin for every proxy rule by default. + // Rsbuild 2.x enables changeOrigin for every proxy rule by default, which + // rewrites only Host: the browser's Origin reaches the foreground intact. + // strictPort keeps the UI on the port the foreground allowlisted. expect(configured).toMatchObject({ server: { proxy: { '/api': { target: 'http://127.0.0.1:3100' }, }, + strictPort: true, }, }); }); +it('configures no dev server block when no foreground target is supplied', () => { + const ambientTarget = process.env.AGENT_BUNDLE_WORKBENCH_API_PROXY; + delete process.env.AGENT_BUNDLE_WORKBENCH_API_PROXY; + try { + expect(createWorkbenchConfig()).not.toHaveProperty('server'); + } finally { + if (ambientTarget !== undefined) process.env.AGENT_BUNDLE_WORKBENCH_API_PROXY = ambientTarget; + } +}); + it('publishes the workbench application at the foreground server index asset', async () => { await expect(access(join(workbenchRoot, 'dist', 'index.html'))).resolves.toBeUndefined(); await expect(access(join(workbenchRoot, 'dist', 'static', 'js', 'index.js'))).resolves.toBeUndefined(); diff --git a/packages/workbench/tests/runtime-consent-dialog.test.ts b/packages/workbench/tests/runtime-consent-dialog.test.ts index aaab6814f..51f9fe445 100644 --- a/packages/workbench/tests/runtime-consent-dialog.test.ts +++ b/packages/workbench/tests/runtime-consent-dialog.test.ts @@ -5,9 +5,8 @@ import { tmpdir } from 'node:os'; import { expect, it } from '@rstest/core'; import { createRsbuild } from '@rsbuild/core'; -import { pluginReact } from '@rsbuild/plugin-react'; -import { workbenchBrowserAliases } from './support/workbench-browser-modules.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions } from './support/workbench-e2e.ts'; import { chromium, type Locator } from 'playwright'; @@ -57,14 +56,7 @@ const mountedConsentFixture = async () => { "createRoot(document.getElementById('root')).render();", ].join('\n')); const rsbuild = await createRsbuild({ - config: { - output: { assetPrefix: '/', distPath: { root: dist }, filename: { js: '[name].js' } }, - plugins: [pluginReact()], - resolve: { - alias: { ...workbenchBrowserAliases }, - }, - source: { define: { 'process.env.NODE_ENV': JSON.stringify('production') }, entry: { consent: entry } }, - }, + config: createWorkbenchFixtureConfig({ distRoot: dist, entry: { consent: entry } }), cwd: workspaceRoot, }); const build = await rsbuild.build(); diff --git a/packages/workbench/tests/runtime-document-atoms-disposal.test.ts b/packages/workbench/tests/runtime-document-atoms-disposal.test.ts index 17a81e1f6..e168f7ed7 100644 --- a/packages/workbench/tests/runtime-document-atoms-disposal.test.ts +++ b/packages/workbench/tests/runtime-document-atoms-disposal.test.ts @@ -2,11 +2,11 @@ import { createServer } from 'node:http'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { join, normalize, relative } from 'node:path'; -import { createRsbuild, type RsbuildConfig } from '@rsbuild/core'; +import { createRsbuild } from '@rsbuild/core'; import { chromium } from 'playwright'; import { describe, expect, it } from '@rstest/core'; -import { createWorkbenchConfig } from '../rsbuild.config.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions } from './support/workbench-e2e.ts'; declare global { @@ -133,13 +133,10 @@ describe('Runtime Document atoms', () => { const entry = join(temp, 'runtime-document-atoms-fixture.tsx'); const output = join(temp, 'dist'); await writeFile(entry, fixtureSource(root)); - const config: RsbuildConfig = createWorkbenchConfig(); - config.source = { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { 'runtime-document-atoms-fixture': entry }, - }; - config.output = { ...config.output, distPath: { root: output } }; - const rsbuild = await createRsbuild({ config }); + const rsbuild = await createRsbuild({ + config: createWorkbenchFixtureConfig({ distRoot: output, entry: { 'runtime-document-atoms-fixture': entry } }), + cwd: root, + }); const buildResult = await rsbuild.build(); await buildResult.close(); const { server, url } = await startStaticServer(output); diff --git a/packages/workbench/tests/runtime-inspector.test.ts b/packages/workbench/tests/runtime-inspector.test.ts index 43f8b1055..29046b2d5 100644 --- a/packages/workbench/tests/runtime-inspector.test.ts +++ b/packages/workbench/tests/runtime-inspector.test.ts @@ -2,11 +2,11 @@ import { createServer } from 'node:http'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { join, normalize, relative } from 'node:path'; -import { createRsbuild, type RsbuildConfig } from '@rsbuild/core'; +import { createRsbuild } from '@rsbuild/core'; import { chromium } from 'playwright'; import { describe, expect, it } from '@rstest/core'; -import { createWorkbenchConfig } from '../rsbuild.config.ts'; +import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; import { browserLaunchOptions } from './support/workbench-e2e.ts'; const contentType = (path: string): string => path.endsWith('.css') @@ -83,13 +83,10 @@ describe('Runtime inspector', () => { const entry = join(temp, 'runtime-inspector-fixture.tsx'); const output = join(temp, 'dist'); await writeFile(entry, fixtureSource(root)); - const config: RsbuildConfig = createWorkbenchConfig(); - config.source = { - define: { 'process.env.NODE_ENV': JSON.stringify('production') }, - entry: { 'runtime-inspector-fixture': entry }, - }; - config.output = { ...config.output, distPath: { root: output } }; - const rsbuild = await createRsbuild({ config }); + const rsbuild = await createRsbuild({ + config: createWorkbenchFixtureConfig({ distRoot: output, entry: { 'runtime-inspector-fixture': entry } }), + cwd: root, + }); const buildResult = await rsbuild.build(); await buildResult.close(); const { server, url } = await startStaticServer(output); diff --git a/packages/workbench/tests/support/packed-release-harness.ts b/packages/workbench/tests/support/packed-release-harness.ts index 68478f839..a9e2b2981 100644 --- a/packages/workbench/tests/support/packed-release-harness.ts +++ b/packages/workbench/tests/support/packed-release-harness.ts @@ -1,6 +1,5 @@ import { execFile as executeFile, type ChildProcess } from 'node:child_process'; import { chmod, mkdir, writeFile } from 'node:fs/promises'; -import { createServer } from 'node:net'; import { relative, isAbsolute, join } from 'node:path'; import { promisify } from 'node:util'; @@ -10,21 +9,6 @@ export const execFile = promisify(executeFile); export const workspaceRoot = process.cwd(); const packedServerStartupBudget = 45_000; -export const availablePort = async (): Promise => { - const server = createServer(); - await new Promise((resolvePromise, rejectPromise) => { - server.once('error', rejectPromise); - server.listen({ host: '127.0.0.1', port: 0 }, resolvePromise); - }); - const address = server.address(); - if (address === null || typeof address === 'string') throw new Error('Expected a TCP address.'); - await new Promise((resolvePromise, rejectPromise) => server.close((error) => { - if (error === undefined) resolvePromise(); - else rejectPromise(error); - })); - return address.port; -}; - export const awaitReady = async (origin: string, child: ChildProcess, output: () => string): Promise => { const startedAt = Date.now(); const diagnostics = (): string => diff --git a/packages/workbench/tests/support/workbench-browser-modules.ts b/packages/workbench/tests/support/workbench-browser-modules.ts deleted file mode 100644 index 9f7833d7e..000000000 --- a/packages/workbench/tests/support/workbench-browser-modules.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createRequire } from 'node:module'; -import { dirname, join } from 'node:path'; - -const workbenchRoot = join(import.meta.dirname, '..', '..'); -const requireFromWorkbench = createRequire(join(workbenchRoot, 'package.json')); - -export const workbenchNodeModules = join(workbenchRoot, 'node_modules'); - -export const dependencyRoot = (name: string): string => dirname(requireFromWorkbench.resolve(`${name}/package.json`)); - -export const workbenchBrowserAliases = { - react: dependencyRoot('react'), - 'react-dom': dependencyRoot('react-dom'), - 'react-dom/client': join(dependencyRoot('react-dom'), 'client.js'), -}; diff --git a/packages/workbench/tests/support/workbench-fixture-config.ts b/packages/workbench/tests/support/workbench-fixture-config.ts new file mode 100644 index 000000000..0706c529f --- /dev/null +++ b/packages/workbench/tests/support/workbench-fixture-config.ts @@ -0,0 +1,76 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +import type { RsbuildConfig } from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; + +const workbenchRoot = join(import.meta.dirname, '..', '..'); +const requireFromWorkbench = createRequire(join(workbenchRoot, 'package.json')); + +const dependencyRoot = (name: string): string => dirname(requireFromWorkbench.resolve(`${name}/package.json`)); + +/** + * The Workbench's own React resolution, so a fixture entry written to a temp + * directory outside the package still bundles the single React the Workbench + * sources use. + */ +const workbenchBrowserAliases = { + react: dependencyRoot('react'), + 'react-dom': dependencyRoot('react-dom'), + 'react-dom/client': join(dependencyRoot('react-dom'), 'client.js'), +}; + +/** + * The one Rsbuild profile for the Workbench browser fixtures: a throwaway + * entry that mounts a Workbench component, compiled into a per-test temp + * `dist` and served by the test's own loopback static server. + * + * This is deliberately not `createWorkbenchConfig()` from `rsbuild.config.ts`. + * The production config exists to ship the Workbench: it renders the checked-in + * `index.html` template, copies THIRD_PARTY_NOTICES and APP-RENDERER-LICENSE + * into `dist`, sets `root` to the package, and — whenever + * `AGENT_BUNDLE_WORKBENCH_API_PROXY` happens to be set in the contributor's + * shell — adds the `/api` dev proxy. None of that is fixture behaviour, and + * fixtures that mutated the production config inherited all of it, so their + * output depended on the shell they ran in. What every fixture actually shares + * is exactly what this module returns: + * + * - `mode: 'production'`, pinned. Rsbuild infers the mode from + * `process.env.NODE_ENV`, which a test runner sets to `test`; that maps to + * mode `none`, which drops the `process.env.NODE_ENV` define (the bundle then + * throws "process is not defined" in the browser) and minification. Pinning + * the mode makes Rsbuild define `process.env.NODE_ENV` itself, so fixtures + * carry no manual `source.define` copy. + * - `pluginReact()` and `workbenchBrowserAliases`. + * - A flat, unhashed `assets/` layout (`.html`, `assets/.js`, + * `assets/.css`). It differs from the production `static/` tree on + * purpose: it is the layout the fixtures have always emitted and their + * servers have always served, and `cleanDistPath: false` because the dist is + * a fresh `mkdtemp` child the test removes itself. + * + * Anything a fixture needs beyond this is an explicit, typed option here — a + * test never mutates the returned config. + */ +export type WorkbenchFixtureConfigOptions = Readonly<{ + /** Absolute output root; `.html` and `assets/.{js,css}` land under it. */ + readonly distRoot: string; + /** Entry name → absolute source path. The name is the emitted document's basename. */ + readonly entry: Readonly>; +}>; + +export const createWorkbenchFixtureConfig = ({ distRoot, entry }: WorkbenchFixtureConfigOptions): RsbuildConfig => ({ + mode: 'production', + output: { + cleanDistPath: false, + distPath: { css: 'assets', js: 'assets', root: distRoot }, + filename: { css: '[name].css', js: '[name].js' }, + filenameHash: false, + }, + plugins: [pluginReact()], + resolve: { + alias: { ...workbenchBrowserAliases }, + }, + source: { + entry: { ...entry }, + }, +}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index de6c96f19..f454ff3bb 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -85,6 +85,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/rsc-runtime/tests/state-packaging.test.ts', 'packages/rsc-runtime/tests/state-sqlite-cross-process.test.ts', 'packages/workbench/tests/comparisons-page-client-scope-browser.test.ts', + 'packages/workbench/tests/contributor-hmr.e2e.test.ts', 'packages/workbench/tests/discovery-atoms-disposal.test.ts', 'packages/workbench/tests/discovery.e2e.test.ts', 'packages/workbench/tests/evals-real.e2e.test.ts', diff --git a/website/docs/en/contributing/index.mdx b/website/docs/en/contributing/index.mdx index 4aa07954a..748f32f4c 100644 --- a/website/docs/en/contributing/index.mdx +++ b/website/docs/en/contributing/index.mdx @@ -99,6 +99,24 @@ Dependency review, package previews, and the release publish workflow are hosted structural reasons — the first reads GitHub's advisory database against the pull-request diff, and the other two are publish-side effects rather than checks. +## Working on the Workbench UI + +The Workbench UI in `packages/workbench` has its own hot-reload loop: a foreground server that +allowlists the Rsbuild dev-server origin, plus the Rsbuild dev server proxying `/api` to it. Run +Terminal A from a plugin project root, such as one of the `examples/*`. + +```sh +# Terminal A +npx agent-bundle dev --root . --port 3100 --no-open \ + --workbench-dev-origin http://localhost:3000 +# Terminal B +AGENT_BUNDLE_WORKBENCH_API_PROXY=http://127.0.0.1:3100 \ + pnpm --filter agent-bundle-workbench dev +``` + +Open `http://localhost:3000`. Ports, the proxy scope, and why the flag is required are in +[Contributor UI HMR](../guide/development/workbench.mdx#contributor-ui-hmr). + ## The documentation site This site is a private workspace package. Run it from the repository root: diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 42ad8a501..770c2172a 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -96,8 +96,8 @@ the selected epoch, rather than accepting a browser-supplied command or model. ## The same session programmatically The public `startDevServer` export accepts the options the CLI flags map to (`root`, `port`, -`open`, `agentApi`, `installHosts`) and resolves to a `DevServerSession` exposing the loopback -`url`, a `status()` snapshot, and `close()`: +`open`, `agentApi`, `installHosts`, `workbenchDevOrigins`) and resolves to a `DevServerSession` +exposing the loopback `url`, a `status()` snapshot, and `close()`: ```ts twoslash import { startDevServer } from 'agent-bundle'; @@ -195,15 +195,68 @@ the same fixed URL once the foreground server returns. ## Contributor UI HMR -Working on the Workbench UI itself is a different loop from consuming a published one. Start it -only with a running foreground server: +Working on the Workbench UI itself is a different loop from consuming a published one: an Rsbuild +development server serves the UI with hot module replacement and proxies `/api` to a separately +started foreground server. Published `agent-bundle dev` serves prebuilt assets and project events; +it does not run an Rsbuild development server. The loop takes two terminals. + +Terminal A starts the foreground server and names the Rsbuild origin it should accept: + +```sh +npx agent-bundle dev --root . --port 3100 --no-open \ + --workbench-dev-origin http://localhost:3000 +``` + +Terminal B starts the Rsbuild development server against that foreground URL: ```sh -AGENT_BUNDLE_WORKBENCH_API_PROXY=http://127.0.0.1:3100 pnpm --filter agent-bundle-workbench dev +AGENT_BUNDLE_WORKBENCH_API_PROXY=http://127.0.0.1:3100 \ + pnpm --filter agent-bundle-workbench dev ``` -`packages/workbench/scripts/dev.mjs` requires that proxy URL. Published `agent-bundle dev` serves -prebuilt assets and project events; it does not run an Rsbuild development server. +Open `http://localhost:3000`. `packages/workbench/scripts/dev.mjs` requires that proxy URL and +passes any further arguments through to `rsbuild dev`. Rsbuild's defaults are host `localhost` and +port `3000`; `pnpm --filter agent-bundle-workbench dev --port 3001` moves the UI, in which case +allowlist `http://localhost:3001` in Terminal A instead. Whenever `AGENT_BUNDLE_WORKBENCH_API_PROXY` +is set, `packages/workbench/rsbuild.config.ts` also sets `server.strictPort: true`, so a busy port +`3000` fails loudly instead of Rsbuild silently moving the UI to a port that is not allowlisted. + +**What is proxied.** Only `/api` — every typed Workbench client route plus the +`/api/project/events` SSE stream — is forwarded to the foreground server. The HTML document, the +JavaScript and CSS, and the HMR websocket come from the Rsbuild server. MCP App preview and runtime +client-surface iframes are bound to the foreground origin: the App sandbox exchanges `postMessage` +frames only with the foreground URL, and runtime client surfaces set their `frame-ancestors` CSP to +it. They are not covered by this loop — open the foreground URL itself (`http://127.0.0.1:3100` +above) to work on those pages. + +**The origin rule.** The proxy never rewrites `Origin` (Rsbuild's `changeOrigin` rewrites only +`Host`), so whenever the browser sends `Origin` — on every mutation — the foreground server sees +`Origin: http://localhost:3000`, not its own. +`--workbench-dev-origin ` (repeatable; `startDevServer({ workbenchDevOrigins })` +programmatically) makes it accept session bootstrap (`GET /api/project/session`), mutation +(`POST`/`DELETE` with the `x-agent-bundle-session` token), and project-event stream +(`GET /api/project/events`, session cookie) requests whose `Origin` is one of the listed origins, +in addition to its own origin. Every other origin is still `AB8003`, and the Agent API origin rule +on `/mcp` is unchanged. Each value must be a bare loopback `http:` or `https:` origin — +`localhost`, `127.0.0.1`, or `[::1]`, such as `http://localhost:3000`, with no path — otherwise +the foreground server refuses to start (`agent-bundle dev` exits before serving anything; +`startDevServer` rejects with code `AB8000`). `GET /api/project/session` additionally returns +`devOrigins: [...]` when the list is non-empty (`origin` in that body remains the foreground +URL), and the Workbench UI accepts the session when the page origin equals the foreground origin +or is listed in `devOrigins`. Without the flag the guard is byte-for-byte unchanged: the +allowlist is never on by default. + +The `Origin` guard defends the loopback foreground server against request forgery from any other +origin, including other loopback origins — MCP App sandbox iframes run third-party plugin code on +separate loopback ports, and Rsbuild's development server admits every loopback origin through its +default CORS policy — so the allowlist names exactly your own Rsbuild origin, and the foreground +still sees the browser's real `Origin`. + +Without `--workbench-dev-origin`, the UI at `http://localhost:3000` refuses its own bootstrap as +`AB8003` (the session body names the foreground origin, not the page's) and stays on the +"Foreground connection unavailable" gate, reporting "Workbench request failed with HTTP 200."; the +foreground refuses mutations carrying that `Origin` with `AB8003` as well. The foreground URL keeps +working regardless. ## Next diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index dfa7387d0..16462cf76 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -55,6 +55,7 @@ to `portable` and takes no `--json`. | `--agent-api` / `--no-agent-api` | config `dev.agentApi` | Enable or disable the authenticated Agent API on `/mcp`. | | `--install-host ` | none | Install and re-sync a development host. Repeatable; `claude`, `codex`, or `cursor`. | | `--open` / `--no-open` | `--no-open` | Open the workbench after the foreground server starts. | +| `--workbench-dev-origin ` | none | Accept Workbench UI requests from this loopback contributor HMR origin (the Rsbuild dev server, for example `http://localhost:3000`). Repeatable; a non-loopback or non-origin value makes `agent-bundle dev` exit before serving anything. See [Contributor UI HMR](../guide/development/workbench.mdx#contributor-ui-hmr). | `dev` runs in the foreground and closes the session on a termination signal. See [Developer Workbench](../guide/development/workbench.mdx). diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 03c56e578..b134fa1ef 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -46,7 +46,7 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | Contributor test suites | `1` compares the installed host CLI contract. | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | Contributor test suites | `1` runs the signed-in Claude native smoke. | | `AGENT_BUNDLE_NATIVE_CODEX_SMOKE` | Contributor test suites | `1` runs the signed-in Codex native smoke. | -| `AGENT_BUNDLE_WORKBENCH_API_PROXY` | Contributor HMR (`packages/workbench/scripts/dev.mjs`) | The running Agent Bundle foreground server URL required before Workbench UI HMR starts. See [Developer Workbench](../guide/development/workbench.mdx). | +| `AGENT_BUNDLE_WORKBENCH_API_PROXY` | Contributor HMR (`packages/workbench/scripts/dev.mjs`) | The running Agent Bundle foreground server URL required before Workbench UI HMR starts; the Rsbuild dev server proxies `/api` there and, while it is set, refuses to move off its port (`server.strictPort`). Pair it with `agent-bundle dev --workbench-dev-origin ` on the foreground server. See [Contributor UI HMR](../guide/development/workbench.mdx#contributor-ui-hmr). | The three native-smoke opt-ins exist because those runs need a real signed-in CLI; they are never part of an ordinary test run. diff --git a/website/docs/en/reference/security.mdx b/website/docs/en/reference/security.mdx index 1e02ab307..36d570c52 100644 --- a/website/docs/en/reference/security.mdx +++ b/website/docs/en/reference/security.mdx @@ -38,6 +38,18 @@ hosted service. The browser is not a trusted input source: it never supplies a c directory, a native model, or a credential, and browser-supplied native models and credentials are refused. Operations are trusted-local only. +The foreground server's browser routes — session bootstrap, mutations, and the project-event +stream — carry an `Origin` guard: a request whose `Origin` is not the foreground URL is refused +with `AB8003` (one without `Origin` passes only as `Sec-Fetch-Site: same-origin`), because any +other origin, including another loopback origin such as an MCP App sandbox iframe running +third-party plugin code on its own loopback port, could otherwise forge requests against it. The +contributor HMR allowlist, `agent-bundle dev --workbench-dev-origin `, is the only +exception: explicit, loopback-only (anything but a bare loopback `http`/`https` origin is refused +at startup), and off by default, so without the flag the guard is unchanged. The Rsbuild +proxy in that loop deliberately never rewrites `Origin`: the foreground keeps seeing the browser's +real origin and admits exactly the listed one instead of trusting whatever a proxy forwards. See +[Contributor UI HMR](../guide/development/workbench.mdx#contributor-ui-hmr). + Raw HTML, JSX/MDX, and Mermaid in Skill Markdown are **inert** in the Workbench renderer. A Skill document is content to display, not markup to execute. diff --git a/website/docs/zh/contributing/index.mdx b/website/docs/zh/contributing/index.mdx index d6901e8d2..80305b3d7 100644 --- a/website/docs/zh/contributing/index.mdx +++ b/website/docs/zh/contributing/index.mdx @@ -89,6 +89,24 @@ pnpm test:host-install:session:claude 依赖审查、包预览与发布工作流出于结构性原因只在托管侧运行——第一项要针对 pull request 的 diff 查询 GitHub 的安全公告数据库,另外两项是发布的副作用,而不是检查。 +## 开发 Workbench 界面 + +`packages/workbench` 中的 Workbench 界面有自己的热更新循环:一个放行 Rsbuild 开发服务器 origin 的前台 +服务器,加上把 `/api` 代理到它的 Rsbuild 开发服务器。终端 A 请在某个插件项目根目录下运行,例如 +`examples/*` 之一。 + +```sh +# 终端 A +npx agent-bundle dev --root . --port 3100 --no-open \ + --workbench-dev-origin http://localhost:3000 +# 终端 B +AGENT_BUNDLE_WORKBENCH_API_PROXY=http://127.0.0.1:3100 \ + pnpm --filter agent-bundle-workbench dev +``` + +打开 `http://localhost:3000`。端口、代理范围,以及为什么必须带这个标志,见 +[贡献者 UI 的 HMR](../guide/development/workbench.mdx#贡献者-ui-的-hmr)。 + ## 文档站点 本站点是一个私有工作区包。请在仓库根目录运行: diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index ac9b24a63..397aad6c5 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -83,8 +83,8 @@ MCP 页面的操作也保持独立——轨迹记录的是一段刻意为之的 ## 以编程方式使用同一个会话 公开的 `startDevServer` 导出接受 CLI 标志所映射的那些选项(`root`、`port`、`open`、`agentApi`、 -`installHosts`),并解析为一个 `DevServerSession`,它暴露 loopback 的 `url`、一个 `status()` 快照 -以及 `close()`: +`installHosts`、`workbenchDevOrigins`),并解析为一个 `DevServerSession`,它暴露 loopback 的 `url`、 +一个 `status()` 快照以及 `close()`: ```ts twoslash import { startDevServer } from 'agent-bundle'; @@ -171,14 +171,60 @@ cwd、environment、harness、evidence 与 outcome 字段。以产物为后端 ## 贡献者 UI 的 HMR -开发 Workbench 界面本身,与消费一个已发布的 Workbench 是两个不同的循环。只在前台服务器运行时启动它: +开发 Workbench 界面本身,与消费一个已发布的 Workbench 是两个不同的循环:由一个 Rsbuild 开发服务器带着 +热模块替换提供界面,并把 `/api` 代理到另外启动的前台服务器。已发布的 `agent-bundle dev` 提供的是预构建 +资源与项目事件;它不会运行 Rsbuild 开发服务器。这个循环需要两个终端。 + +终端 A 启动前台服务器,并指明它应当接受的那个 Rsbuild origin: + +```sh +npx agent-bundle dev --root . --port 3100 --no-open \ + --workbench-dev-origin http://localhost:3000 +``` + +终端 B 针对该前台 URL 启动 Rsbuild 开发服务器: ```sh -AGENT_BUNDLE_WORKBENCH_API_PROXY=http://127.0.0.1:3100 pnpm --filter agent-bundle-workbench dev +AGENT_BUNDLE_WORKBENCH_API_PROXY=http://127.0.0.1:3100 \ + pnpm --filter agent-bundle-workbench dev ``` -`packages/workbench/scripts/dev.mjs` 要求提供该代理 URL。已发布的 `agent-bundle dev` 提供的是预构建 -资源与项目事件;它不会运行 Rsbuild 开发服务器。 +打开 `http://localhost:3000`。`packages/workbench/scripts/dev.mjs` 要求提供该代理 URL,并把其余参数原样 +传给 `rsbuild dev`。Rsbuild 的默认值是主机 `localhost`、端口 `3000`; +`pnpm --filter agent-bundle-workbench dev --port 3001` 会把界面挪到别的端口,此时请在终端 A 中改为放行 +`http://localhost:3001`。只要设置了 `AGENT_BUNDLE_WORKBENCH_API_PROXY`, +`packages/workbench/rsbuild.config.ts` 还会设置 `server.strictPort: true`,因此端口 `3000` 被占用时会明确 +失败,而不是由 Rsbuild 悄悄把界面挪到一个未被放行的端口。 + +**哪些内容会被代理。** 只有 `/api`——每一条类型化的 Workbench 客户端路由,加上 `/api/project/events` +SSE 流——会被转发到前台服务器。HTML 文档、JavaScript 与 CSS,以及 HMR websocket 都来自 Rsbuild 服务器。 +MCP App 预览与运行时客户端表面的 iframe 绑定在前台 origin 上:App 沙箱只与前台 URL 交换 `postMessage` +帧,运行时客户端表面则把自己的 `frame-ancestors` CSP 设为前台 URL。它们不在这个循环的覆盖范围内——要开发 +那些页面,请直接打开前台 URL 本身(上例中为 `http://127.0.0.1:3100`)。 + +**origin 规则。** 代理绝不会改写 `Origin`(Rsbuild 的 `changeOrigin` 只改写 `Host`),因此只要浏览器 +发送了 `Origin`——每一次变更请求都会——前台服务器看到的就是 `Origin: http://localhost:3000`,而不是它 +自己的 origin。`--workbench-dev-origin `(可重复;以编程方式则为 +`startDevServer({ workbenchDevOrigins })`)让它除自身 origin 之外,还接受 `Origin` 为所列 origin 之一的 +会话引导(`GET /api/project/session`)、变更(带 `x-agent-bundle-session` token 的 `POST`/`DELETE`)与 +项目事件流(`GET /api/project/events`,会话 cookie)请求。其他任何 origin 仍是 `AB8003`,`/mcp` 上 +Agent API 的 origin 规则也保持不变。每个取值都必须是裸的 loopback `http:` 或 `https:` origin—— +`localhost`、`127.0.0.1` 或 `[::1]`,例如 `http://localhost:3000`,不带路径——否则前台服务器拒绝启动 +(`agent-bundle dev` 会在开始服务之前退出;`startDevServer` 以代码 `AB8000` 拒绝)。当列表非空时, +`GET /api/project/session` 还会额外返回 +`devOrigins: [...]`(该响应体中的 `origin` 仍是前台 URL),而 Workbench 界面会在页面 origin 等于前台 +origin、或出现在 `devOrigins` 中时接受该会话。不带该标志时,这道守卫逐字节保持不变:这份放行列表绝不会 +默认开启。 + +`Origin` 守卫保护 loopback 前台服务器不受来自任何其他 origin 的请求伪造,包括其他 loopback origin—— +MCP App 沙箱 iframe 在独立的 loopback 端口上运行第三方插件代码,而 Rsbuild 开发服务器的默认 CORS 策略 +放行所有 loopback origin——因此这份放行列表只点名你自己的那个 Rsbuild origin,并且前台看到的仍然是浏览器 +真实的 `Origin`。 + +不带 `--workbench-dev-origin` 时,位于 `http://localhost:3000` 的界面会以 `AB8003` 拒绝自己的引导 +(会话响应体中写的是前台 origin,而不是页面的 origin),并停留在"Foreground connection unavailable" +门控页上,显示"Workbench request failed with HTTP 200.";前台也同样会以 `AB8003` 拒绝携带该 `Origin` +的变更请求。前台 URL 本身无论如何都照常工作。 ## 下一步 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index fef1d32f5..f80c86c07 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -54,6 +54,7 @@ npx agent-bundle --version | `--agent-api` / `--no-agent-api` | 配置中的 `dev.agentApi` | 启用或禁用 `/mcp` 上经过认证的 Agent API。 | | `--install-host ` | 无 | 安装并重新同步一个开发期宿主。可重复;取值为 `claude`、`codex` 或 `cursor`。 | | `--open` / `--no-open` | `--no-open` | 前台服务器启动后是否打开 workbench。 | +| `--workbench-dev-origin ` | 无 | 接受来自这个 loopback 贡献者 HMR origin(即 Rsbuild 开发服务器,例如 `http://localhost:3000`)的 Workbench 界面请求。可重复;非 loopback 或不是 origin 的取值会让 `agent-bundle dev` 在开始服务之前退出。见[贡献者 UI 的 HMR](../guide/development/workbench.mdx#贡献者-ui-的-hmr)。 | `dev` 在前台运行,并在收到终止信号时关闭会话。见 [开发者 Workbench](../guide/development/workbench.mdx)。 diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index 8dd13a4fe..95f6577e4 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -43,7 +43,7 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | 贡献者测试套件 | `1` 用于比对已安装宿主 CLI 的契约。 | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | 贡献者测试套件 | `1` 用于运行已登录的 Claude 原生冒烟测试。 | | `AGENT_BUNDLE_NATIVE_CODEX_SMOKE` | 贡献者测试套件 | `1` 用于运行已登录的 Codex 原生冒烟测试。 | -| `AGENT_BUNDLE_WORKBENCH_API_PROXY` | 贡献者 HMR(`packages/workbench/scripts/dev.mjs`) | 启动 Workbench 界面 HMR 之前必须设置的、正在运行的 Agent Bundle 前台服务器 URL。见[开发者 Workbench](../guide/development/workbench.mdx)。 | +| `AGENT_BUNDLE_WORKBENCH_API_PROXY` | 贡献者 HMR(`packages/workbench/scripts/dev.mjs`) | 启动 Workbench 界面 HMR 之前必须设置的、正在运行的 Agent Bundle 前台服务器 URL;Rsbuild 开发服务器把 `/api` 代理到那里,并且在它被设置期间拒绝挪离自己的端口(`server.strictPort`)。请在前台服务器一侧搭配 `agent-bundle dev --workbench-dev-origin ` 使用。见[贡献者 UI 的 HMR](../guide/development/workbench.mdx#贡献者-ui-的-hmr)。 | 这三个原生冒烟测试的开关之所以存在,是因为那些运行需要一个真实的、已登录的 CLI;它们绝不属于日常测试 运行的一部分。 diff --git a/website/docs/zh/reference/security.mdx b/website/docs/zh/reference/security.mdx index 00fac827c..0840a7aab 100644 --- a/website/docs/zh/reference/security.mdx +++ b/website/docs/zh/reference/security.mdx @@ -31,6 +31,16 @@ API key 与形似凭据的环境取值,并在所选宿主的日常配置、设 它绝不提供命令、工作目录、原生模型或凭据,且由浏览器提供的原生模型与凭据会被拒绝。所有操作都只是可信 本地操作。 +前台服务器的浏览器路由——会话引导、变更与项目事件流——带有一道 `Origin` 守卫:`Origin` 不是前台 URL +的请求会以 `AB8003` 被拒绝(不带 `Origin` 的请求只有作为 `Sec-Fetch-Site: same-origin` 才能通过), +因为其他任何 origin——包括另一个 loopback origin,例如在自己的 loopback 端口上运行第三方插件代码的 +MCP App 沙箱 iframe——否则都可能对它伪造请求。贡献者 HMR 放行列表 +`agent-bundle dev --workbench-dev-origin ` 是唯一的例外:显式、仅限 loopback(不是裸 loopback +`http`/`https` origin 的任何取值都会在启动时被拒绝)、默认关闭,因此不带该标志时这道守卫保持 +不变。该循环中的 Rsbuild 代理刻意绝不改写 `Origin`:前台始终看到浏览器真实的 origin,并且只放行被明确 +列出的那一个,而不是信任代理转发过来的任何内容。见 +[贡献者 UI 的 HMR](../guide/development/workbench.mdx#贡献者-ui-的-hmr)。 + Skill Markdown 中的原始 HTML、JSX/MDX 与 Mermaid 在 Workbench 渲染器中是**惰性的**。Skill 文档是用于 展示的内容,而不是用于执行的标记。