feat(browser): replace the preview proxy with a real browser panel and an agent web tool (#2883)

The preview panel worked by proxying a dev server through OpenChamber's own
origin and rewriting the HTML that came back. Anything the rewriter did not
anticipate broke, and pages that refuse to be embedded never loaded at all.
This deletes the proxy (-1604 lines and its tests) and merges the preview and
browser panels into one surface backed by a real Chromium view.

What the panel is now

- A `<webview>` in its own session partition: logins and cookies persist, hot
  reload works because nothing is rewritten, DevTools are one click away.
- Annotation: pick one element, drag a region, or draw freehand, write a note,
  and it reaches chat with a screenshot of the visible page with the marks on it.
- Toolbar: hard reload, page zoom, device sizes, a light/dark switch that
  applies to the page rather than the app, and cookie/cache clearing scoped to
  the panel alone.
- Several pages at once, each tab showing the page's own favicon, and an address
  bar that suggests pages already visited in this project.
- Dev servers are listed from what is actually listening on the machine, checked
  against what a project announced, so a server is offered no matter how it was
  started. One that is still starting is waited for instead of failing.

Remote dev servers

The desktop app binds a local port and pipes raw bytes to the OpenChamber host
over the existing authenticated connection, so the page keeps its own origin at
the root of its own host. The reachable set is exactly what discovery reports
and is re-checked per connection, so an authenticated client cannot dial
arbitrary local services on the host. Links and redirects to another loopback
port stay on the machine that served the page. A tunnel that cannot be opened is
reported; it is never replaced by the plain loopback URL, which would answer
from the user's own machine under a remote address.

Agent control

Browser actions are a separate `openchamber_web` tool: open, snapshot, click,
type, scroll, inspect computed styles, resize between mobile/tablet/desktop, and
capture a screenshot into `.openchamber/screenshots/` in the project. The
existing `openchamber` tool keeps sessions, worktrees and scheduled tasks. Each
has its own setting in the new Settings -> General -> OpenChamber Tools section,
and the plugin is not injected at all when both are off.

Capability belongs to the connected client, not to configuration: a client
declares on its event stream that it can drive a page, which only a Chromium
host does. Exactly one client performs each request — it claims the request
before acting, and the first claim wins — because deciding by whose result
arrives first would be too late for a click that already happened. No client
listening is answered immediately with an explanation rather than a timeout.

Runtime boundaries

Web tabs get a plain iframe that can display a page but not inspect one. The
VS Code extension no longer offers the surface at all, since nothing that makes
the panel worth having works there. Mobile is unaffected.

Native boundary

Camera, microphone, location and device-picker requests from panel pages are
denied — Electron grants them by default when no handler is set, and the panel
loads whatever address the user types. Page capture, appearance emulation and
storage clearing verify that their target belongs to the panel's own session
instead of trusting a web-contents id from the renderer.

Persisted state

Stored `preview` tabs migrate to `browser` (v13 -> v14). Context panel tab
limits are now per surface, so filling one surface no longer evicts another's
tabs. Address history is stored per project and per runtime.

Documentation

`preview.mdx` and `desktop-browser.mdx` rewritten across all locales, the agent
tool settings path corrected, new `DOCUMENTATION.md` for the browser-control
broker and the dev tunnel, and the `ui-api-decoupling` skill updated where it
still described the deleted proxy.
This commit is contained in:
Bohdan Triapitsyn
2026-08-13 22:44:13 +03:00
committed by GitHub
parent 50613bb170
commit a5aa32446d
151 changed files with 10431 additions and 5587 deletions
+65 -19
View File
@@ -92,16 +92,18 @@ import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.j
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js';
import { createClientPairingRuntime } from './lib/client-auth/pairing.js';
import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
import { attachRealtimeProxy } from './lib/realtime-proxy.js';
import { createRelayService } from './lib/relay/service.js';
import { createRelayHostLock } from './lib/relay/host-lock.js';
import { createAgentToolRuntime } from './lib/agent-tool/runtime.js';
import { createBrowserControlBroker } from './lib/browser-control/broker.js';
import { createDevServerScanner } from './lib/dev-servers/routes.js';
import { createDevTunnelRuntime } from './lib/dev-tunnel/runtime.js';
import { registerBrowserControlRoutes } from './lib/browser-control/routes.js';
import { createSystemPromptRuntime } from './lib/system-prompt/runtime.js';
import { createOpenChamberSessionService } from './lib/openchamber-sessions/routes.js';
import { createScheduledTaskService } from './lib/scheduled-tasks/service.js';
import { createOpenChamberControlService } from './lib/openchamber-control/service.js';
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
import webPush from 'web-push';
const __filename = fileURLToPath(import.meta.url);
@@ -1094,9 +1096,13 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
},
getManagedOpenCodeEnv: async () => {
const settings = await readSettingsFromDiskMigrated().catch(() => null);
const managedEnv = settings?.agentControlToolEnabled === false
? {}
: await (agentToolRuntime?.prepareManagedOpenCodeEnv() || {});
// Each capability is its own tool and its own switch; the plugin is only
// injected while at least one of them is on.
const includeControl = settings?.agentControlToolEnabled !== false;
const includeWeb = settings?.agentWebToolEnabled !== false;
const managedEnv = includeControl || includeWeb
? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb }) || {})
: {};
if (settings?.optimizeSystemPrompt !== true) return managedEnv;
const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
@@ -1188,6 +1194,37 @@ const openChamberSessionService = createOpenChamberSessionService({
waitForOpenCodeReady,
emitSessionCreatedEvent,
});
// Browser actions are published to whichever OpenChamber clients are connected;
// the one owning the browser panel answers. `emitRequest` returns the number of
// clients reached so the broker can fail fast when nobody is listening.
const browserControlBroker = createBrowserControlBroker({
createId: () => `browser-${crypto.randomUUID()}`,
emitRequest: (request) => {
// Opening a page only needs a panel to open it in; everything else needs a
// client that can actually drive one. Counting the right clients is what
// lets the broker say "not here" instead of timing out.
const needsBrowserView = request.action !== 'browser.open';
let delivered = 0;
for (const client of uiOpenChamberEventClients) {
if (needsBrowserView && client.openchamberBrowserCapable !== true) continue;
try {
writeSseEvent(client, {
type: 'openchamber:browser-control-request',
properties: {
requestId: request.requestId,
action: request.action,
parameters: request.parameters,
},
});
delivered += 1;
} catch {
uiOpenChamberEventClients.delete(client);
}
}
return delivered;
},
});
const openChamberControlService = createOpenChamberControlService({
readSettingsFromDiskMigrated,
sanitizeProjects,
@@ -1196,6 +1233,7 @@ const openChamberControlService = createOpenChamberControlService({
waitForOpenCodeReady,
sessionService: openChamberSessionService,
scheduledTaskService,
browserControl: browserControlBroker,
});
const ensureGlobalWatcherStarted = async () => {
@@ -1653,6 +1691,24 @@ async function main(options = {}) {
relayServiceInstance = relayService;
relayService.registerRoutes(app);
registerBrowserControlRoutes(app, { express, broker: browserControlBroker });
// One scanner backs both discovery and the tunnel allowlist, so a port the
// user can see is exactly a port the tunnel will dial.
const devServerScanner = createDevServerScanner({ spawn, platform: process.platform });
const listDevServers = () => devServerScanner.discover({
ownPorts: [port, openCodePort].filter((value) => Number.isInteger(value) && value > 0),
});
createDevTunnelRuntime({
server,
discoverDevServers: listDevServers,
uiAuthController,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
logger: console,
});
await featureRoutesRuntime.registerRoutes(app, {
crypto,
fs,
@@ -1682,6 +1738,10 @@ async function main(options = {}) {
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getOpenCodePort: () => openCodePort,
// Dev-server discovery must not offer OpenChamber's own listeners back to
// the user as something to preview.
getOwnPorts: () => [port, openCodePort].filter((value) => Number.isInteger(value) && value > 0),
devServerScanner,
buildAugmentedPath,
projectConfigRuntime,
scheduledTasksRuntime,
@@ -1695,20 +1755,6 @@ async function main(options = {}) {
permissionAutoAcceptRuntime,
});
const previewProxyRuntime = createPreviewProxyRuntime({
crypto,
URL,
createProxyMiddleware,
responseInterceptor,
});
previewProxyRuntime.attach(app, {
server,
express,
uiAuthController,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
});
const startupPipelineResult = await startupPipelineRuntime.run({
app,
server,
@@ -2,12 +2,27 @@
## Purpose
This module exposes OpenChamber orchestration to agents as one typed OpenCode
custom tool named `openchamber`. It is injected only when OpenChamber launches
and owns the OpenCode process, and only while the persisted
`agentControlToolEnabled` setting is not `false` (default on; toggled in
Settings → General → OpenCode CLI and applied on the next managed OpenCode
restart).
This module exposes OpenChamber to agents as typed OpenCode custom tools. There
are two, because controlling sessions and driving a page are separate intents
the user can want independently:
- `openchamber` — projects, sessions, worktrees, and scheduled tasks. Enabled
while the persisted `agentControlToolEnabled` setting is not `false`.
- `openchamber_web` — looking at and interacting with the page in OpenChamber's
browser panel. Enabled while `agentWebToolEnabled` is not `false`.
Both default to on, are toggled in Settings → General → OpenCode CLI, and apply
on the next managed OpenCode restart. Each tool carries only its own actions and
only the parameters those actions use, so turning one off removes its inputs
from the schema rather than leaving them visible. The plugin is injected only
when OpenChamber launches and owns the OpenCode process, and not at all when
both settings are `false`.
- The plugin accepts the action's inputs either inside `parameters` or beside
`action`, because models produce both shapes; an explicit `parameters` object
wins on a conflict. Rejecting the flattened shape turned a call that plainly
carried a `url` into "url is required", which reads as a broken tool rather
than a malformed call.
## Runtime flow
+90 -16
View File
@@ -3,15 +3,29 @@ import { pathToFileURL } from 'node:url';
import {
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS,
OPENCHAMBER_AGENT_TOOL_ACTIONS,
OPENCHAMBER_WEB_ACTION_DEFINITIONS,
OPENCHAMBER_WEB_ACTIONS,
} from '../openchamber-control/actions.js';
const TOOL_SCHEMA_VERSION = 1;
const ACTIONS = new Set(OPENCHAMBER_AGENT_TOOL_ACTIONS);
// Everything either managed tool may ask for; the agent allowlist stays
// narrower than the full control surface.
const ACTIONS = new Set([...OPENCHAMBER_AGENT_TOOL_ACTIONS, ...OPENCHAMBER_WEB_ACTIONS]);
const AGENT_TOOL_ACTION_TITLES = Object.fromEntries(
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS.map(({ action, title }) => [action, title]),
[...OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS, ...OPENCHAMBER_WEB_ACTION_DEFINITIONS]
.map(({ action, title }) => [action, title]),
);
const PLUGIN_PARAMETER_PROPERTIES = {
/**
* Each tool carries only the inputs its own actions take.
*
* A shared parameter object would leave a disabled capability's inputs visible
* in the other tool's schema, which is both misleading and paid for in context
* on every call.
*/
const WEB_PARAMETER_NAMES = ['url', 'selector', 'text', 'value', 'submit', 'direction', 'viewport', 'label'];
const ALL_PARAMETER_PROPERTIES = {
projectId: { type: 'string', description: 'Configured project ID; do not combine with directory' },
directory: { type: 'string', description: 'Absolute checkout or session directory; defaults to the current session directory' },
sessionId: { type: 'string' },
@@ -44,8 +58,29 @@ const PLUGIN_PARAMETER_PROPERTIES = {
cron: { type: 'string', description: 'Cron expression' },
timezone: { type: 'string', description: 'IANA timezone' },
disabled: { type: 'boolean', description: 'true disables and false enables; required for schedule.toggle' },
url: { type: 'string', description: 'http(s) URL for browser.open' },
selector: { type: 'string', description: 'CSS selector from a browser.snapshot result' },
text: { type: 'string', description: 'Visible label to match when no selector is given' },
value: { type: 'string', description: 'Text to type for browser.type' },
submit: { type: 'boolean', description: 'Press Enter after typing' },
direction: { type: 'string', enum: ['up', 'down', 'top', 'bottom'], description: 'Scroll direction for browser.scroll' },
viewport: { type: 'string', enum: ['mobile', 'tablet', 'desktop', 'fill'], description: 'Page layout size; snapshots report which one is in effect' },
label: { type: 'string', description: 'Short name for a browser.capture image, such as before-fix' },
};
const pickParameters = (names) => Object.fromEntries(
Object.entries(ALL_PARAMETER_PROPERTIES).filter(([name]) => names.includes(name)),
);
const CONTROL_PARAMETER_PROPERTIES = pickParameters(
Object.keys(ALL_PARAMETER_PROPERTIES).filter((name) => !WEB_PARAMETER_NAMES.includes(name)),
);
const WEB_PARAMETER_PROPERTIES = pickParameters(WEB_PARAMETER_NAMES);
const CONTROL_TOOL_DESCRIPTION = "Control OpenChamber projects, sessions, and scheduled tasks on the user's behalf. Sessions and scheduled tasks you create are for the user to follow and interact with; never use this tool to delegate parts of your own current task. Use one action per call. Scope with projectId or directory; omit both to use the current session directory. Session dispatches return immediately by default and you receive no notification when a dispatched session finishes, so never promise to report back on it; the user follows it in OpenChamber; a dispatched session needs no follow-up from you. If the user later asks how it went, use session.messages (add wait to block until it is idle, lastAssistant for just the final answer) — session.send always sends a NEW prompt and never just waits. Set wait only when the user asks or the next step requires the completed result. Session and worktree deletion are unavailable.";
const WEB_TOOL_DESCRIPTION = "Look at and interact with a web page in OpenChamber's browser panel, so you can check your own work rather than describing what you expect. Use one action per call. Open a page, snapshot it to read its text and its interactive elements, then click, type or scroll using the selectors the snapshot returned; snapshots also report any errors the page logged. Pass a selector to browser.snapshot to read one part of a long page. browser.inspect returns computed styles when the question is how something renders. Set viewport to check a layout at mobile, tablet or desktop size. The page runs with the user's real logins, so treat what you see as their live session.";
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
@@ -68,23 +103,33 @@ const isLoopbackAddress = (value) => {
|| address === '::ffff:127.0.0.1';
};
const createPluginSource = () => String.raw`
export const OpenChamberPlugin = async () => ({
tool: {
openchamber: {
description: "Control OpenChamber projects, sessions, and scheduled tasks on the user's behalf. Sessions and scheduled tasks you create are for the user to follow and interact with; never use this tool to delegate parts of your own current task. Use one action per call. Scope with projectId or directory; omit both to use the current session directory. Session dispatches return immediately by default and you receive no notification when a dispatched session finishes, so never promise to report back on it; the user follows it in OpenChamber; a dispatched session needs no follow-up from you. If the user later asks how it went, use session.messages (add wait to block until it is idle, lastAssistant for just the final answer) — session.send always sends a NEW prompt and never just waits. Set wait only when the user asks or the next step requires the completed result. Session and worktree deletion are unavailable.",
/**
* One template, one entry per enabled capability.
*
* Both tools speak to the same callback with the same envelope; only the action
* set, the inputs and the description differ. Generating them from one template
* keeps the transport, metadata and failure handling identical, which is what
* the caller depends on.
*/
const createToolEntry = ({ name, description, actions, definitions, parameters }) => String.raw` ${name}: {
description: ${JSON.stringify(description)},
args: {
action: { type: "string", enum: ${JSON.stringify(OPENCHAMBER_AGENT_TOOL_ACTIONS)}, oneOf: ${JSON.stringify(OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS.map(({ action, description }) => ({ const: action, description })))}, description: "OpenChamber action to perform" },
parameters: { type: "object", properties: ${JSON.stringify(PLUGIN_PARAMETER_PROPERTIES)}, additionalProperties: false, description: "Inputs for the action; use an empty object when none are needed" },
action: { type: "string", enum: ${JSON.stringify(actions)}, oneOf: ${JSON.stringify(definitions.map((entry) => ({ const: entry.action, description: entry.description })))}, description: "OpenChamber action to perform" },
parameters: { type: "object", properties: ${JSON.stringify(parameters)}, additionalProperties: false, description: "Inputs for the action; use an empty object when none are needed" },
},
async execute(input, context) {
const args = { ...(input.parameters ?? {}), action: input.action }
// Models routinely put the inputs next to the action instead of inside
// the parameters object, and dropping them there produced a
// "url is required" error for a call that plainly carried a url. Both
// shapes are accepted; an explicit parameters object wins on a conflict.
const { action: requestedAction, parameters, ...flattened } = input ?? {}
const args = { ...flattened, ...(parameters ?? {}), action: requestedAction }
const actionTitles = ${JSON.stringify(AGENT_TOOL_ACTION_TITLES)}
const title = Object.hasOwn(actionTitles, args.action) ? actionTitles[args.action] : args.action
context.metadata({
title,
metadata: {
openchamber: {
${name}: {
schemaVersion: ${TOOL_SCHEMA_VERSION},
action: args.action,
description: title,
@@ -119,7 +164,7 @@ export const OpenChamberPlugin = async () => ({
context.metadata({
title,
metadata: {
openchamber: {
${name}: {
schemaVersion: ${TOOL_SCHEMA_VERSION},
action: args.action,
description: title,
@@ -135,9 +180,35 @@ export const OpenChamberPlugin = async () => ({
}
},
},
},
`;
const createPluginSource = ({ includeControl, includeWeb }) => {
const entries = [];
if (includeControl) {
entries.push(createToolEntry({
name: 'openchamber',
description: CONTROL_TOOL_DESCRIPTION,
actions: OPENCHAMBER_AGENT_TOOL_ACTIONS,
definitions: OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS,
parameters: CONTROL_PARAMETER_PROPERTIES,
}));
}
if (includeWeb) {
entries.push(createToolEntry({
name: 'openchamber_web',
description: WEB_TOOL_DESCRIPTION,
actions: OPENCHAMBER_WEB_ACTIONS,
definitions: OPENCHAMBER_WEB_ACTION_DEFINITIONS,
parameters: WEB_PARAMETER_PROPERTIES,
}));
}
return `export const OpenChamberPlugin = async () => ({
tool: {
${entries.join('')} },
})
`;
};
const mergePluginConfig = (rawConfig, pluginUrl) => {
const errors = [];
@@ -170,13 +241,16 @@ export const createAgentToolRuntime = (dependencies) => {
const pluginPath = path.join(pluginDirectory, 'openchamber-plugin.js');
let activeToken = null;
const prepareManagedOpenCodeEnv = async () => {
const prepareManagedOpenCodeEnv = async ({ includeControl = true, includeWeb = true } = {}) => {
const port = getActivePort();
if (!Number.isInteger(port) || port <= 0) {
throw new Error('OpenChamber listener port is unavailable for managed tool injection');
}
if (!includeControl && !includeWeb) {
throw new Error('At least one OpenChamber managed tool must be enabled to inject the plugin');
}
await fsPromises.mkdir(pluginDirectory, { recursive: true });
await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
await fsPromises.writeFile(pluginPath, createPluginSource({ includeControl, includeWeb }), { mode: 0o600 });
activeToken = crypto.randomBytes(32).toString('base64url');
const pluginUrl = pathToFileURL(pluginPath).href;
return {
@@ -114,6 +114,92 @@ describe('managed agent tool runtime', () => {
expect(source).not.toContain(preparedEnv.OPENCHAMBER_AGENT_TOOL_TOKEN);
});
it('emits both tools, each carrying only its own actions and inputs', async () => {
const { runtime, dataDir } = await createRuntime();
await runtime.prepareManagedOpenCodeEnv();
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?both=${Date.now()}`);
const { tool } = await pluginModule.OpenChamberPlugin();
const controlActions = tool.openchamber.args.action.enum;
const webActions = tool.openchamber_web.args.action.enum;
expect(webActions).toContain('browser.open');
expect(controlActions).not.toContain('browser.open');
expect(webActions).not.toContain('session.create');
// Turning one tool off has to remove its inputs too, not just its actions.
expect(Object.keys(tool.openchamber_web.args.parameters.properties)).toContain('url');
expect(Object.keys(tool.openchamber.args.parameters.properties)).not.toContain('url');
expect(Object.keys(tool.openchamber.args.parameters.properties)).toContain('sessionId');
});
it('accepts inputs passed beside the action, not only inside parameters', async () => {
const { runtime, dataDir } = await createRuntime();
const prepared = await runtime.prepareManagedOpenCodeEnv();
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?flat=${Date.now()}`);
const { tool } = await pluginModule.OpenChamberPlugin();
const sent = [];
const originalFetch = globalThis.fetch;
const originalUrl = process.env.OPENCHAMBER_AGENT_TOOL_URL;
const originalToken = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN;
process.env.OPENCHAMBER_AGENT_TOOL_URL = prepared.OPENCHAMBER_AGENT_TOOL_URL;
process.env.OPENCHAMBER_AGENT_TOOL_TOKEN = prepared.OPENCHAMBER_AGENT_TOOL_TOKEN;
globalThis.fetch = async (_endpoint, init) => {
sent.push(JSON.parse(init.body));
return new Response(JSON.stringify({ schemaVersion: 1, ok: true, action: 'browser.open', data: {} }));
};
const context = { directory: '/work/project', abort: new AbortController().signal, metadata: () => {} };
try {
// The shape a model actually produced: url and viewport next to action.
await tool.openchamber_web.execute(
{ action: 'browser.open', url: 'https://example.test', viewport: 'mobile' },
context,
);
// The documented shape must keep working, and win when both are present.
await tool.openchamber_web.execute(
{ action: 'browser.open', url: 'https://ignored.test', parameters: { url: 'https://example.test/nested' } },
context,
);
// Both tools come from one template, so session control accepts it too.
await tool.openchamber.execute(
{ action: 'session.messages', sessionId: 'ses_1', limit: 3 },
context,
);
} finally {
globalThis.fetch = originalFetch;
process.env.OPENCHAMBER_AGENT_TOOL_URL = originalUrl;
process.env.OPENCHAMBER_AGENT_TOOL_TOKEN = originalToken;
}
expect(sent[0].input).toEqual({ action: 'browser.open', url: 'https://example.test', viewport: 'mobile' });
expect(sent[1].input.url).toBe('https://example.test/nested');
expect(sent[2].input).toEqual({ action: 'session.messages', sessionId: 'ses_1', limit: 3 });
});
it('omits a tool the user turned off', async () => {
const { runtime, dataDir } = await createRuntime();
await runtime.prepareManagedOpenCodeEnv({ includeControl: false, includeWeb: true });
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?web=${Date.now()}`);
const { tool } = await pluginModule.OpenChamberPlugin();
expect(Object.keys(tool)).toEqual(['openchamber_web']);
});
it('refuses to inject a plugin with no tools in it', async () => {
const { runtime } = await createRuntime();
let failed = false;
try {
await runtime.prepareManagedOpenCodeEnv({ includeControl: false, includeWeb: false });
} catch {
failed = true;
}
expect(failed).toBe(true);
});
it('executes actions through the shared control service', async () => {
const executeAction = vi.fn(async () => ({ projects: [] }));
const { runtime } = await createRuntime({ executeAction });
@@ -0,0 +1,50 @@
# Browser Control Broker
## Purpose
This module carries agent browser actions from the server to the client that
owns the in-app browser view, and the result back. The browser lives in a
renderer, not in the server process, so the server can never act on a page
itself; it can only ask and wait.
## Boundaries
- `broker.js` owns request lifetime: it publishes one action through the
injected `emitRequest`, holds the pending request, and settles it on a client
result, a timeout, or an abort signal. It knows nothing about transports.
- `routes.js` is the result callback (`POST /api/browser-control/result`). It
validates the envelope and hands the outcome to the broker.
- `../../index.js` supplies `emitRequest`, which writes the request to the
OpenChamber SSE clients and returns how many were reached.
- `../openchamber-control/service.js` is the only caller. It maps the
`browser.*` actions of the `openchamber_web` tool onto `broker.request()` and
owns their parameter validation.
- The client half is `packages/ui/src/lib/browser/controlClient.ts`, which
registers the mounted browser pane as the one responder.
## Invariants
- Capability belongs to the connection, not to configuration. A client declares
it can drive a page by opening its event stream with `browser=1`, which only
a Chromium host does; the flag lives and dies with that connection, so there
is no setting to enable and no restart to remember.
- `emitRequest` counts only clients that can serve the action. `browser.open`
needs any client, because opening a tab is what creates a view; every other
action needs a declared-capable one.
- Exactly one client performs a request. The broadcast reaches everyone who
could serve it, so a client claims the request over
`POST /api/browser-control/claim` and acts only if granted; the first claim
wins and every other client does nothing. Deciding by whose result arrives
first would be too late, because by then each of them has already clicked.
A claim for a settled request is refused for the same reason.
- Nobody listening is answered immediately with a 503 describing the
environment, never by blocking for the full timeout. A blocked wait followed
by a timeout cannot be told apart from a page that hung.
- A client that accepted a request and then disappeared still times out.
Assuming success would report a page interaction that never happened.
- A result for an unknown request id is accepted with `matched: false`, not an
error: a client answering after the timeout has behaved correctly.
- The result route parses its own body. This server has no global body parser,
and a missing one silently turns every answer into an agent-visible timeout.
- Request payload limits are sized for a page snapshot (visible text plus every
interactive element), not for a control message.
@@ -0,0 +1,154 @@
/**
* Request/response broker between the agent tool and the in-app browser.
*
* The browser lives in the renderer, not the server, so the server cannot act
* on a page directly. It publishes a request over the existing OpenChamber
* event stream and waits for the client that owns the browser view to post the
* result back.
*
* The request goes to every client that could serve it, because the server
* cannot know which one is showing a page. Exactly one must act on it, so a
* client claims the request before touching anything and only the first claim
* is granted. Without that, two connected desktop clients would both click, and
* the losing one's late result would not undo what it had already done.
*
* Two failure modes matter and are handled explicitly rather than as timeouts:
*
* - No client is listening. The agent is told immediately that the browser is
* not open, instead of blocking for the full timeout and then reporting
* something ambiguous.
* - The client accepted the request and then went away. That still times out,
* because the alternative — assuming success — would be a lie.
*/
const DEFAULT_TIMEOUT_MS = 20_000;
const MAX_TIMEOUT_MS = 120_000;
export class BrowserControlError extends Error {
constructor(message, status = 400) {
super(message);
this.name = 'BrowserControlError';
this.status = status;
}
}
export const createBrowserControlBroker = ({
emitRequest,
createId,
setTimer = setTimeout,
clearTimer = clearTimeout,
} = {}) => {
if (typeof emitRequest !== 'function') {
throw new TypeError('emitRequest is required');
}
const pending = new Map();
const settle = (requestId, outcome) => {
const entry = pending.get(requestId);
if (!entry) return false;
pending.delete(requestId);
clearTimer(entry.timer);
entry.finish(outcome);
return true;
};
return {
/** Number of requests still awaiting a client response. */
get pendingCount() {
return pending.size;
},
/**
* Publishes one browser action and resolves with the client's result.
* Rejects with a BrowserControlError the agent can act on.
*/
request(action, parameters = {}, { timeoutMs = DEFAULT_TIMEOUT_MS, signal } = {}) {
const requestId = typeof createId === 'function' ? createId() : `browser-${Date.now()}-${pending.size}`;
const boundedTimeout = Math.min(Math.max(1_000, Number(timeoutMs) || DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
const listenerCount = emitRequest({ requestId, action, parameters });
if (!listenerCount) {
// Written for the agent reading it, not the user: state what this
// environment can do, and leave deciding whether it matters to the
// caller rather than handing it an instruction it cannot carry out.
return Promise.reject(new BrowserControlError(
'No OpenChamber client connected here can control a page. Reading and '
+ 'interacting with a page works when OpenChamber runs as its desktop '
+ 'application; a web browser tab can display a page but cannot be '
+ 'driven. Nothing was changed. Mention this to the user only if it '
+ 'affects what they asked for.',
503,
));
}
return new Promise((resolve, reject) => {
const finish = (outcome) => {
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
if (outcome.ok) resolve(outcome.data ?? null);
else reject(new BrowserControlError(outcome.message || 'Browser action failed', outcome.status || 400));
};
const onAbort = signal
? () => settle(requestId, { ok: false, message: 'Browser action was cancelled', status: 499 })
: null;
if (signal) {
if (signal.aborted) {
reject(new BrowserControlError('Browser action was cancelled', 499));
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
const timer = setTimer(() => {
settle(requestId, {
ok: false,
message: `The in-app browser did not respond within ${Math.round(boundedTimeout / 1000)}s`,
status: 504,
});
}, boundedTimeout);
pending.set(requestId, { finish, timer, claimed: false });
});
},
/**
* Grants the right to perform one request, to one client.
*
* The first caller wins; everyone else is told no and must do nothing. An
* unknown id is also a refusal: the request has already been settled, and
* acting on it now would change a page nobody is waiting on.
*/
claim(requestId) {
if (typeof requestId !== 'string' || !requestId) return false;
const entry = pending.get(requestId);
if (!entry || entry.claimed) return false;
entry.claimed = true;
return true;
},
/**
* Accepts a result posted by the client. Returns false for an unknown id,
* which is the normal outcome for a response that lost a race with the
* timeout and must not be treated as an error.
*/
resolve(requestId, result) {
if (typeof requestId !== 'string' || !requestId) return false;
if (result && result.ok === true) {
return settle(requestId, { ok: true, data: result.data ?? null });
}
return settle(requestId, {
ok: false,
message: typeof result?.error === 'string' && result.error ? result.error : 'Browser action failed',
status: 400,
});
},
/** Fails everything in flight, e.g. when the owning client disconnects. */
rejectAll(message) {
for (const requestId of [...pending.keys()]) {
settle(requestId, { ok: false, message, status: 503 });
}
},
};
};
@@ -0,0 +1,191 @@
import { describe, expect, test } from 'bun:test';
import { BrowserControlError, createBrowserControlBroker } from './broker.js';
const createBroker = (options = {}) => {
const emitted = [];
let sequence = 0;
const broker = createBrowserControlBroker({
emitRequest: (payload) => {
emitted.push(payload);
return options.listeners ?? 1;
},
createId: () => {
sequence += 1;
return `req-${sequence}`;
},
...options.overrides,
});
return { broker, emitted };
};
describe('browser control broker', () => {
test('resolves with the data the client posted back', async () => {
const { broker, emitted } = createBroker();
const inflight = broker.request('browser.snapshot', {});
expect(emitted[0]?.action).toBe('browser.snapshot');
broker.resolve(emitted[0].requestId, { ok: true, data: { url: 'http://localhost:5173/' } });
expect(await inflight).toEqual({ url: 'http://localhost:5173/' });
});
test('fails fast when no client is connected instead of blocking', async () => {
const { broker } = createBroker({ listeners: 0 });
await expect(broker.request('browser.open', { url: 'http://a/' })).rejects.toThrow(BrowserControlError);
});
test('describes the environment rather than telling the agent what to do', async () => {
const { broker } = createBroker({ listeners: 0 });
try {
await broker.request('browser.snapshot', {});
throw new Error('expected rejection');
} catch (error) {
expect(error.status).toBe(503);
// The agent reads this, not the user: it must state the limitation and
// where the capability exists, without issuing an instruction the agent
// cannot carry out.
expect(error.message).toContain('desktop application');
expect(error.message).toContain('Nothing was changed');
expect(error.message).not.toContain('Ask the user to open');
}
});
test('surfaces a client-reported failure with its message', async () => {
const { broker, emitted } = createBroker();
const inflight = broker.request('browser.click', { selector: '#missing' });
broker.resolve(emitted[0].requestId, { ok: false, error: 'No element matches #missing' });
await expect(inflight).rejects.toThrow('No element matches #missing');
});
test('times out when the client accepted the request and never answered', async () => {
let fire = null;
const { broker } = createBroker({
overrides: {
setTimer: (callback) => { fire = callback; return 1; },
clearTimer: () => {},
},
});
const inflight = broker.request('browser.snapshot', {}, { timeoutMs: 5_000 });
fire();
await expect(inflight).rejects.toThrow('did not respond within 5s');
});
test('ignores a late response that lost the race with the timeout', async () => {
let fire = null;
const { broker, emitted } = createBroker({
overrides: {
setTimer: (callback) => { fire = callback; return 1; },
clearTimer: () => {},
},
});
const inflight = broker.request('browser.snapshot', {});
fire();
await expect(inflight).rejects.toThrow();
expect(broker.resolve(emitted[0].requestId, { ok: true, data: {} })).toBe(false);
});
test('rejects an unknown request id without throwing', () => {
const { broker } = createBroker();
expect(broker.resolve('nope', { ok: true })).toBe(false);
expect(broker.resolve('', { ok: true })).toBe(false);
});
test('clears pending state once a request settles', async () => {
const { broker, emitted } = createBroker();
const inflight = broker.request('browser.snapshot', {});
expect(broker.pendingCount).toBe(1);
broker.resolve(emitted[0].requestId, { ok: true, data: null });
await inflight;
expect(broker.pendingCount).toBe(0);
});
test('fails everything in flight when the owning client disconnects', async () => {
const { broker } = createBroker();
const inflight = broker.request('browser.snapshot', {});
broker.rejectAll('The OpenChamber client disconnected');
await expect(inflight).rejects.toThrow('disconnected');
expect(broker.pendingCount).toBe(0);
});
test('propagates cancellation from the caller', async () => {
const { broker } = createBroker();
const controller = new AbortController();
const inflight = broker.request('browser.snapshot', {}, { signal: controller.signal });
controller.abort();
await expect(inflight).rejects.toThrow('cancelled');
});
test('rejects immediately when the caller is already cancelled', async () => {
const { broker } = createBroker();
const controller = new AbortController();
controller.abort();
await expect(broker.request('browser.snapshot', {}, { signal: controller.signal })).rejects.toThrow('cancelled');
});
});
/**
* Whether a page can be driven depends on which client is connected, not on the
* server: a desktop shell and a browser tab can be attached to one server at
* once, and either may arrive or leave at any moment. The broker is told how
* many clients could actually perform each action.
*/
describe('client capability', () => {
const createCapabilityBroker = (capableFor) => {
const emitted = [];
let sequence = 0;
const broker = createBrowserControlBroker({
emitRequest: (payload) => {
emitted.push(payload);
return capableFor(payload.action);
},
createId: () => { sequence += 1; return `req-${sequence}`; },
});
return { broker, emitted };
};
test('opening a page works with a client that cannot drive one', async () => {
// A browser tab can display a page even though it cannot be controlled.
const { broker, emitted } = createCapabilityBroker((action) => (action === 'browser.open' ? 1 : 0));
const inflight = broker.request('browser.open', { url: 'http://localhost:3000/' });
broker.resolve(emitted[0].requestId, { ok: true, data: { opened: true } });
expect(await inflight).toEqual({ opened: true });
});
test('driving a page fails immediately when no client can', async () => {
const { broker } = createCapabilityBroker((action) => (action === 'browser.open' ? 1 : 0));
await expect(broker.request('browser.click', { selector: '#a' })).rejects.toThrow('desktop application');
});
test('driving a page works as soon as a capable client is connected', async () => {
// No restart, no setting: a desktop client attaching is enough.
const { broker, emitted } = createCapabilityBroker(() => 1);
const inflight = broker.request('browser.snapshot', {});
broker.resolve(emitted[0].requestId, { ok: true, data: { url: 'http://localhost:3000/' } });
expect(await inflight).toEqual({ url: 'http://localhost:3000/' });
});
});
describe('one request, one performer', () => {
test('grants the request to the first claimant and refuses the rest', async () => {
const broker = createBrowserControlBroker({ emitRequest: () => 2, createId: () => 'req-1' });
const pending = broker.request('browser.click', { selector: 'button' });
expect(broker.claim('req-1')).toBe(true);
// A second desktop client is told no, so it never clicks.
expect(broker.claim('req-1')).toBe(false);
broker.resolve('req-1', { ok: true, data: { clicked: true } });
await expect(pending).resolves.toEqual({ clicked: true });
});
test('refuses a claim for a request that is already over', () => {
const broker = createBrowserControlBroker({ emitRequest: () => 1, createId: () => 'req-1' });
const pending = broker.request('browser.click', {});
broker.resolve('req-1', { ok: true, data: null });
void pending.catch(() => undefined);
// Acting now would change a page nobody is waiting on.
expect(broker.claim('req-1')).toBe(false);
expect(broker.claim('unknown')).toBe(false);
});
});
@@ -0,0 +1,48 @@
/**
* Result callback for in-app browser actions.
*
* The client that owns the browser view posts here with the outcome of a
* request it received over the event stream. Only the request id is trusted to
* correlate; an unknown id is accepted with `matched: false` rather than an
* error, because a client answering after a timeout has done nothing wrong.
*/
export function registerBrowserControlRoutes(app, { express, broker }) {
// Claiming is separate from answering so that a client learns whether it may
// act *before* it acts. Deciding by whose result arrives first would be too
// late: by then every client has already clicked.
app.post('/api/browser-control/claim', express.json({ limit: '4kb' }), (req, res) => {
const requestId = typeof req.body?.requestId === 'string' ? req.body.requestId.trim() : '';
if (!requestId) {
res.status(400).json({ error: 'requestId is required' });
return;
}
res.json({ granted: broker.claim(requestId) });
});
// This server attaches body parsing per route rather than globally. Without
// it `req.body` is undefined here, the client's result is rejected, and the
// agent sees an unexplained timeout instead of its answer. A page snapshot
// carries the visible text plus every interactive element, so the limit is
// sized for that rather than for a small control message.
app.post('/api/browser-control/result', express.json({ limit: '2mb' }), (req, res) => {
const body = req.body;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'A JSON body is required' });
return;
}
const requestId = typeof body.requestId === 'string' ? body.requestId.trim() : '';
if (!requestId) {
res.status(400).json({ error: 'requestId is required' });
return;
}
const matched = broker.resolve(requestId, {
ok: body.ok === true,
data: body.data ?? null,
error: typeof body.error === 'string' ? body.error : '',
});
res.json({ matched });
});
}
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createBrowserControlBroker } from './broker.js';
import { registerBrowserControlRoutes } from './routes.js';
/**
* These run against a real Express app on purpose. This server attaches body
* parsing per route, so a route that forgets it still *registers* fine and only
* fails when a client posts to it — which surfaces to the agent as an
* unexplained timeout, nowhere near the cause.
*/
const createApp = ({ listeners = 1 } = {}) => {
const emitted = [];
let sequence = 0;
const broker = createBrowserControlBroker({
emitRequest: (payload) => {
emitted.push(payload);
return listeners;
},
createId: () => {
sequence += 1;
return `req-${sequence}`;
},
});
const app = express();
registerBrowserControlRoutes(app, { express, broker });
return { app, broker, emitted };
};
describe('browser control result route', () => {
it('parses a posted JSON body and resolves the waiting request', async () => {
const { app, broker, emitted } = createApp();
const inflight = broker.request('browser.snapshot', {});
await request(app)
.post('/api/browser-control/result')
.send({ requestId: emitted[0].requestId, ok: true, data: { url: 'http://localhost:3000/' } })
.expect(200, { matched: true });
expect(await inflight).toEqual({ url: 'http://localhost:3000/' });
});
it('accepts a snapshot large enough to carry a real page', async () => {
const { app, broker, emitted } = createApp();
const inflight = broker.request('browser.snapshot', {});
const data = {
url: 'http://localhost:3000/',
text: 'x'.repeat(200_000),
elements: Array.from({ length: 120 }, (_, index) => ({
selector: `div:nth-of-type(${index})`,
label: 'y'.repeat(100),
})),
};
await request(app)
.post('/api/browser-control/result')
.send({ requestId: emitted[0].requestId, ok: true, data })
.expect(200, { matched: true });
const result = await inflight;
expect(result.text).toHaveLength(200_000);
expect(result.elements).toHaveLength(120);
});
it('propagates a client-reported failure', async () => {
const { app, broker, emitted } = createApp();
// Capture the outcome before posting: the rejection lands while the POST is
// still in flight, and an unattached handler surfaces as an unhandled one.
const outcome = broker.request('browser.click', { selector: '#nope' })
.then(() => null, (error) => error);
await request(app)
.post('/api/browser-control/result')
.send({ requestId: emitted[0].requestId, ok: false, error: 'No element matches #nope' })
.expect(200, { matched: true });
expect((await outcome)?.message).toBe('No element matches #nope');
});
it('reports matched: false for a response that arrived after the timeout', async () => {
const { app } = createApp();
await request(app)
.post('/api/browser-control/result')
.send({ requestId: 'expired', ok: true, data: {} })
.expect(200, { matched: false });
});
it('rejects a body with no request id', async () => {
const { app } = createApp();
await request(app)
.post('/api/browser-control/result')
.send({ ok: true })
.expect(400);
});
it('rejects a body that is not an object', async () => {
const { app } = createApp();
await request(app)
.post('/api/browser-control/result')
.set('Content-Type', 'application/json')
.send('"just-a-string"')
.expect(400);
});
});
@@ -0,0 +1,203 @@
/**
* Parsers for listening-socket enumeration.
*
* Two platforms, two formats, one shape out. Both parsers are pure so the
* fiddly parts — grouped records, IPv6 brackets, wildcard binds — are covered
* by tests instead of by running the tools.
*/
/** Hosts that mean "this machine" when a socket reports its bind address. */
const LOOPBACK_TOKENS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
/** Wildcard binds are reachable over loopback too. */
const WILDCARD_TOKENS = new Set(['*', '0.0.0.0', '[::]', '::']);
/**
* Ports that are listening but are never the thing a user wants to preview.
* Kept deliberately short: guessing too aggressively hides real dev servers.
*/
const IGNORED_PORTS = new Set([
22, // ssh
53, // dns
445, // smb
631, // cups
5432, // postgres
3306, // mysql
6379, // redis
27017, // mongodb
9229, // node inspector
]);
const splitHostPort = (value) => {
const raw = String(value || '').trim();
if (!raw) return null;
// IPv6 arrives bracketed: [::1]:5173
if (raw.startsWith('[')) {
const close = raw.indexOf(']');
if (close === -1) return null;
const host = raw.slice(0, close + 1);
const rest = raw.slice(close + 1);
if (!rest.startsWith(':')) return null;
return { host, port: rest.slice(1) };
}
const separator = raw.lastIndexOf(':');
if (separator === -1) return null;
return { host: raw.slice(0, separator), port: raw.slice(separator + 1) };
};
const toPort = (value) => {
const port = Number.parseInt(String(value || '').trim(), 10);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
};
/**
* True when a bind address can be reached from this machine over loopback.
* A socket bound to a specific LAN address only is intentionally excluded:
* `http://localhost:<port>` would not reach it.
*/
export const isLocallyReachableHost = (host) => {
const value = String(host || '').trim().toLowerCase();
return LOOPBACK_TOKENS.has(value) || WILDCARD_TOKENS.has(value);
};
const isIgnoredDevPort = (port) => IGNORED_PORTS.has(port);
/**
* Parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn`.
*
* The `-F` format emits one field per line, prefixed by a letter, and is
* stateful: `p`/`c` lines open a process record and every following `n` line
* belongs to it until the next `p`. A single process commonly reports the same
* port twice (IPv4 and IPv6), so results are de-duplicated by port.
*/
export const parseLsofListeners = (output) => {
const byPort = new Map();
let pid = null;
let command = '';
for (const line of String(output || '').split('\n')) {
if (!line) continue;
const tag = line[0];
const value = line.slice(1);
if (tag === 'p') {
const parsedPid = Number.parseInt(value, 10);
pid = Number.isInteger(parsedPid) ? parsedPid : null;
command = '';
continue;
}
if (tag === 'c') {
command = value.trim();
continue;
}
if (tag !== 'n') continue;
// `n` values look like `*:5173`, `127.0.0.1:5173`, or `[::1]:5173`.
// Established sockets contain `->`; LISTEN filtering should exclude them,
// but the guard keeps a mixed invocation honest.
if (value.includes('->')) continue;
const parsed = splitHostPort(value);
if (!parsed) continue;
const port = toPort(parsed.port);
if (port === null) continue;
if (!isLocallyReachableHost(parsed.host)) continue;
const existing = byPort.get(port);
if (existing && existing.pid !== null) continue;
byPort.set(port, { port, pid, command });
}
return [...byPort.values()].sort((left, right) => left.port - right.port);
};
/**
* Parses `netstat -ano -p TCP` on Windows, where no per-process command name is
* available without a second call; `command` stays empty and callers fall back
* to the port alone.
*/
export const parseNetstatListeners = (output) => {
const byPort = new Map();
for (const line of String(output || '').split('\n')) {
const parts = line.trim().split(/\s+/);
if (parts.length < 4) continue;
if (!/^tcp$/i.test(parts[0])) continue;
if (!/^LISTENING$/i.test(parts[3])) continue;
const parsed = splitHostPort(parts[1]);
if (!parsed) continue;
const port = toPort(parsed.port);
if (port === null) continue;
if (!isLocallyReachableHost(parsed.host)) continue;
const pid = Number.parseInt(parts[4] ?? '', 10);
if (byPort.has(port)) continue;
byPort.set(port, { port, pid: Number.isInteger(pid) ? pid : null, command: '' });
}
return [...byPort.values()].sort((left, right) => left.port - right.port);
};
/**
* Narrows raw listeners to the ones worth offering as a preview target.
*
* `ownPorts` removes OpenChamber's own listeners — offering the user a preview
* of the app they are already looking at is pure noise.
*/
export const selectDevServerCandidates = (listeners, { ownPorts = [], ownPids = [] } = {}) => {
const excludedPorts = new Set(ownPorts.filter((port) => Number.isInteger(port)));
const excludedPids = new Set(ownPids.filter((pid) => Number.isInteger(pid)));
return listeners.filter((entry) => {
if (excludedPorts.has(entry.port)) return false;
if (entry.pid !== null && excludedPids.has(entry.pid)) return false;
if (isIgnoredDevPort(entry.port)) return false;
return true;
});
};
/** Linux reports LISTEN as state 0A in /proc/net/tcp. */
const PROC_STATE_LISTEN = '0A';
/** Wildcard binds, as /proc writes them: IPv4 0.0.0.0 and IPv6 :: */
const PROC_WILDCARD_ADDRESSES = new Set(['00000000', '00000000000000000000000000000000']);
/** Loopback: 127.0.0.1 (little-endian per word) and ::1 */
const PROC_LOOPBACK_ADDRESSES = new Set(['0100007F', '00000000000000000000000001000000']);
/**
* Parses `/proc/net/tcp` and `/proc/net/tcp6`.
*
* The fallback for hosts without `lsof`, which is most containers — and a
* deployed OpenChamber is exactly where a dev server needs discovering. Reads a
* kernel file rather than shelling out, so it cannot be defeated by a missing
* binary or a stripped PATH.
*
* No process name or pid: mapping a socket to its owner means walking every
* /proc/<pid>/fd, which is far more work than the label is worth.
*/
export const parseProcNetTcpListeners = (output) => {
const byPort = new Map();
for (const line of String(output || '').split('\n')) {
const parts = line.trim().split(/\s+/);
// sl, local_address, rem_address, st, ...
if (parts.length < 4) continue;
if (parts[3] !== PROC_STATE_LISTEN) continue;
const [address, portHex] = String(parts[1] || '').split(':');
if (!address || !portHex) continue;
const normalizedAddress = address.toUpperCase();
if (!PROC_WILDCARD_ADDRESSES.has(normalizedAddress) && !PROC_LOOPBACK_ADDRESSES.has(normalizedAddress)) {
continue;
}
const port = Number.parseInt(portHex, 16);
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
if (byPort.has(port)) continue;
byPort.set(port, { port, pid: null, command: '' });
}
return [...byPort.values()].sort((left, right) => left.port - right.port);
};
@@ -0,0 +1,174 @@
import { describe, expect, test } from 'bun:test';
import {
isLocallyReachableHost,
parseLsofListeners,
parseProcNetTcpListeners,
parseNetstatListeners,
selectDevServerCandidates,
} from './parse.js';
describe('lsof listener parsing', () => {
test('associates every socket with the process record above it', () => {
const output = [
'p1234', 'cnode', 'n*:5173',
'p5678', 'cpython3', 'n127.0.0.1:8000',
].join('\n');
expect(parseLsofListeners(output)).toEqual([
{ port: 5173, pid: 1234, command: 'node' },
{ port: 8000, pid: 5678, command: 'python3' },
]);
});
test('keeps one entry when a process binds the same port on IPv4 and IPv6', () => {
const output = ['p1234', 'cnode', 'n*:5173', 'n[::1]:5173'].join('\n');
expect(parseLsofListeners(output)).toEqual([{ port: 5173, pid: 1234, command: 'node' }]);
});
test('unwraps bracketed IPv6 addresses', () => {
expect(parseLsofListeners(['p1', 'cnode', 'n[::1]:3000'].join('\n')))
.toEqual([{ port: 3000, pid: 1, command: 'node' }]);
});
test('skips sockets bound only to a LAN address, which localhost cannot reach', () => {
expect(parseLsofListeners(['p1', 'cnode', 'n192.168.1.10:5173'].join('\n'))).toEqual([]);
});
test('skips established connections that slipped past the LISTEN filter', () => {
expect(parseLsofListeners(['p1', 'cnode', 'n127.0.0.1:5173->127.0.0.1:60123'].join('\n')))
.toEqual([]);
});
test('returns sorted results', () => {
const output = ['p1', 'cnode', 'n*:9000', 'n*:3000', 'n*:5173'].join('\n');
expect(parseLsofListeners(output).map((entry) => entry.port)).toEqual([3000, 5173, 9000]);
});
test('tolerates empty and malformed output rather than throwing', () => {
expect(parseLsofListeners('')).toEqual([]);
expect(parseLsofListeners(null)).toEqual([]);
expect(parseLsofListeners('garbage\nn:\nnnotaport')).toEqual([]);
});
test('rejects out-of-range ports', () => {
expect(parseLsofListeners(['p1', 'cnode', 'n*:70000', 'n*:0'].join('\n'))).toEqual([]);
});
});
describe('netstat listener parsing', () => {
const output = [
'Active Connections',
'',
' Proto Local Address Foreign Address State PID',
' TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 4242',
' TCP 127.0.0.1:8000 0.0.0.0:0 LISTENING 9001',
' TCP 192.168.0.5:9999 0.0.0.0:0 LISTENING 9002',
' TCP 127.0.0.1:5173 127.0.0.1:60123 ESTABLISHED 9003',
].join('\n');
test('takes listening loopback and wildcard sockets with their pid', () => {
expect(parseNetstatListeners(output)).toEqual([
{ port: 5173, pid: 4242, command: '' },
{ port: 8000, pid: 9001, command: '' },
]);
});
test('ignores established connections and LAN-only binds', () => {
const ports = parseNetstatListeners(output).map((entry) => entry.port);
expect(ports).not.toContain(9999);
});
test('tolerates empty output', () => {
expect(parseNetstatListeners('')).toEqual([]);
});
});
describe('host reachability', () => {
test('accepts loopback and wildcard binds', () => {
for (const host of ['127.0.0.1', 'localhost', '[::1]', '*', '0.0.0.0', '[::]']) {
expect(isLocallyReachableHost(host)).toBe(true);
}
});
test('rejects a specific LAN address', () => {
expect(isLocallyReachableHost('192.168.1.4')).toBe(false);
});
});
describe('candidate selection', () => {
const listeners = [
{ port: 5173, pid: 10, command: 'node' },
{ port: 5432, pid: 11, command: 'postgres' },
{ port: 4096, pid: 12, command: 'openchamber' },
{ port: 3000, pid: 13, command: 'node' },
];
test('drops OpenChamber own ports so the app never offers itself', () => {
const ports = selectDevServerCandidates(listeners, { ownPorts: [4096] }).map((entry) => entry.port);
expect(ports).toEqual([5173, 3000]);
});
test('drops sockets owned by our own process', () => {
const ports = selectDevServerCandidates(listeners, { ownPids: [13] }).map((entry) => entry.port);
expect(ports).toEqual([5173, 4096]);
});
test('drops well-known infrastructure ports that are never previewable', () => {
const ports = selectDevServerCandidates(listeners).map((entry) => entry.port);
expect(ports).not.toContain(5432);
});
test('keeps everything else, including unusual ports', () => {
const ports = selectDevServerCandidates([{ port: 12345, pid: 1, command: 'bun' }]).map((entry) => entry.port);
expect(ports).toEqual([12345]);
});
});
describe('proc net tcp parsing', () => {
const header = ' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode';
test('takes listening sockets on loopback and wildcard binds', () => {
const output = [
header,
' 0: 00000000:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000 100 0',
' 1: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12346 1 0000 100 0',
].join('\n');
expect(parseProcNetTcpListeners(output)).toEqual([
{ port: 3000, pid: null, command: '' },
{ port: 8080, pid: null, command: '' },
]);
});
test('ignores sockets that are not listening', () => {
const output = [
header,
' 0: 0100007F:1F90 0100007F:C350 01 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000 100 0',
].join('\n');
expect(parseProcNetTcpListeners(output)).toEqual([]);
});
test('ignores a bind to a specific LAN address', () => {
const output = [
header,
' 0: 0A00020F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000 100 0',
].join('\n');
expect(parseProcNetTcpListeners(output)).toEqual([]);
});
test('reads the IPv6 table, including ::1 and ::', () => {
const output = [
header,
' 0: 00000000000000000000000001000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 1 1 0 0 0',
' 1: 00000000000000000000000000000000:0BB8 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 1 1 0 0 0',
].join('\n');
expect(parseProcNetTcpListeners(output).map((entry) => entry.port)).toEqual([3000, 8080]);
});
test('tolerates an empty or malformed table', () => {
expect(parseProcNetTcpListeners('')).toEqual([]);
expect(parseProcNetTcpListeners(header)).toEqual([]);
expect(parseProcNetTcpListeners('garbage')).toEqual([]);
});
});
@@ -0,0 +1,135 @@
/**
* Dev-server discovery.
*
* Answers "what is listening on this machine that I could preview". The old
* approach guessed from `package.json` scripts, which told us what *could* be
* started, never what was actually running — so it was wrong exactly when the
* user needed it. Enumerating listening sockets reports the truth.
*
* Discovery is advisory. A failed scan reports failure; it never reports an
* empty list, because a caller cannot tell "nothing is running" from "the scan
* broke" and would render the wrong empty state.
*/
import fsPromises from 'node:fs/promises';
import {
parseLsofListeners,
parseNetstatListeners,
parseProcNetTcpListeners,
selectDevServerCandidates,
} from './parse.js';
const SCAN_TIMEOUT_MS = 2_500;
/** Enumeration is cheap but not free; a short cache absorbs panel re-renders. */
const CACHE_TTL_MS = 3_000;
const runCommand = (spawn, command, args, timeoutMs) => new Promise((resolve) => {
let child;
try {
child = spawn(command, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
} catch {
resolve(null);
return;
}
let stdout = '';
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { child.kill(); } catch { /* already exited */ }
resolve(value);
};
const timer = setTimeout(() => finish(null), timeoutMs);
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
child.on('error', () => finish(null));
child.on('close', (code) => finish(code === 0 || stdout ? stdout : null));
});
/**
* Reads the kernel's socket tables. Containers routinely ship without `lsof`,
* and a deployed OpenChamber is precisely where discovery has to work, so this
* is tried whenever the command is unavailable.
*/
const readProcListeners = async (readFile) => {
const tables = await Promise.all(['/proc/net/tcp', '/proc/net/tcp6'].map(
(path) => readFile(path, 'utf8').catch(() => null),
));
if (tables.every((table) => table === null)) return null;
const byPort = new Map();
for (const table of tables) {
if (table === null) continue;
for (const entry of parseProcNetTcpListeners(table)) {
if (!byPort.has(entry.port)) byPort.set(entry.port, entry);
}
}
return [...byPort.values()].sort((left, right) => left.port - right.port);
};
export const createDevServerScanner = ({ spawn, platform, readFile = fsPromises.readFile }) => {
let cache = null;
const scan = async () => {
const isWindows = platform === 'win32';
if (isWindows) {
const output = await runCommand(spawn, 'netstat', ['-ano', '-p', 'TCP'], SCAN_TIMEOUT_MS);
if (output === null) return { ok: false, reason: 'netstat-unavailable' };
return { ok: true, listeners: parseNetstatListeners(output) };
}
const output = await runCommand(spawn, 'lsof', ['-iTCP', '-sTCP:LISTEN', '-P', '-n', '-F', 'pcn'], SCAN_TIMEOUT_MS);
if (output !== null) return { ok: true, listeners: parseLsofListeners(output) };
const procListeners = await readProcListeners(readFile);
if (procListeners !== null) return { ok: true, listeners: procListeners };
return { ok: false, reason: 'no-listener-source' };
};
return {
/**
* @param {{ ownPorts?: number[] }} options
* @returns {Promise<{ ok: true, servers: Array<{ port: number, pid: number|null, command: string, url: string }> } | { ok: false, reason: string }>}
*/
async discover({ ownPorts = [] } = {}) {
const now = Date.now();
if (cache && now - cache.at < CACHE_TTL_MS) return cache.value;
const result = await scan();
if (!result.ok) {
// Not cached: a transient failure should not suppress the next attempt.
return result;
}
const servers = selectDevServerCandidates(result.listeners, {
ownPorts,
ownPids: [process.pid],
}).map((entry) => ({
...entry,
url: `http://localhost:${entry.port}/`,
}));
const value = { ok: true, servers };
cache = { at: now, value };
return value;
},
};
};
export function registerDevServerRoutes(app, { scanner, getOwnPorts }) {
app.get('/api/dev-servers', async (req, res) => {
try {
const ownPorts = typeof getOwnPorts === 'function' ? getOwnPorts() : [];
const result = await scanner.discover({ ownPorts: Array.isArray(ownPorts) ? ownPorts : [] });
if (!result.ok) {
res.status(503).json({ error: 'Port discovery is unavailable', reason: result.reason });
return;
}
res.json({ servers: result.servers });
} catch (error) {
res.status(500).json({ error: error?.message || 'Port discovery failed' });
}
});
}
@@ -0,0 +1,57 @@
# Dev Server Tunnel
## Purpose
This module carries raw TCP bytes between a desktop client and a dev server
running on the OpenChamber host, so a remote dev server can be opened in the
browser panel without anything being rewritten.
The page is served from a real origin at the root of its own host. That is the
whole design: absolute URLs resolve, cookies scope correctly, HMR sockets
connect, and developer tools behave as they do locally. No HTML, header, or
URL is inspected or modified, which is what the previous rewriting proxy did
and what made it fragile per framework.
## Boundaries
- `runtime.js` is the host end: it accepts the WebSocket upgrade at
`/api/dev-tunnel`, authenticates it, opens a TCP socket to the requested
local port, and pipes the two together.
- `client.js` is the local end: it binds a loopback listener on the user's
machine and pipes each accepted connection through one WebSocket. It lives in
this package because it needs a WebSocket client the package already depends
on; the desktop shell drives it over IPC.
- Port discovery is not owned here. `runtime.js` is given the reachable set by
the same dev-server discovery the user's own list is built from.
- The browser panel decides when to tunnel; this module never chooses a target.
`packages/ui/src/lib/browser/devTunnel.ts` owns that decision, including for
navigations the page starts itself: a tunnelled page that sends the view to
another loopback port means a port on the host, not on the user's machine.
## Invariants
- The reachable set is exactly what dev-server discovery offers the user, never
"any loopback port". Without that restriction an authenticated client could
dial arbitrary local services on the host — databases, admin panels, the
OpenCode API — through this socket.
- Authentication depends on whether the caller is a browser, and this is
deliberate rather than a relaxation:
- With an `Origin` header the request came from a browser context, and the
usual origin allowlist applies unchanged. That check is a CSRF defence: a
hostile page can make a browser open a WebSocket carrying ambient cookies,
and the origin is what exposes it.
- With no `Origin` the request must carry client-token auth. A browser cannot
reach this path — the WebSocket API always sends an origin and never lets a
page set an `Authorization` header — so this case is the desktop shell.
- Concurrency is capped per host, not per page, because one page load opens
many sockets.
- A connection that cannot be established fails the socket rather than holding
it open; a stalled connect is bounded by an explicit timeout, and so is the
WebSocket handshake. While it is pending the local socket is paused and its
buffered bytes are capped, so a local process writing into a stalled
handshake cannot grow the desktop app's memory.
- A tunnel that cannot be opened is reported to the panel, never replaced by the
plain loopback URL. On a remote instance that substitution would change which
machine answers and show local content under a remote address.
- Closing either end closes the other. A half-open pipe would leave the page
waiting on bytes that will never arrive.
@@ -0,0 +1,174 @@
/**
* Local end of the dev-server tunnel.
*
* Binds a loopback listener on this machine and pipes every connection to a
* dev server on the OpenChamber host. The point of binding a real local port —
* rather than serving the remote page under a path on some other origin — is
* that the page then has its own origin at the root of its own host. Absolute
* URLs resolve, cookies scope correctly, HMR sockets connect, and nothing has
* to be rewritten.
*
* Lives in the web package because it needs a WebSocket client, which this
* package already depends on; the desktop shell drives it over IPC.
*/
import net from 'node:net';
import { WebSocket } from 'ws';
/**
* What one connection may buffer while its WebSocket is still connecting.
*
* Enough for a request with generous headers, far short of a body worth
* holding: a local process could otherwise keep writing into a stalled
* handshake and grow the desktop app's memory without limit.
*/
const MAX_PENDING_BYTES = 256 * 1024;
/** A handshake that has not completed by now is not going to. */
const HANDSHAKE_TIMEOUT_MS = 15_000;
const toWebSocketUrl = (baseUrl, port) => {
const parsed = new URL('/api/dev-tunnel', baseUrl);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
parsed.searchParams.set('port', String(port));
return parsed.toString();
};
export const createDevTunnelClient = ({
logger = console,
handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS,
maxPendingBytes = MAX_PENDING_BYTES,
} = {}) => {
/** Keyed by `${baseUrl}|${remotePort}` so repeat opens reuse one listener. */
const tunnels = new Map();
const closeTunnel = (key) => {
const tunnel = tunnels.get(key);
if (!tunnel) return false;
tunnels.delete(key);
for (const socket of tunnel.sockets) {
try { socket.destroy(); } catch { /* already gone */ }
}
try { tunnel.server.close(); } catch { /* already closing */ }
return true;
};
return {
/**
* Opens (or reuses) a tunnel and resolves with the local port to browse.
* Rejects if the listener cannot bind; per-connection failures close only
* that connection, so one failed request cannot take the tunnel down.
*/
async open({ baseUrl, port, headers = {} }) {
const remotePort = Number.parseInt(String(port), 10);
if (!Number.isInteger(remotePort) || remotePort <= 0 || remotePort > 65535) {
throw new Error('A valid remote port is required');
}
const base = String(baseUrl || '').trim();
if (!base) throw new Error('A remote base URL is required');
const key = `${base}|${remotePort}`;
const existing = tunnels.get(key);
if (existing) return { localPort: existing.localPort, reused: true };
const target = toWebSocketUrl(base, remotePort);
const sockets = new Set();
const server = net.createServer((socket) => {
socket.setNoDelay(true);
sockets.add(socket);
const upstream = new WebSocket(target, { headers, perMessageDeflate: false });
upstream.binaryType = 'nodebuffer';
let pendingWrites = [];
let pendingBytes = 0;
const handshakeTimer = setTimeout(() => {
logger.warn?.(`[dev-tunnel] handshake timed out for port ${remotePort}`);
teardown();
}, handshakeTimeoutMs);
function teardown() {
clearTimeout(handshakeTimer);
pendingWrites = [];
pendingBytes = 0;
sockets.delete(socket);
try { socket.destroy(); } catch { /* already gone */ }
try { upstream.close(); } catch { /* already closing */ }
}
upstream.on('open', () => {
clearTimeout(handshakeTimer);
for (const chunk of pendingWrites) upstream.send(chunk);
pendingWrites = [];
pendingBytes = 0;
// The local end was held back while there was nowhere to put its
// bytes; there is somewhere now.
socket.resume();
});
upstream.on('message', (data) => {
if (socket.destroyed) return;
socket.write(data);
});
upstream.on('error', (error) => {
logger.warn?.(`[dev-tunnel] upstream failed for port ${remotePort}: ${error?.message || error}`);
teardown();
});
upstream.on('close', teardown);
socket.on('data', (chunk) => {
// Bytes can arrive before the WebSocket handshake completes; buffering
// them is what keeps the first HTTP request intact. The buffer is
// bounded, and the local end is paused rather than trusted to stop.
if (upstream.readyState === WebSocket.OPEN) {
upstream.send(chunk);
return;
}
if (upstream.readyState !== WebSocket.CONNECTING) return;
pendingWrites.push(chunk);
pendingBytes += chunk.length;
if (pendingBytes > maxPendingBytes) {
logger.warn?.(`[dev-tunnel] dropped a connection that buffered too much for port ${remotePort}`);
teardown();
return;
}
socket.pause();
});
socket.on('error', teardown);
socket.on('close', teardown);
});
const localPort = await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to bind a local tunnel port'));
return;
}
resolve(address.port);
});
});
server.on('error', (error) => {
logger.warn?.(`[dev-tunnel] listener error for port ${remotePort}: ${error?.message || error}`);
});
tunnels.set(key, { server, sockets, localPort, remotePort, baseUrl: base });
return { localPort, reused: false };
},
close({ baseUrl, port }) {
return closeTunnel(`${String(baseUrl || '').trim()}|${Number.parseInt(String(port), 10)}`);
},
/** Closes every tunnel; used when the desktop switches runtime or quits. */
closeAll() {
for (const key of [...tunnels.keys()]) closeTunnel(key);
},
list() {
return [...tunnels.values()].map(({ localPort, remotePort, baseUrl }) => ({ localPort, remotePort, baseUrl }));
},
};
};
@@ -0,0 +1,189 @@
/**
* Raw byte tunnel to a dev server running on the OpenChamber host.
*
* This is what lets a desktop client preview a dev server that lives on another
* machine without rewriting anything. The client binds its own local port and
* pipes it here; the page is then served from a real origin at the root of its
* own host, so absolute URLs, cookies, HMR sockets, and DevTools all behave
* exactly as they do locally. No HTML is inspected or modified.
*
* Security posture: the reachable set is the same list dev-server discovery
* offers the user, not "any loopback port". Without that restriction an
* authenticated client could dial arbitrary local services on the host —
* databases, admin panels, the OpenCode API — through this socket.
*
* Authentication differs from the browser-facing sockets on purpose. Those
* demand an allowed `Origin`, which is a CSRF defence: a hostile page can make
* a browser open a WebSocket carrying the user's ambient cookies, and the
* origin is what exposes it. This tunnel's client is the desktop shell, not a
* browser, and it authenticates with an explicit bearer token. So:
*
* - With an `Origin` header, the request came from a browser context and the
* usual origin check applies unchanged.
* - With no `Origin`, the request must carry client-token auth. A browser
* cannot reach this path: the WebSocket API always sends an origin and never
* lets a page set an `Authorization` header.
*/
import net from 'node:net';
import { WebSocketServer } from 'ws';
const DEV_TUNNEL_WS_PATH = '/api/dev-tunnel';
/** One page load opens many sockets; the cap is per host, not per page. */
const MAX_CONCURRENT_SOCKETS = 64;
const CONNECT_TIMEOUT_MS = 5_000;
const parseRequestedPort = (url) => {
try {
const parsed = new URL(String(url || ''), 'http://localhost');
if (parsed.pathname !== DEV_TUNNEL_WS_PATH) return null;
const port = Number.parseInt(parsed.searchParams.get('port') || '', 10);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
} catch {
return null;
}
};
export const isDevTunnelPath = (url) => {
try {
return new URL(String(url || ''), 'http://localhost').pathname === DEV_TUNNEL_WS_PATH;
} catch {
return false;
}
};
export function createDevTunnelRuntime({
server,
discoverDevServers,
uiAuthController,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
logger = console,
}) {
const wsServer = new WebSocketServer({ noServer: true });
let openSockets = 0;
/**
* A port is reachable only while discovery still reports it. Re-checked on
* every upgrade rather than cached, so a dev server that stops listening
* stops being reachable.
*/
const isAllowedPort = async (port) => {
const result = await discoverDevServers();
if (!result?.ok) return false;
return result.servers.some((entry) => entry.port === port);
};
wsServer.on('connection', (socket, req) => {
const port = parseRequestedPort(req.url);
if (port === null) {
socket.close(1008, 'Invalid port');
return;
}
openSockets += 1;
const upstream = net.connect({ host: '127.0.0.1', port });
upstream.setNoDelay(true);
let settled = false;
const teardown = () => {
if (settled) return;
settled = true;
openSockets -= 1;
try { upstream.destroy(); } catch { /* already gone */ }
try { socket.close(); } catch { /* already closing */ }
};
const connectTimer = setTimeout(() => {
if (!upstream.connecting) return;
logger.warn?.(`[dev-tunnel] timed out connecting to 127.0.0.1:${port}`);
teardown();
}, CONNECT_TIMEOUT_MS);
upstream.on('connect', () => clearTimeout(connectTimer));
upstream.on('data', (chunk) => {
if (socket.readyState !== socket.OPEN) return;
socket.send(chunk);
// Stop reading from the dev server while the socket drains, otherwise a
// fast response against a slow client buffers the whole body in memory.
if (socket.bufferedAmount > 1_000_000) {
upstream.pause();
const resume = () => {
if (socket.bufferedAmount > 1_000_000) {
setTimeout(resume, 20);
return;
}
upstream.resume();
};
setTimeout(resume, 20);
}
});
upstream.on('error', () => { clearTimeout(connectTimer); teardown(); });
upstream.on('close', () => { clearTimeout(connectTimer); teardown(); });
socket.on('message', (data) => {
if (upstream.destroyed) return;
upstream.write(data);
});
socket.on('close', teardown);
socket.on('error', teardown);
});
const upgradeHandler = (req, socket, head) => {
if (!isDevTunnelPath(req.url)) return;
void (async () => {
try {
if (uiAuthController?.enabled) {
const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: false });
if (!auth) {
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
return;
}
const hasOrigin = typeof req.headers?.origin === 'string' && req.headers.origin.trim() !== '';
if (hasOrigin) {
if (!await isRequestOriginAllowed(req)) {
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
return;
}
} else if (auth.type !== 'client') {
rejectWebSocketUpgrade(socket, 403, 'Client authentication required');
return;
}
}
const port = parseRequestedPort(req.url);
if (port === null) {
rejectWebSocketUpgrade(socket, 400, 'Invalid port');
return;
}
if (openSockets >= MAX_CONCURRENT_SOCKETS) {
rejectWebSocketUpgrade(socket, 503, 'Too many tunnel connections');
return;
}
if (!await isAllowedPort(port)) {
// Says which port, because the alternative is an empty response in
// the panel with nothing anywhere explaining why.
logger.warn?.(`[dev-tunnel] refused port ${port}: not reported by dev-server discovery`);
rejectWebSocketUpgrade(socket, 403, 'That port is not an available dev server');
return;
}
wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req));
} catch {
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
}
})();
};
server.on('upgrade', upgradeHandler);
return {
path: DEV_TUNNEL_WS_PATH,
get openSocketCount() {
return openSockets;
},
dispose() {
server.off('upgrade', upgradeHandler);
wsServer.close();
},
};
}
@@ -0,0 +1,282 @@
import { afterEach, describe, expect, test } from 'bun:test';
import http from 'node:http';
import net from 'node:net';
import { createDevTunnelClient } from './client.js';
import { createDevTunnelRuntime, isDevTunnelPath } from './runtime.js';
/**
* These exercise the real socket path end to end: a dev server, an OpenChamber
* host tunnelling to it, and a client binding a local port. Anything less would
* not prove the thing that matters — that a page loads over the tunnel exactly
* as it does locally.
*/
const started = [];
const listen = (server, host = '127.0.0.1') => new Promise((resolve) => {
server.listen(0, host, () => resolve(server.address().port));
});
const trackSockets = (server) => {
const sockets = new Set();
server.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
return sockets;
};
const stopServer = (server, sockets) => async () => {
for (const socket of sockets) {
socket.destroy();
}
await new Promise((resolve) => server.close(resolve));
};
const startDevServer = async (handler) => {
const server = http.createServer(handler);
const sockets = trackSockets(server);
const port = await listen(server);
started.push(stopServer(server, sockets));
return port;
};
const startHost = async ({ allowedPorts, auth = null, discoveryOk = true }) => {
const server = http.createServer((_req, res) => res.end('host'));
const sockets = trackSockets(server);
const port = await listen(server);
const runtime = createDevTunnelRuntime({
server,
discoverDevServers: async () => (discoveryOk
? {
ok: true,
servers: allowedPorts.map((value) => ({ port: value, url: `http://localhost:${value}/`, command: 'node', pid: 1 })),
}
: { ok: false, reason: 'no-listener-source' }),
uiAuthController: auth ?? { enabled: false },
isRequestOriginAllowed: async (req) => req.headers.origin === 'http://allowed.example',
rejectWebSocketUpgrade: (socket, status, message) => {
socket.write(`HTTP/1.1 ${status} ${message}\r\n\r\n`);
socket.destroy();
},
logger: { warn: () => {} },
});
started.push(async () => {
runtime.dispose();
await stopServer(server, sockets)();
});
return { port, baseUrl: `http://127.0.0.1:${port}`, runtime, sockets };
};
const httpGet = (port, path = '/') => new Promise((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path }, (response) => {
let body = '';
response.on('data', (chunk) => { body += chunk; });
response.on('end', () => resolve({ status: response.statusCode, body, headers: response.headers }));
});
request.on('error', reject);
request.setTimeout(5_000, () => request.destroy(new Error('timeout')));
});
afterEach(async () => {
while (started.length) {
const stop = started.pop();
await stop();
}
});
describe('dev tunnel path matching', () => {
test('only claims its own upgrade path', () => {
expect(isDevTunnelPath('/api/dev-tunnel?port=5173')).toBe(true);
expect(isDevTunnelPath('/api/terminal/ws')).toBe(false);
expect(isDevTunnelPath('')).toBe(false);
});
});
describe('dev tunnel end to end', () => {
test('serves the dev server through a local port, unmodified', async () => {
const devPort = await startDevServer((req, res) => {
res.setHeader('content-type', 'text/html');
res.setHeader('x-dev-header', 'kept');
res.end(`<html><body>path:${req.url}</body></html>`);
});
const host = await startHost({ allowedPorts: [devPort] });
const client = createDevTunnelClient({ logger: { warn: () => {} } });
started.push(() => client.closeAll());
const { localPort } = await client.open({ baseUrl: host.baseUrl, port: devPort });
const response = await httpGet(localPort, '/some/page?q=1');
expect(response.status).toBe(200);
expect(response.body).toBe('<html><body>path:/some/page?q=1</body></html>');
expect(response.headers['x-dev-header']).toBe('kept');
});
test('drops a connection that floods a handshake that never completes', async () => {
// A host that accepts the TCP connection and then says nothing: the
// WebSocket handshake hangs, which is when buffering could run away.
const stalled = net.createServer(() => {});
const stalledSockets = trackSockets(stalled);
const stalledPort = await listen(stalled);
started.push(stopServer(stalled, stalledSockets));
const client = createDevTunnelClient({
logger: { warn: () => {} },
handshakeTimeoutMs: 300,
});
started.push(() => client.closeAll());
const { localPort } = await client.open({ baseUrl: `http://127.0.0.1:${stalledPort}`, port: 4321 });
const closed = await new Promise((resolve) => {
const socket = net.createConnection({ port: localPort, host: '127.0.0.1' }, () => {
const chunk = Buffer.alloc(64 * 1024, 0x61);
const write = () => {
// Keep writing while the handshake hangs; the tunnel must stop this
// rather than hold every byte in the desktop app's memory.
if (socket.destroyed) return;
socket.write(chunk, () => setTimeout(write, 1));
};
write();
});
socket.on('close', () => resolve(true));
socket.on('error', () => resolve(true));
setTimeout(() => resolve(false), 3_000);
});
expect(closed).toBe(true);
});
test('reuses one listener for repeat opens of the same target', async () => {
const devPort = await startDevServer((_req, res) => res.end('ok'));
const host = await startHost({ allowedPorts: [devPort] });
const client = createDevTunnelClient({ logger: { warn: () => {} } });
started.push(() => client.closeAll());
const first = await client.open({ baseUrl: host.baseUrl, port: devPort });
const second = await client.open({ baseUrl: host.baseUrl, port: devPort });
expect(second.localPort).toBe(first.localPort);
expect(second.reused).toBe(true);
});
test('refuses a port discovery does not report, so it is not a loopback proxy', async () => {
const secret = await startDevServer((_req, res) => res.end('secret service'));
const host = await startHost({ allowedPorts: [] });
const client = createDevTunnelClient({ logger: { warn: () => {} } });
started.push(() => client.closeAll());
const { localPort } = await client.open({ baseUrl: host.baseUrl, port: secret });
await expect(httpGet(localPort, '/')).rejects.toThrow();
});
test('closing a tunnel frees its local port', async () => {
const devPort = await startDevServer((_req, res) => res.end('ok'));
const host = await startHost({ allowedPorts: [devPort] });
const client = createDevTunnelClient({ logger: { warn: () => {} } });
const { localPort } = await client.open({ baseUrl: host.baseUrl, port: devPort });
expect(client.close({ baseUrl: host.baseUrl, port: devPort })).toBe(true);
expect(client.list()).toEqual([]);
// The port is free again: binding it back succeeds.
const probe = net.createServer();
await new Promise((resolve, reject) => {
probe.once('error', reject);
probe.listen(localPort, '127.0.0.1', resolve);
});
await new Promise((resolve) => probe.close(resolve));
});
test('rejects an invalid remote port before binding anything', async () => {
const client = createDevTunnelClient({ logger: { warn: () => {} } });
await expect(client.open({ baseUrl: 'http://127.0.0.1:1', port: 0 })).rejects.toThrow('valid remote port');
await expect(client.open({ baseUrl: '', port: 5173 })).rejects.toThrow('base URL');
expect(client.list()).toEqual([]);
});
// Not covered here: recovery after a request the dev server kills mid-flight.
// The behaviour is real (each connection tears down independently), but the
// abandoned socket makes this harness's teardown unreliable, and a flaky test
// is worse than a documented gap. Verify it by hand against a restarting dev
// server.
});
/**
* The desktop shell dials this from the main process, where there is no browser
* and therefore no Origin header. Requiring one — as the browser-facing sockets
* rightly do — silently rejected every tunnel and surfaced as an empty response
* in the panel, with nothing to connect it back to authentication.
*/
describe('dev tunnel authentication', () => {
const clientAuth = {
enabled: true,
resolveAuthContext: async (req) => (
req.headers.authorization === 'Bearer good' ? { type: 'client' } : null
),
};
const sessionAuth = {
enabled: true,
resolveAuthContext: async () => ({ type: 'session' }),
};
test('accepts a bearer-authenticated client that sends no origin', async () => {
const devPort = await startDevServer((_req, res) => res.end('ok'));
const host = await startHost({ allowedPorts: [devPort], auth: clientAuth });
const client = createDevTunnelClient({ logger: { warn: () => {} } });
started.push(() => client.closeAll());
const { localPort } = await client.open({
baseUrl: host.baseUrl,
port: devPort,
headers: { Authorization: 'Bearer good' },
});
expect((await httpGet(localPort, '/')).body).toBe('ok');
});
test('rejects a client with no credentials', async () => {
const devPort = await startDevServer((_req, res) => res.end('ok'));
const host = await startHost({ allowedPorts: [devPort], auth: clientAuth });
const client = createDevTunnelClient({ logger: { warn: () => {} } });
started.push(() => client.closeAll());
const { localPort } = await client.open({ baseUrl: host.baseUrl, port: devPort });
await expect(httpGet(localPort, '/')).rejects.toThrow();
});
test('still refuses a session-authenticated request that sends no origin', async () => {
// Only an explicit bearer may skip the origin check; ambient session
// credentials are exactly what the origin check exists to protect.
const devPort = await startDevServer((_req, res) => res.end('ok'));
const host = await startHost({ allowedPorts: [devPort], auth: sessionAuth });
const client = createDevTunnelClient({ logger: { warn: () => {} } });
started.push(() => client.closeAll());
const { localPort } = await client.open({ baseUrl: host.baseUrl, port: devPort });
await expect(httpGet(localPort, '/')).rejects.toThrow();
});
test('rejects a disallowed origin even with valid credentials', async () => {
const devPort = await startDevServer((_req, res) => res.end('ok'));
const host = await startHost({ allowedPorts: [devPort], auth: clientAuth });
const client = createDevTunnelClient({ logger: { warn: () => {} } });
started.push(() => client.closeAll());
const { localPort } = await client.open({
baseUrl: host.baseUrl,
port: devPort,
headers: { Authorization: 'Bearer good', Origin: 'http://evil.example' },
});
await expect(httpGet(localPort, '/')).rejects.toThrow();
});
test('refuses every port when discovery itself is unavailable', async () => {
const devPort = await startDevServer((_req, res) => res.end('ok'));
const host = await startHost({ allowedPorts: [devPort], discoveryOk: false });
const client = createDevTunnelClient({ logger: { warn: () => {} } });
started.push(() => client.closeAll());
const { localPort } = await client.open({ baseUrl: host.baseUrl, port: devPort });
await expect(httpGet(localPort, '/')).rejects.toThrow();
});
});
@@ -53,3 +53,12 @@ other.
directory and does not erase other session results.
- Destructive session/worktree deletion and project-path registration are not
part of the action contract.
- `browser.capture` writes its image on the server, into
`.openchamber/screenshots/` under the scoped project directory, and returns
the project-relative path rather than the image bytes. The client that took
the picture may be on a different machine than the repository, and a path is
what an answer, a commit, or a review can use; base64 in a tool result cannot
be any of those. The agent's label is reduced to a filename fragment, never
used as a path. The result also states how to present the image, because chat
renders the image paths written in a finished answer below that message —
saving the file is not what shows it to anyone.
@@ -1,3 +1,12 @@
/**
* Two capabilities, two tools.
*
* Controlling sessions and driving a page are different intents, and a single
* tool description covering both is vaguer than either — which is how a model
* ends up calling the wrong one. Separate tools also mean turning one off
* removes it entirely, parameters included, rather than leaving its inputs
* visible in a shared schema.
*/
export const OPENCHAMBER_CONTROL_ACTION_DEFINITIONS = Object.freeze([
{ action: 'projects.list', title: 'List configured projects', description: 'List configured projects; no parameters' },
{ action: 'models.list', title: 'Show model preferences', description: 'Show default, favorite, and recent model preferences; no parameters' },
@@ -15,7 +24,7 @@ export const OPENCHAMBER_CONTROL_ACTION_DEFINITIONS = Object.freeze([
{ action: 'schedule.toggle', title: 'Enable or disable a scheduled task', description: 'Enable or disable taskId; requires the disabled boolean' },
]);
export const OPENCHAMBER_CONTROL_ACTIONS = Object.freeze(
const OPENCHAMBER_CONTROL_ACTIONS = Object.freeze(
OPENCHAMBER_CONTROL_ACTION_DEFINITIONS.map(({ action }) => action),
);
@@ -26,3 +35,26 @@ export const OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS = Object.freeze(
export const OPENCHAMBER_AGENT_TOOL_ACTIONS = Object.freeze(
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS.map(({ action }) => action),
);
export const OPENCHAMBER_WEB_ACTION_DEFINITIONS = Object.freeze([
{ action: 'browser.open', title: 'Open a page in the browser panel', description: 'Open url in the in-app browser panel; use it to look at the running app. Set viewport to mobile, tablet or desktop to lay the page out at that size' },
{ action: 'browser.snapshot', title: 'Read the open page', description: 'Read the open page: url, title, visible text, and interactive elements with the selectors the other browser actions accept. Pass selector to read only that part of a long page. Reports any errors the page logged' },
{ action: 'browser.click', title: 'Click on the open page', description: 'Click an element; give selector, or text to match a link or button by its visible label' },
{ action: 'browser.type', title: 'Type into the open page', description: 'Type value into the field matched by selector; set submit to press Enter afterwards' },
{ action: 'browser.scroll', title: 'Scroll the open page', description: 'Scroll the page; direction is up, down, top, or bottom, or pass selector to bring one element into view' },
{ action: 'browser.back', title: 'Go back in the browser panel', description: 'Return to the previous page in this tab; no parameters' },
{ action: 'browser.forward', title: 'Go forward in the browser panel', description: 'Move forward again in this tab; no parameters' },
{ action: 'browser.inspect', title: 'Read how an element renders', description: 'Read the computed styles of the element matched by selector — colours, fonts, spacing, borders — as the page actually renders them' },
{ action: 'browser.capture', title: 'Save a screenshot of the page', description: 'Save what is currently visible in the browser panel as an image file in the project and return its path, so a change can be shown rather than described. Pass label to name it (for example before-fix); the result reports the page, layout and path to reference in your answer' },
{ action: 'browser.resize', title: 'Change the page viewport', description: 'Lay the open page out at a different size; viewport is mobile, tablet, desktop, or fill to use the whole panel' },
]);
export const OPENCHAMBER_WEB_ACTIONS = Object.freeze(
OPENCHAMBER_WEB_ACTION_DEFINITIONS.map(({ action }) => action),
);
/** Everything the callback route will dispatch, whichever tool asked. */
export const OPENCHAMBER_ALL_ACTIONS = Object.freeze([
...OPENCHAMBER_CONTROL_ACTIONS,
...OPENCHAMBER_WEB_ACTIONS,
]);
@@ -0,0 +1,83 @@
/**
* Where an agent's page screenshots land.
*
* The image is written on the server, next to the code it is evidence for,
* because that is the machine holding the repository — the client that took the
* picture may be somewhere else entirely. A file in the project is also the
* only form of this that survives past the chat: it can be referenced from an
* answer, committed, or attached to a review.
*
* A screenshot nobody can place is not evidence, so the name carries the label
* the agent chose and the moment it was taken, and the caller is handed back
* the page and layout it shows.
*/
import path from 'node:path';
import fsPromises from 'node:fs/promises';
/** Project-relative home for agent screenshots. */
export const SCREENSHOT_DIRECTORY = path.join('.openchamber', 'screenshots');
const MAX_LABEL_LENGTH = 48;
/**
* Turns a label into a filename fragment.
*
* Everything outside a small safe set is dropped rather than escaped: this
* value reaches the filesystem, and a label is a name, never a path. `..`, a
* separator, or a leading dot cannot survive this.
*/
export const screenshotSlug = (label) => {
const slug = String(label ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, MAX_LABEL_LENGTH)
.replace(/-+$/g, '');
return slug || 'page';
};
/** File-safe timestamp: sorts chronologically and reads as a date. */
const screenshotStamp = (date) => date.toISOString().replace(/[:.]/g, '-').replace('Z', '');
const EXTENSIONS = new Map([
['image/jpeg', '.jpg'],
['image/png', '.png'],
['image/webp', '.webp'],
]);
/**
* Writes one capture into the project and reports where it went.
*
* Returns both the project-relative path — what belongs in an answer or a
* commit — and the absolute one, so a caller that needs the file itself does
* not have to rebuild it.
*/
export const writeScreenshot = async ({
directory,
base64,
mime = 'image/jpeg',
label,
now = new Date(),
fs = fsPromises,
}) => {
if (typeof directory !== 'string' || directory.trim().length === 0) {
throw new Error('A project directory is required to save a screenshot');
}
if (typeof base64 !== 'string' || base64.length === 0) {
throw new Error('The browser returned no image');
}
const extension = EXTENSIONS.get(mime) || '.jpg';
const relativePath = path.join(
SCREENSHOT_DIRECTORY,
`${screenshotSlug(label)}-${screenshotStamp(now)}${extension}`,
);
const absolutePath = path.join(directory, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, Buffer.from(base64, 'base64'));
// Posix separators in the reported path: it is written into Markdown and
// commit messages, where a Windows separator is an escape character.
return { path: relativePath.split(path.sep).join('/'), absolutePath };
};
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import path from 'node:path';
import { SCREENSHOT_DIRECTORY, screenshotSlug, writeScreenshot } from './screenshots.js';
const createFs = () => {
const written = new Map();
const made = [];
return {
written,
made,
mkdir: async (target) => { made.push(target); },
writeFile: async (target, data) => { written.set(target, data); },
};
};
describe('screenshot labels', () => {
it('keeps a readable name', () => {
expect(screenshotSlug('Before fix')).toBe('before-fix');
});
it('never lets a label become a path', () => {
expect(screenshotSlug('../../etc/passwd')).toBe('etc-passwd');
expect(screenshotSlug('/absolute')).toBe('absolute');
expect(screenshotSlug('..')).toBe('page');
expect(screenshotSlug('.hidden')).toBe('hidden');
});
it('falls back to a name rather than an empty one', () => {
expect(screenshotSlug('')).toBe('page');
expect(screenshotSlug('!!!')).toBe('page');
expect(screenshotSlug(undefined)).toBe('page');
});
});
describe('writing a screenshot', () => {
const base64 = Buffer.from('image-bytes').toString('base64');
it('writes into the project and reports a portable relative path', async () => {
const fs = createFs();
const result = await writeScreenshot({
directory: '/work/project',
base64,
mime: 'image/jpeg',
label: 'After fix',
now: new Date('2026-08-13T09:37:00.000Z'),
fs,
});
expect(result.path).toBe('.openchamber/screenshots/after-fix-2026-08-13T09-37-00-000.jpg');
expect(result.path.includes('\\')).toBe(false);
expect(result.absolutePath).toBe(path.join('/work/project', SCREENSHOT_DIRECTORY, 'after-fix-2026-08-13T09-37-00-000.jpg'));
expect(fs.written.get(result.absolutePath).toString()).toBe('image-bytes');
expect(fs.made[0]).toBe(path.join('/work/project', SCREENSHOT_DIRECTORY));
});
it('names the file after the image it actually holds', async () => {
const fs = createFs();
const result = await writeScreenshot({ directory: '/work/project', base64, mime: 'image/png', fs });
expect(result.path.endsWith('.png')).toBe(true);
});
it('refuses to write without a project directory', async () => {
let failed = false;
try {
await writeScreenshot({ directory: '', base64, fs: createFs() });
} catch {
failed = true;
}
expect(failed).toBe(true);
});
it('reports an empty capture instead of writing a zero-byte file', async () => {
const fs = createFs();
let failed = false;
try {
await writeScreenshot({ directory: '/work/project', base64: '', fs });
} catch {
failed = true;
}
expect(failed).toBe(true);
expect(fs.written.size).toBe(0);
});
});
@@ -1,12 +1,14 @@
import path from 'node:path';
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { OpenChamberControlError, asControlError } from './error.js';
import { OPENCHAMBER_CONTROL_ACTIONS } from './actions.js';
import { OPENCHAMBER_ALL_ACTIONS } from './actions.js';
import { writeScreenshot } from './screenshots.js';
const DEFAULT_WAIT_TIMEOUT_SECONDS = 600;
const MAX_WAIT_TIMEOUT_SECONDS = 86_400;
const WAIT_POLL_INTERVAL_MS = 500;
const CONTROL_ACTIONS = new Set(OPENCHAMBER_CONTROL_ACTIONS);
// One service, both capabilities: which tool asked is the caller's concern.
const CONTROL_ACTIONS = new Set(OPENCHAMBER_ALL_ACTIONS);
const SCHEDULE_TASK_ID_ACTIONS = new Set([
'schedule.run',
'schedule.delete',
@@ -141,6 +143,7 @@ export const createOpenChamberControlService = (dependencies) => {
waitForOpenCodeReady,
sessionService,
scheduledTaskService,
browserControl = null,
createClient = createOpencodeClient,
sleep = (duration) => new Promise((resolve) => setTimeout(resolve, duration)),
now = Date.now,
@@ -320,11 +323,147 @@ export const createOpenChamberControlService = (dependencies) => {
return publicResult;
};
/**
* Validates browser inputs here rather than in the renderer: an invalid call
* should come back as a usage error the agent can correct, without waking a
* client or waiting for a round trip.
*/
const browserAction = async (action, input, signal, contextDirectory) => {
const parameters = {};
const readViewport = (required) => {
const viewport = asNonEmptyString(input.viewport);
if (!viewport) {
if (required) throw new OpenChamberControlError('viewport is required for browser.resize', 400);
return;
}
if (!['mobile', 'tablet', 'desktop', 'fill'].includes(viewport)) {
throw new OpenChamberControlError('viewport must be mobile, tablet, desktop, or fill', 400);
}
parameters.viewport = viewport;
};
if (action === 'browser.resize') readViewport(true);
if (action === 'browser.capture') {
const label = asNonEmptyString(input.label);
if (label) parameters.label = label;
}
if (action === 'browser.open') {
readViewport(false);
const url = asNonEmptyString(input.url);
if (!url) throw new OpenChamberControlError('url is required for browser.open', 400);
let parsed;
try {
parsed = new URL(url);
} catch {
throw new OpenChamberControlError('url must be an absolute http(s) URL', 400);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new OpenChamberControlError('url must use http or https', 400);
}
parameters.url = parsed.toString();
}
if (action === 'browser.click') {
const selector = asNonEmptyString(input.selector);
const text = asNonEmptyString(input.text);
if (!selector && !text) {
throw new OpenChamberControlError('browser.click requires selector or text', 400);
}
if (selector) parameters.selector = selector;
if (text) parameters.text = text;
}
if (action === 'browser.snapshot') {
const selector = asNonEmptyString(input.selector);
if (selector) parameters.selector = selector;
}
if (action === 'browser.inspect') {
const selector = asNonEmptyString(input.selector);
if (!selector) throw new OpenChamberControlError('selector is required for browser.inspect', 400);
parameters.selector = selector;
}
if (action === 'browser.type') {
const selector = asNonEmptyString(input.selector);
if (!selector) throw new OpenChamberControlError('selector is required for browser.type', 400);
if (typeof input.value !== 'string') {
throw new OpenChamberControlError('value is required for browser.type', 400);
}
parameters.selector = selector;
parameters.value = input.value;
parameters.submit = input.submit === true;
}
if (action === 'browser.scroll') {
const selector = asNonEmptyString(input.selector);
const direction = asNonEmptyString(input.direction);
if (!selector && !direction) {
throw new OpenChamberControlError('browser.scroll requires direction or selector', 400);
}
if (direction && !['up', 'down', 'top', 'bottom'].includes(direction)) {
throw new OpenChamberControlError('direction must be up, down, top, or bottom', 400);
}
if (selector) parameters.selector = selector;
if (direction) parameters.direction = direction;
}
// Opening a page waits for the navigation to settle, so its budget has to
// exceed the client's own wait; sharing one timeout with the quick actions
// made a slow page indistinguishable from an unreachable browser.
const timeoutMs = action === 'browser.open' ? 45_000 : 20_000;
const result = await browserControl.request(action, parameters, { signal, timeoutMs });
// The image is written here rather than in the renderer: the file belongs
// beside the code it documents, and the client that took it may be on a
// different machine than the repository.
if (action === 'browser.capture') {
const directory = asNonEmptyString(input.directory) || asNonEmptyString(contextDirectory);
if (!directory) {
throw new OpenChamberControlError('directory is required to save a screenshot', 400);
}
const capture = result && typeof result === 'object' ? result : {};
const saved = await writeScreenshot({
directory,
base64: capture.base64,
mime: capture.mime,
label: input.label,
});
// The base64 never goes back to the caller: it is large, and the path is
// what an answer, a commit, or a review can actually use.
return {
path: saved.path,
// Saving the file is only half of showing it. Chat collects the image
// paths written in a finished answer and renders them below it, so the
// agent is told the one thing it cannot infer: that writing the path is
// what puts the picture in front of the user.
hint: `Write ![](${saved.path}) in your reply to show this image to the user; it is rendered under your message.`,
url: capture.url ?? null,
title: capture.title ?? null,
viewport: capture.viewport ?? null,
width: capture.width ?? null,
height: capture.height ?? null,
};
}
return result;
};
const execute = async (action, input = {}, contextDirectory, options = {}) => {
try {
if (!CONTROL_ACTIONS.has(action)) {
throw new OpenChamberControlError(`Unsupported OpenChamber action: ${action || 'missing'}`, 400);
}
if (action.startsWith('browser.')) {
if (!browserControl) {
throw new OpenChamberControlError('The in-app browser is not available on this server', 503);
}
return browserAction(action, input, options.signal, contextDirectory);
}
if (action === 'projects.list') return { projects: await projects() };
if (action === 'models.list') return models();
if (action === 'schedule.status') return scheduledTaskService.status();
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import { createOpenChamberControlService } from './service.js';
const createService = (overrides = {}) => {
@@ -261,3 +265,54 @@ describe('OpenChamber control service', () => {
await expect(service.execute('session.delete')).rejects.toThrow('Unsupported OpenChamber action');
});
});
describe('browser capture', () => {
const pixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
const createBrowserService = async (capture) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-capture-'));
const request = vi.fn(async () => capture);
const { service } = createService({ browserControl: { request } });
return { service, directory, request };
};
it('saves the image beside the code and hands back a path the answer can use', async () => {
const { service, directory } = await createBrowserService({
base64: pixel,
mime: 'image/png',
url: 'http://localhost:3000/',
title: 'App',
viewport: { mode: 'mobile', width: 390, height: 844 },
width: 390,
height: 844,
});
const result = await service.execute('browser.capture', { label: 'After fix' }, directory);
expect(result.path.startsWith('.openchamber/screenshots/after-fix-')).toBe(true);
expect(result.path.endsWith('.png')).toBe(true);
expect(result.url).toBe('http://localhost:3000/');
expect(result.viewport).toEqual({ mode: 'mobile', width: 390, height: 844 });
// The bytes stay on disk; a tool result is not a place to carry an image.
expect('base64' in result).toBe(false);
const written = await fs.readFile(path.join(directory, result.path));
expect(written.length > 0).toBe(true);
});
it('tells the agent how to actually show the image', async () => {
const { service, directory } = await createBrowserService({ base64: pixel, mime: 'image/png' });
const result = await service.execute('browser.capture', {}, directory);
expect(result.hint).toContain(`![](${result.path})`);
});
it('refuses to capture with no project to save into', async () => {
const { service } = await createBrowserService({ base64: pixel, mime: 'image/png' });
await expect(service.execute('browser.capture', {})).rejects.toThrow(/directory is required/);
});
it('passes a label through to the browser and leaves other actions untouched', async () => {
const { service, directory, request } = await createBrowserService({ base64: pixel, mime: 'image/png' });
await service.execute('browser.capture', { label: 'before' }, directory);
expect(request).toHaveBeenCalledWith('browser.capture', { label: 'before' }, expect.anything());
});
});
@@ -24,42 +24,6 @@ const parseLoopbackUrl = (rawUrl) => {
return url;
};
const getRequestPathname = (req) => {
const rawUrl = req?.originalUrl || req?.url || '';
if (typeof rawUrl !== 'string' || rawUrl.length === 0) return '';
try {
return new URL(rawUrl, 'http://localhost').pathname;
} catch {
return '';
}
};
const getQueryParam = (req, name) => {
const rawUrl = req?.originalUrl || req?.url || '';
if (typeof rawUrl !== 'string' || rawUrl.length === 0) return '';
try {
return new URL(rawUrl, 'http://localhost').searchParams.get(name)?.trim() || '';
} catch {
return '';
}
};
const getCookieValue = (req, name) => {
const cookieHeader = req?.headers?.cookie;
if (typeof cookieHeader !== 'string' || cookieHeader.length === 0) return '';
for (const segment of cookieHeader.split(';')) {
const [rawName, ...rawValueParts] = segment.split('=');
if (rawName?.trim() !== name) continue;
return rawValueParts.join('=').trim();
}
return '';
};
const hasPreviewProxyCredential = (req) => {
if (!getRequestPathname(req).startsWith('/api/preview/proxy/')) return false;
return Boolean(getQueryParam(req, 'oc_preview_token') || getCookieValue(req, 'oc_preview_token'));
};
export const registerServerStatusRoutes = (app, dependencies) => {
const {
express,
@@ -626,14 +590,6 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
};
const requireApiAuth = async (req, res, next) => {
// Preview proxy requests carry a target-scoped capability token that the
// preview proxy validates against the registered target id/TTL. Let those
// requests reach that stricter check instead of failing the global UI auth
// gate when the short-lived browser URL auth token expires.
if (hasPreviewProxyCredential(req)) {
return next();
}
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return tunnelAuthController.requireTunnelSession(req, res, next);
@@ -491,7 +491,7 @@ describe('core-routes', () => {
expect(dependencies.clientPairingRuntime.redeemPairingSession).toHaveBeenCalledTimes(11);
});
it('should let preview proxy credentials reach preview proxy validation', async () => {
it('no longer exempts preview-proxy style credentials from API auth', async () => {
const app = express();
const requireAuth = vi.fn((_req, res) => res.status(401).type('text/plain').send('Authentication required'));
@@ -523,20 +523,18 @@ describe('core-routes', () => {
app.use('/api/preview/proxy', (_req, res) => res.json({ reached: true }));
// The preview proxy is gone; the token that used to bypass the auth gate
// must no longer open a hole for any route that happens to match the path.
await request(app)
.get('/api/preview/proxy/abc123/?oc_preview_token=preview-secret')
.expect(200, { reached: true });
.expect(401, 'Authentication required');
await request(app)
.get('/api/preview/proxy/abc123/')
.set('Cookie', 'oc_preview_token=preview-secret')
.expect(200, { reached: true });
await request(app)
.get('/api/preview/proxy/abc123/')
.expect(401, 'Authentication required');
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(requireAuth).toHaveBeenCalledTimes(2);
});
});
@@ -5,6 +5,7 @@ import { registerWalkthroughRoutes } from '../walkthrough/routes.js';
import { registerSessionGoalRoutes } from '../session-goal/routes.js';
import { registerGitHubRoutes } from '../github/routes.js';
import { registerGitRoutes } from '../git/routes.js';
import { registerDevServerRoutes } from '../dev-servers/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
@@ -110,6 +111,8 @@ export const createFeatureRoutesRuntime = (dependencies) => {
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getOpenCodePort,
getOwnPorts,
devServerScanner,
buildAugmentedPath,
projectConfigRuntime,
scheduledTasksRuntime,
@@ -284,6 +287,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
registerSessionGoalRoutes(app);
registerGitHubRoutes(app);
registerGitRoutes(app);
registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts });
registerMagicPromptRoutes(app, {
fsPromises,
path,
@@ -502,6 +502,9 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') {
result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications;
}
if (typeof candidate.agentWebToolEnabled === 'boolean') {
result.agentWebToolEnabled = candidate.agentWebToolEnabled;
}
if (typeof candidate.agentControlToolEnabled === 'boolean') {
result.agentControlToolEnabled = candidate.agentControlToolEnabled;
}
File diff suppressed because it is too large Load Diff
@@ -1,528 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
applyPreviewPassthroughRequestHeaders,
applyPreviewPassthroughResponseHeaders,
classifyPreviewNavigation,
classifyPreviewResourceError,
createPreviewProxyRuntime,
normalizeProxyTargetUrl,
PREVIEW_TARGET_ERROR_HEADER,
rewritePreviewBody,
rewritePreviewCspHeader,
rewritePreviewRedirectLocation,
} from './proxy-runtime.js';
const createResponse = () => {
const headers = new Map();
return {
body: null,
statusCode: 200,
headers,
setHeader(name, value) {
headers.set(name.toLowerCase(), value);
},
removeHeader(name) {
headers.delete(name.toLowerCase());
},
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.body = body;
return body;
},
};
};
const createAttachedPreviewRuntime = () => {
let proxyOptions;
const postRoutes = new Map();
const useRoutes = new Map();
let randomByte = 0;
const runtime = createPreviewProxyRuntime({
crypto: {
randomBytes(size) {
randomByte += 1;
return Buffer.alloc(size, randomByte);
},
},
URL,
createProxyMiddleware(options) {
proxyOptions = options;
const middleware = () => {};
middleware.upgrade = () => {};
return middleware;
},
responseInterceptor: (handler) => handler,
});
const app = {
post(path, ...handlers) {
postRoutes.set(path, handlers);
},
use(path, ...handlers) {
useRoutes.set(path, handlers);
},
};
runtime.attach(app, {
server: { on() {} },
express: { json: () => (_req, _res, next) => next() },
uiAuthController: null,
isRequestOriginAllowed: async () => true,
rejectWebSocketUpgrade() {},
});
return { postRoutes, proxyOptions: () => proxyOptions, useRoutes };
};
const rewrite = (bodyText, kind) => rewritePreviewBody({
bodyText,
kind,
proxyBasePath: '/api/preview/proxy/abc123',
targetOrigin: 'http://127.0.0.1:3000',
});
describe('preview target failure signaling', () => {
it('marks missing and expired targets instead of relying on the HTTP status alone', () => {
const { useRoutes } = createAttachedPreviewRuntime();
const [guard] = useRoutes.get('/api/preview/proxy');
for (const [originalUrl, code, error] of [
['/api/preview/proxy/', 'missing', 'Preview target not found'],
[`/api/preview/proxy/${'a'.repeat(32)}/`, 'expired', 'Preview target expired'],
]) {
const response = createResponse();
guard({ originalUrl, headers: {} }, response, () => {});
expect(response.statusCode).toBe(404);
expect(response.headers.get(PREVIEW_TARGET_ERROR_HEADER)).toBe(code);
expect(response.body).toEqual({ error });
}
});
it('marks invalid target tokens and accepts a registered target token', async () => {
const { postRoutes, useRoutes } = createAttachedPreviewRuntime();
const [, registerTarget] = postRoutes.get('/api/preview/targets');
const [guard] = useRoutes.get('/api/preview/proxy');
const registrationResponse = createResponse();
await registerTarget({ body: { url: 'http://127.0.0.1:4323/' }, secure: false }, registrationResponse);
const { id, previewToken } = registrationResponse.body;
const invalidResponse = createResponse();
guard({ originalUrl: `/api/preview/proxy/${id}/`, headers: {} }, invalidResponse, () => {});
expect(invalidResponse.statusCode).toBe(403);
expect(invalidResponse.headers.get(PREVIEW_TARGET_ERROR_HEADER)).toBe('invalid-token');
const validResponse = createResponse();
let continued = false;
guard({
originalUrl: `/api/preview/proxy/${id}/?oc_preview_token=${previewToken}`,
headers: {},
}, validResponse, () => {
continued = true;
});
expect(continued).toBe(true);
expect(validResponse.headers.has(PREVIEW_TARGET_ERROR_HEADER)).toBe(false);
});
it('removes the reserved target-error marker from upstream responses', async () => {
const { postRoutes, proxyOptions, useRoutes } = createAttachedPreviewRuntime();
const [, registerTarget] = postRoutes.get('/api/preview/targets');
const registrationResponse = createResponse();
await registerTarget({ body: { url: 'http://127.0.0.1:4323/' }, secure: false }, registrationResponse);
const { id, previewToken } = registrationResponse.body;
const request = {
originalUrl: `/api/preview/proxy/${id}/missing?oc_preview_token=${previewToken}`,
headers: {},
};
const response = createResponse();
response.setHeader(PREVIEW_TARGET_ERROR_HEADER, 'expired');
await proxyOptions().on.proxyRes(
Buffer.from('{"error":"upstream missing"}'),
{ headers: { 'content-type': 'application/json' } },
request,
response,
);
expect(response.headers.has(PREVIEW_TARGET_ERROR_HEADER)).toBe(false);
const [guard] = useRoutes.get('/api/preview/proxy');
let continued = false;
guard(request, createResponse(), () => {
continued = true;
});
expect(continued).toBe(true);
});
});
describe('preview Inertia header passthrough', () => {
it('forwards Inertia request headers to the preview target', () => {
const forwarded = new Map();
const proxyReq = {
setHeader: (name, value) => forwarded.set(name, value),
};
applyPreviewPassthroughRequestHeaders({
headers: {
'x-inertia': 'true',
'x-inertia-version': 'asset-hash',
'x-unrelated': 'ignored',
},
}, proxyReq);
expect(forwarded.get('x-inertia')).toBe('true');
expect(forwarded.get('x-inertia-version')).toBe('asset-hash');
expect(forwarded.has('x-unrelated')).toBe(false);
});
it('forwards Inertia response headers back to the preview client', () => {
const forwarded = new Map();
const res = {
headersSent: false,
setHeader: (name, value) => forwarded.set(name, value),
};
applyPreviewPassthroughResponseHeaders({
headers: {
'x-inertia': 'true',
'x-inertia-location': 'http://127.0.0.1:8000/login',
'x-unrelated': 'ignored',
},
}, res);
expect(forwarded.get('x-inertia')).toBe('true');
expect(forwarded.get('x-inertia-location')).toBe('http://127.0.0.1:8000/login');
expect(forwarded.has('x-unrelated')).toBe(false);
});
});
describe('preview resource error classification', () => {
it('suppresses Astro/Vite stylesheet modules reported as failed scripts', () => {
expect(classifyPreviewResourceError({
tagName: 'script',
url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/src/styles/global.css',
})).toBe('suppress');
expect(classifyPreviewResourceError({
tagName: 'script',
url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/src/pages/support.astro?astro&type=style&index=0&lang.css',
})).toBe('suppress');
});
it('suppresses framework virtual modules reported by dev servers', () => {
expect(classifyPreviewResourceError({
tagName: 'script',
url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/src/layouts/BaseLayout.astro?astro&type=script&index=0&lang.ts',
})).toBe('suppress');
expect(classifyPreviewResourceError({
tagName: 'script',
url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/@vite/client',
})).toBe('suppress');
expect(classifyPreviewResourceError({
tagName: 'link',
url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/@id/astro:scripts/page.js',
})).toBe('suppress');
});
it('suppresses conservative ecosystem dev-runtime resources', () => {
const noisyResources = [
'/_next/static/chunks/webpack.js',
'/_next/static/chunks/react-refresh.js',
'/.svelte-kit/generated/client/app.js',
'/@id/__x00__virtual:sveltekit:browser',
'/@remix-run/dev/dist/browser.js',
'/__hmr?runtime=remix',
'/_nuxt/@vite/client',
'/_nuxt/@id/virtual:nuxt:%2FUsers%2Fapp',
'/webpack-dev-server/client/index.js',
'/webpack/hot/dev-server.js',
'/__webpack_hmr',
];
for (const resource of noisyResources) {
expect(classifyPreviewResourceError({
tagName: 'script',
url: `http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc${resource}`,
})).toBe('suppress');
}
});
it('keeps ordinary application resource failures visible', () => {
expect(classifyPreviewResourceError({
tagName: 'script',
url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/assets/app.js',
})).toBe('report');
expect(classifyPreviewResourceError({
tagName: 'img',
url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/missing.png',
})).toBe('report');
expect(classifyPreviewResourceError({
tagName: 'link',
url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/styles/missing.css',
})).toBe('report');
});
});
describe('preview body URL rewriting', () => {
it('rewrites only HTML resource attributes in HTML responses', () => {
const input = '<img src="/logo.png"><a href="/docs">Docs</a><script>const url = "/api/data";</script>';
const output = rewrite(input, 'html');
expect(output).toContain('src="/api/preview/proxy/abc123/logo.png"');
expect(output).toContain('href="/api/preview/proxy/abc123/docs"');
expect(output).toContain('const url = "/api/data";');
});
it('rewrites inline module imports in HTML responses', () => {
const input = [
'<script type="module">',
'import RefreshRuntime from "/@react-refresh";',
'window.__vite_plugin_react_preamble_installed__ = true;',
'</script>',
'<script type=module>',
'import { injectIntoGlobalHook } from "/@react-refresh";',
'import value from "/module.js";',
'const url = "/api/data";',
'</script>',
'<script type=\'module\'>',
'import "/entry.js";',
'</script>',
'<script type="text/javascript">',
'import "/not-rewritten.js";',
'</script>',
'<script>const refreshUrl = "/@react-refresh";</script>',
].join('');
const output = rewrite(input, 'html');
expect(output).toContain('from "/api/preview/proxy/abc123/@react-refresh"');
expect(output).toContain('import { injectIntoGlobalHook } from "/api/preview/proxy/abc123/@react-refresh";');
expect(output).toContain('import value from "/api/preview/proxy/abc123/module.js";');
expect(output).toContain('const url = "/api/data";');
expect(output).toContain('import "/api/preview/proxy/abc123/entry.js";');
expect(output).toContain('import "/not-rewritten.js";');
expect(output).toContain('window.__vite_plugin_react_preamble_installed__ = true;');
expect(output).toContain('const refreshUrl = "/@react-refresh";');
});
it('removes CSP meta tags that block the preview bridge', () => {
const input = '<meta http-equiv="Content-Security-Policy" content="script-src \'self\'"><div>Preview</div>';
const output = rewrite(input, 'html');
expect(output).not.toContain('Content-Security-Policy');
expect(output).toContain('<div>Preview</div>');
});
it('adds preview and URL auth tokens to rewritten proxy resources when provided', () => {
const output = rewritePreviewBody({
bodyText: '<script src="/entry.js"></script><script type="module">import RefreshRuntime from "/@react-refresh";</script><a href="http://localhost:3000/docs?x=1&oc_client_token=legacy">Docs</a>',
kind: 'html',
proxyBasePath: '/api/preview/proxy/abc123',
targetOrigin: 'http://127.0.0.1:3000',
previewToken: 'preview-secret',
urlAuthToken: 'url-secret',
});
expect(output).toContain('src="/api/preview/proxy/abc123/entry.js?oc_preview_token=preview-secret&oc_url_token=url-secret"');
expect(output).toContain('from "/api/preview/proxy/abc123/@react-refresh?oc_preview_token=preview-secret&oc_url_token=url-secret"');
expect(output).toContain('href="/api/preview/proxy/abc123/docs?x=1&oc_preview_token=preview-secret&oc_url_token=url-secret"');
expect(output).not.toContain('oc_client_token');
});
it('rewrites only CSS imports and url references in CSS responses', () => {
const input = '@import "/theme.css"; .hero { background: url(/hero.png); } .copy::after { content: "/not-a-url"; }';
const output = rewrite(input, 'css');
expect(output).toContain('@import "/api/preview/proxy/abc123/theme.css"');
expect(output).toContain('url(/api/preview/proxy/abc123/hero.png)');
expect(output).toContain('content: "/not-a-url"');
});
it('rewrites only JavaScript static import specifiers in JavaScript responses', () => {
const input = 'import "/entry.js"; import value from "/module.js"; const url = "/api/data"; fetch("/api/data");';
const output = rewrite(input, 'javascript');
expect(output).toContain('import "/api/preview/proxy/abc123/entry.js"');
expect(output).toContain('from "/api/preview/proxy/abc123/module.js"');
expect(output).toContain('const url = "/api/data"');
expect(output).toContain('fetch("/api/data")');
});
it('adds URL auth tokens to CSS and JavaScript rewritten resources', () => {
const cssOutput = rewritePreviewBody({
bodyText: '@import "/theme.css"; .hero { background: url(/hero.png); }',
kind: 'css',
proxyBasePath: '/api/preview/proxy/abc123',
targetOrigin: 'http://127.0.0.1:3000',
previewToken: 'preview-secret',
urlAuthToken: 'url-secret',
});
const jsOutput = rewritePreviewBody({
bodyText: 'import("/entry.js"); import value from "/module.js";',
kind: 'javascript',
proxyBasePath: '/api/preview/proxy/abc123',
targetOrigin: 'http://127.0.0.1:3000',
previewToken: 'preview-secret',
urlAuthToken: 'url-secret',
});
expect(cssOutput).toContain('@import "/api/preview/proxy/abc123/theme.css?oc_preview_token=preview-secret&oc_url_token=url-secret"');
expect(cssOutput).toContain('url(/api/preview/proxy/abc123/hero.png?oc_preview_token=preview-secret&oc_url_token=url-secret)');
expect(jsOutput).toContain('import("/api/preview/proxy/abc123/entry.js?oc_preview_token=preview-secret&oc_url_token=url-secret")');
expect(jsOutput).toContain('from "/api/preview/proxy/abc123/module.js?oc_preview_token=preview-secret&oc_url_token=url-secret"');
});
});
describe('preview redirect URL rewriting', () => {
it('rewrites loopback redirects through the preview proxy', () => {
expect(rewritePreviewRedirectLocation({
location: 'http://localhost:3000/login?next=%2F#top',
proxyBasePath: '/api/preview/proxy/abc123',
targetOrigin: 'http://127.0.0.1:3000',
})).toBe('/api/preview/proxy/abc123/login?next=%2F#top');
});
it('leaves external redirects unchanged', () => {
expect(rewritePreviewRedirectLocation({
location: 'https://example.com/login',
proxyBasePath: '/api/preview/proxy/abc123',
targetOrigin: 'http://127.0.0.1:3000',
})).toBe('https://example.com/login');
});
it('adds proxy auth tokens to loopback redirects when provided', () => {
expect(rewritePreviewRedirectLocation({
location: 'http://localhost:3000/login?next=%2F#top',
proxyBasePath: '/api/preview/proxy/abc123',
targetOrigin: 'http://127.0.0.1:3000',
previewToken: 'preview-secret',
urlAuthToken: 'url-secret',
})).toBe('/api/preview/proxy/abc123/login?next=%2F&oc_preview_token=preview-secret&oc_url_token=url-secret#top');
});
it('leaves redirects unchanged when no target origin is provided', () => {
expect(rewritePreviewRedirectLocation({
location: 'http://localhost:5174/callback',
proxyBasePath: '/api/preview/proxy/abc123',
previewToken: 'preview-secret',
})).toBe('http://localhost:5174/callback');
});
});
describe('preview navigation policy', () => {
const currentUrl = 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/docs';
it('keeps same-page hash and already-proxied links in the iframe', () => {
expect(classifyPreviewNavigation({ url: '#section', currentUrl }).action).toBe('allow');
expect(classifyPreviewNavigation({
url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/roadmap',
currentUrl,
}).action).toBe('allow');
});
it('routes loopback absolute links through the preview proxy', () => {
expect(classifyPreviewNavigation({ url: 'http://localhost:3000/roadmap', currentUrl })).toEqual({
action: 'proxy',
url: 'http://localhost:3000/roadmap',
});
});
it('maps app-origin root links back to the upstream origin while proxied', () => {
expect(classifyPreviewNavigation({
url: 'http://127.0.0.1:57123/support',
currentUrl,
targetOrigin: 'https://openchamber.dev',
})).toEqual({
action: 'proxy',
url: 'https://openchamber.dev/support',
});
});
it('sends non-loopback http links outside the preview iframe', () => {
expect(classifyPreviewNavigation({ url: 'https://example.com/docs', currentUrl })).toEqual({
action: 'external',
url: 'https://example.com/docs',
});
});
it('leaves non-http links to browser defaults', () => {
expect(classifyPreviewNavigation({ url: 'mailto:test@example.com', currentUrl })).toEqual({
action: 'allow',
url: 'mailto:test@example.com',
});
});
});
describe('proxy target normalization (SSRF guard)', () => {
it('allows ordinary external hosts when allowExternal is set', () => {
expect(normalizeProxyTargetUrl('https://docs.openchamber.dev/security/', { allowExternal: true }))
.toEqual({ ok: true, origin: 'https://docs.openchamber.dev' });
});
it('rejects non-loopback hosts without allowExternal', () => {
expect(normalizeProxyTargetUrl('https://example.com/', {}).ok).toBe(false);
});
it('refuses private, loopback and link-local literals on the external path', () => {
for (const url of [
'http://127.0.0.1/',
'http://10.0.0.5/',
'http://172.16.9.9/',
'http://192.168.1.1/',
'http://169.254.169.254/latest/meta-data/',
'http://100.64.0.1/',
'http://localhost/',
'http://service.local/',
'http://[::1]/',
'http://[fd00::1]/',
'http://[fe80::1]/',
'http://2130706433/', // decimal form of 127.0.0.1, normalized by WHATWG URL
]) {
expect(normalizeProxyTargetUrl(url, { allowExternal: true }).ok, url).toBe(false);
}
});
it('still blocks private hosts even via IPv4-mapped IPv6', () => {
expect(normalizeProxyTargetUrl('http://[::ffff:127.0.0.1]/', { allowExternal: true }).ok).toBe(false);
});
});
describe('preview CSP rewrite', () => {
it('drops frame-ancestors and require-trusted-types-for but keeps the rest', () => {
const result = rewritePreviewCspHeader(
"default-src 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'",
'abc123',
);
expect(result).not.toContain('frame-ancestors');
expect(result).not.toContain('require-trusted-types-for');
expect(result).toContain("default-src 'self'");
});
it('adds the nonce to an existing script-src instead of removing it', () => {
const result = rewritePreviewCspHeader("script-src 'self'", 'abc123');
expect(result).toContain("script-src 'self' 'nonce-abc123'");
});
it('adds the nonce to script-src-elem when present', () => {
const result = rewritePreviewCspHeader("script-src-elem 'self'", 'abc123');
expect(result).toContain("script-src-elem 'self' 'nonce-abc123'");
});
it('synthesizes script-src from default-src when no script directive exists', () => {
const result = rewritePreviewCspHeader("default-src 'self' https://cdn.example.com", 'abc123');
expect(result).toContain("default-src 'self' https://cdn.example.com");
expect(result).toContain("script-src 'self' https://cdn.example.com 'nonce-abc123'");
});
it("drops a lone 'none' so the nonce takes effect", () => {
const result = rewritePreviewCspHeader("script-src 'none'", 'abc123');
expect(result).toBe("script-src 'nonce-abc123'");
});
it('returns empty/unset CSP values unchanged', () => {
expect(rewritePreviewCspHeader('', 'abc123')).toBe('');
expect(rewritePreviewCspHeader(undefined, 'abc123')).toBe(undefined);
});
});
@@ -145,6 +145,12 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
// Whether a client can drive a browser view is a property of that client,
// not of this server: a desktop shell and a browser tab can be connected to
// the same server at once. Recording it on the connection keeps the answer
// current without any enable/disable setting to go stale.
res.openchamberBrowserCapable = req.query?.browser === '1';
const clients = getOpenChamberEventClients();
clients.add(res);