Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/devframe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The supported baseline is Devframe 0.9.10, DevTools Kit 0.6.1 and Vite 8.1.5. Up
5. Open the management panel at the host URL. Connect the extension popup to the same host and authenticate with the Devframe code printed in the host's stderr log for that connection. Keep the inspected application tab active when approving.
6. Ask the agent for `browser.request_access`, review its requested level/navigation policy in the extension popup, and approve the current tab, group or window. Run `browser.snapshot` and `browser.batch` from that same agent session.

The example's identity file lives under `examples/devframe/dist/identity`. Preserve it and the extension's storage to retain pairing across restarts. The standalone panel requests review; the popup performs the final approval and validates the selected Chrome scope.
Set `CDB_IDENTITY_DIRECTORY` to a persistent directory chosen by the host. The default remains `examples/devframe/dist/identity` for existing example installations; use a directory outside build output for a long-lived installation. Preserve it and the extension's storage to retain pairing across restarts. The standalone panel requests review; the popup performs the final approval and validates the selected Chrome scope.

`pnpm --filter @chrome-debugger-bridge-example/devframe smoke` checks the served panel, page-script assets and native catalogue without a browser. The focused `tests/e2e/devframe-native.test.ts` exercises real Chromium, closed shadow roots, nested cross-origin frames, overlapping grants, live group membership and provider recovery through Devframe RPC and native MCP.

Expand Down
8 changes: 5 additions & 3 deletions examples/devframe/host.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { join } from 'node:path';
import { styleText } from 'node:util';

import { createFileBrokerIdentityStore, defineBroker } from '@dvcol/cdb-broker';
import { createFileBrokerIdentityStore, defineBroker, normalizeBrowserControlError } from '@dvcol/cdb-broker';
import { Server } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { createDevServer } from 'devframe/adapters/dev';
Expand All @@ -10,8 +10,9 @@ import { getTempAuthCode } from 'devframe/node/auth';
import { createDevframeExample } from './devframe.ts';

async function main(): Promise<void> {
const identityDirectory = process.env.CDB_IDENTITY_DIRECTORY ?? join(import.meta.dirname, 'dist', 'identity');
const example = createDevframeExample(defineBroker({
identityStore: await createFileBrokerIdentityStore(join(import.meta.dirname, 'dist', 'identity')),
identityStore: await createFileBrokerIdentityStore(identityDirectory),
navigation: { default: 'same-origin', allowed: ['same-origin', 'follow-tab'] },
}));
const extensionOrigin = process.env.CDB_EXTENSION_ORIGIN;
Expand All @@ -34,14 +35,15 @@ async function main(): Promise<void> {
});
const broker = example.service.broker;
const agent = { id: 'example-stdio-agent', label: 'Example MCP agent' };
await broker.connectSession(agent);
const mcp = new Server({ name: 'cdb-devframe-example', version: example.definition.version }, { capabilities: { tools: {} } });
mcp.setRequestHandler('tools/list', async () => ({ tools: broker.tools.map(tool => ({ ...tool, inputSchema: { ...tool.inputSchema, type: 'object' as const } })) }));
mcp.setRequestHandler('tools/call', async (request, context) => {
try {
const value = await broker.invoke(agent, request.params.name, request.params.arguments, { signal: context.mcpReq.signal });
return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value) }] };
} catch (error) {
return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify({ ...(error as object), message: error instanceof Error ? error.message : String(error) }) }] };
return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(normalizeBrowserControlError(error)) }] };
}
});
let closing = false;
Expand Down
2 changes: 2 additions & 0 deletions packages/broker/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,5 @@ export interface BrokerPeer {
export type BrokerTool = Omit<CdbToolDefinition, 'invoke' | 'mcpInputSchema'>;

export type { AgentAuthenticationTranscript, AuthorityBinding, GrantRequestClaim, LogicalSessionCredential };

export { type BrowserControlError, type BrowserControlErrorData, browserControlToolError, normalizeBrowserControlError } from './error.js';
45 changes: 44 additions & 1 deletion packages/broker/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,51 @@ export class BrokerError extends Error {
readonly retryable = false,
readonly retryAfterMilliseconds?: number,
readonly details?: Readonly<Record<string, unknown>>,
options?: ErrorOptions,
) {
super(message);
super(message, options);
this.name = 'BrokerError';
}
}

/** Public error data sent over RPC. Local exception causes and stacks stay with the host. */
export interface BrowserControlErrorData {
readonly code: string;
readonly message: string;
readonly retryable: boolean;
readonly retryAfterMilliseconds?: number;
readonly details?: Readonly<Record<string, unknown>>;
}

/** @deprecated Use BrowserControlErrorData for the serialized error shape. */
export type BrowserControlError = BrowserControlErrorData;

/** Preserve protocol errors and semantic MCP errors without copying their evolving code catalogue. */
export function normalizeBrowserControlError(error: unknown): BrowserControlErrorData {
const record = error !== null && typeof error === 'object' ? error as Record<string, unknown> : {};
const retryDelay = record.retryAfterMilliseconds ?? record.retryAfterMs;
return {
code: typeof record.code === 'string' ? record.code : 'CDB_OPERATION_FAILED',
message: typeof record.message === 'string' ? record.message : String(error),
retryable: typeof record.retryable === 'boolean' ? record.retryable : false,
...(typeof retryDelay === 'number' ? { retryAfterMilliseconds: retryDelay } : {}),
...(record.details !== null && typeof record.details === 'object' && !Array.isArray(record.details) ? { details: record.details as Record<string, unknown> } : {}),
};
}

/** Returns a semantic failure only for an MCP error result; successful observations are never parsed as errors. */
export function browserControlToolError(result: unknown): BrowserControlErrorData | undefined {
if (result === null || typeof result !== 'object' || !('isError' in result) || result.isError !== true) return undefined;
if ('content' in result && Array.isArray(result.content)) {
for (const content of result.content as unknown[]) {
if (content === null || typeof content !== 'object' || !('type' in content) || content.type !== 'text' || !('text' in content) || typeof content.text !== 'string') continue;
try {
const payload: unknown = JSON.parse(content.text);
if (payload !== null && typeof payload === 'object' && 'code' in payload && typeof payload.code === 'string') return normalizeBrowserControlError(payload);
} catch {
/** Text-only MCP failures do not contain a structured protocol error. */
}
}
}
return normalizeBrowserControlError(new Error('The browser tool failed.'));
}
16 changes: 13 additions & 3 deletions packages/broker/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AgentTargetConnection, AuthorityBinding, GrantedTargetReference, GrantRequest, GrantRequestClaim, GrantRequestProvider, JsonValue, LogicalSessionCredential } from '@dvcol/cdb';
import type { CdbToolInvocationContext, McpChromeDebuggerBridgeClient } from '@dvcol/cdb-mcp';
import type { CdbToolDefinition, CdbToolInvocationContext, McpChromeDebuggerBridgeClient } from '@dvcol/cdb-mcp';
import type { AgentAuthenticationTranscript, BrokerAuthenticationClaims } from '@dvcol/cdb/authentication';

import type { BrokerDefinition } from './config.js';
Expand All @@ -18,6 +18,15 @@ import { createBrokerSession } from './session-client.js';

const accessLevels: readonly AccessLevel[] = ['observe', 'inspect', 'interact', 'debug', 'unsafe'];

function toolDescriptor({ name, description, inputSchema, safety }: CdbToolDefinition): BrokerTool {
return {
name,
description,
inputSchema,
...(safety === undefined ? {} : { safety }),
};
}

interface Provider {
readonly registration: ProviderRegistration;
readonly peer: BrokerPeer;
Expand Down Expand Up @@ -287,7 +296,7 @@ export async function createBroker(configuration: BrokerDefinition = {}): Promis
pendingRequests.delete(requestId);
const code = reason === 'expired' ? 'ACCESS_REQUEST_TIMEOUT' : reason === 'rejected' ? 'ACCESS_REQUEST_REJECTED' : 'ACCESS_REQUEST_CANCELLED';
const message = reason === 'expired' ? 'The browser access request expired.' : reason === 'rejected' ? 'The browser access request was rejected.' : 'The browser access request was cancelled.';
pending.reject(new BrokerError(error?.code ?? code, error?.message ?? message, error?.retryable ?? false));
pending.reject(new BrokerError(error?.code ?? code, error?.message ?? message, error?.retryable ?? false, undefined, undefined, { cause: error }));
}
changed();
});
Expand Down Expand Up @@ -431,6 +440,7 @@ export async function createBroker(configuration: BrokerDefinition = {}): Promis

const requestTool: BrokerTool = {
name: 'browser.request_access',
safety: 'action',
description: 'Request user-approved browser control. Reuse a current approved target or request another tab. No browser authority exists until approval completes.',
inputSchema: {
type: 'object',
Expand Down Expand Up @@ -514,7 +524,7 @@ export async function createBroker(configuration: BrokerDefinition = {}): Promis
toolProvider: { name: '@dvcol/cdb', version: packageManifest.version },
connectSession,
snapshot,
tools: [requestTool, ...descriptors.definitions.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }))],
tools: [requestTool, ...descriptors.definitions.map(toolDescriptor)],
subscribe(listener: (state: BrokerState) => void) {
ensureActive();
listeners.add(listener);
Expand Down
30 changes: 30 additions & 0 deletions packages/broker/test/error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, it } from 'vitest';

import { BrokerError, browserControlToolError, normalizeBrowserControlError } from '../src/error.js';

it('preserves arbitrary protocol codes, retry delays and uncertain dispatch details', () => {
expect.assertions(2);
const failure = { code: 'MCP_ACTION_OUTCOME_UNKNOWN', message: 'Input may have dispatched', retryable: false, details: { dispatched: true, outcome: 'uncertain' } };
expect(normalizeBrowserControlError(Object.assign(new Error(failure.message), failure))).toEqual(failure);
expect(normalizeBrowserControlError({ code: 'FUTURE_RECOVERABLE_ERROR', message: 'Try later', retryable: true, retryAfterMs: 10 })).toEqual({ code: 'FUTURE_RECOVERABLE_ERROR', message: 'Try later', retryable: true, retryAfterMilliseconds: 10 });
});

it('inspects semantic failures while leaving successful text payloads alone', () => {
expect.assertions(4);
const failure = { code: 'MCP_LOCATOR_NOT_FOUND', message: 'Not found', retryable: true };
const content = [{ type: 'text', text: JSON.stringify(failure) }];
expect(browserControlToolError({ content })).toBeUndefined();
expect(browserControlToolError({ content, isError: true })).toEqual(failure);
expect(browserControlToolError({ content: [{ type: 'text', text: 'Unstructured' }], isError: true })).toEqual({ code: 'CDB_OPERATION_FAILED', message: 'The browser tool failed.', retryable: false });
expect(normalizeBrowserControlError('Failed')).toEqual({ code: 'CDB_OPERATION_FAILED', message: 'Failed', retryable: false });
});

it('retains the local exception cause while serializing only public error data', () => {
expect.assertions(4);
const cause = new Error('Private storage path and implementation detail');
const error = new BrokerError('STORAGE_FAILED', 'Could not save the grant.', true, 20, { operation: 'save' }, { cause });
expect(error).toBeInstanceOf(Error);
expect(error.cause).toBe(cause);
expect(normalizeBrowserControlError(error)).toEqual({ code: 'STORAGE_FAILED', message: 'Could not save the grant.', retryable: true, retryAfterMilliseconds: 20, details: { operation: 'save' } });
expect(JSON.stringify(normalizeBrowserControlError(error))).not.toContain(cause.message);
});
18 changes: 18 additions & 0 deletions packages/devframe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,21 @@ See the [runnable example](../../examples/devframe/README.md).
Notification descriptions and approved-tab counts update through the existing Devframe message handle. Changed content retains its message ID and follows the host's normal notification behavior, including resurfacing a dismissed toast. Unchanged broker publications do not update the message. Request completion, expiry and disposal remove its message and command.

The public validation workspace backports Devframe's toast-removal fix to hub-ui 0.9.10 using [a temporary pnpm patch](https://github.com/dvcol/chrome-debugger-bridge/blob/main/patches/README.md). This workspace patch is not inherited by consumers of the published CDB package; embedding applications using that hub-ui version must apply the patch themselves until adopting an upstream version containing the fix.

### Connection-bound clients

Use `createCdbConnection` from `@dvcol/cdb-devframe/connection` when the embedding
host replaces Devframe peers during reconnect. Call `attach(peer)` from the host's
connection callback, `disconnected()` on transport loss, and `dispose()` at
shutdown. Attaching is synchronous; browser subscriptions and optional logical
session readiness are lazy and do not delay ordinary host traffic.

The handle supplies the panel's `watch`, `snapshot` and management operations,
plus browser invocation and operation-scoped cancellation. It owns initial state,
subscription deduplication, stale-callback fencing and cleanup. It never closes
the shared host transport or replays a dispatched operation.

For agent callers, supply `session` with a credential store/key and principal
metadata. Use a distinct authenticated peer and handle for each principal; do not
share one logical session between MCP callers. Reconnecting that principal
resumes its own credential. Management and provider connections omit `session`.
4 changes: 4 additions & 0 deletions packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"types": "./dist/client.d.mts",
"import": "./dist/client.mjs"
},
"./connection": {
"types": "./dist/connection.d.mts",
"import": "./dist/connection.mjs"
},
"./panel": {
"types": "./dist/panel.d.mts",
"import": "./dist/panel.mjs"
Expand Down
14 changes: 10 additions & 4 deletions packages/devframe/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export interface CdbClient {

const clients = new WeakMap<object, CdbClient>();

function replyValue<Value>(result: CdbReply<Value>): Value {
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
}

export interface CdbClientSession {
connect: (client: CdbClient) => Promise<void>;
terminate: (client: CdbClient) => Promise<void>;
Expand Down Expand Up @@ -95,8 +100,7 @@ export function createCdbClient(client: CdbDevframeClient): CdbClient {
async function call<Value>(name: string, ...arguments_: unknown[]): Promise<Value> {
if (disposed) throw new Error('The CDB client has been disposed.');
const result = await rpc.call(name, ...arguments_) as CdbReply<Value>;
if (!result.ok) throw Object.assign(new Error(result.error.message), result.error);
return result.value;
return replyValue(result);
}

function publish(value: BrokerState): void {
Expand Down Expand Up @@ -235,14 +239,16 @@ export function createCdbClient(client: CdbDevframeClient): CdbClient {
},
async dispose() {
if (disposed) return;
provider?.close(1000, 'CDB client disposed');
try {
if (watching !== undefined) await call('unwatch');
provider?.close(1000, 'CDB client disposed');
} finally {
disposed = true;
clients.delete(client);
const subscribed = watching !== undefined;
stateListeners.clear();
watching = undefined;
state = undefined;
if (subscribed) replyValue(await rpc.call('unwatch') as CdbReply<void>);
}
},
};
Expand Down
Loading
Loading