Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/572-workbench-hmr-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': patch
---

Let the documented contributor HMR loop complete a Workbench session: `agent-bundle dev --workbench-dev-origin <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)
22 changes: 17 additions & 5 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ interface DevCommandOptions {
readonly open?: boolean;
readonly port?: number;
readonly root: string;
readonly workbenchDevOrigin: readonly string[];
}

interface DevProxyCommandOptions {
Expand Down Expand Up @@ -738,7 +739,8 @@ export const runCli = async (
.option('--no-agent-api', 'Disable the authenticated Agent API on /mcp')
.option('--install-host <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 <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)({
Expand All @@ -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);
Expand Down
67 changes: 54 additions & 13 deletions packages/agent-bundle/src/dev/foreground-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -392,6 +416,7 @@ export class ForegroundServer {
readonly #sockets = new Set<Socket>();
readonly #streamSubscriptions = new Set<ProjectEventSubscription>();
readonly #testing: ForegroundServerTesting | undefined;
readonly #workbenchDevOrigins: ReadonlySet<string>;
#closePromise: Promise<void> | undefined;
#closing = false;
#listenStarted = false;
Expand All @@ -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;
Expand All @@ -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 }),
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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));
}

Expand All @@ -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));
}
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-bundle/src/dev/workbench-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions packages/agent-bundle/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,60 @@ it('requires a server name for the nested development proxy command', async () =
expect(result.stderr).toContain("required option '--server <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<NonNullable<CliDependencies['startDevServer']>>[0][] = [];
const handlers = new Map<NodeJS.Signals, () => 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<void> => {
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 <origin>');
});

it('builds a selected target through the built executable from a path containing spaces', async () => {
await buildCliPackage();
const project = await createCliProject();
Expand Down
Loading
Loading