Skip to content

Commit 85a9c88

Browse files
authored
Merge pull request #143 from redxzeta/sync/upstream-main-20260829-2347
chore: sync upstream/main (2026-08-29)
2 parents 5db1dc5 + 0ec4857 commit 85a9c88

127 files changed

Lines changed: 10112 additions & 509 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/upstream-sync-state.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"base": "built-from-scratch",
33
"upstreamRef": "upstream/main",
4-
"upstreamHead": "a93c47e275870f34ec7aa8cd72f2a0ff6246db7c",
5-
"syncedAt": "2026-08-23T00:00:00.000Z"
4+
"upstreamHead": "a8a7a5eae3a77de21988088f67e97eccf73eaaad",
5+
"syncedAt": "2026-08-30T01:13:06.965Z"
66
}

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ task-specific conversation, state, files, and history.
3232

3333
Keep the active conversation alongside the surface it is changing:
3434

35-
| Surface | Purpose |
36-
| ------------------ | --------------------------------------------------------------------------------------------- |
37-
| **Changes** | Inspect diffs, changed files, and review state. |
38-
| **Terminal** | Run commands in the project environment. |
39-
| **Browser** | Keep local previews, browser work, and the floating in-chat browser panel next to the thread. |
40-
| **Files / Editor** | Browse, inspect, and edit project files in context. |
41-
| **Git** | Work with branches, commits, pushes, and pull requests. |
35+
| Surface | Purpose |
36+
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
37+
| **Changes** | Inspect diffs, changed files, and review state. |
38+
| **Terminal** | Run commands in the project environment. |
39+
| **Browser** | Keep local previews and the floating in-chat browser next to the thread, with semantic or page-declared WebMCP tools for agents. |
40+
| **Files / Editor** | Browse, inspect, and edit project files in context. |
41+
| **Git** | Work with branches, commits, pushes, and pull requests. |
4242

4343
Split views, browser previews, and device previews keep execution and verification connected to the
4444
task that produced them.

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
},
1616
"dependencies": {
1717
"effect": "catalog:",
18-
"electron": "40.10.6",
18+
"electron": "43.4.1",
1919
"electron-updater": "^6.6.2"
2020
},
2121
"devDependencies": {

apps/desktop/src/browserAnnotations/guestPreload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
GUEST_ANNOTATION_PROTOCOL_VERSION,
3232
isGuestAnnotationCommand,
3333
} from "./guestProtocol";
34+
import "../browserWebMcp/guestBridge";
3435

3536
const HOST_ATTRIBUTE = "data-synara-browser-annotations";
3637
/** Mutation storms are coalesced into at most one marker re-resolve per window. */

apps/desktop/src/browserAutomation/browserManagerAutomation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const { browserSession, fromId, webContentsViewConstructor, willDownloadListener
1212
return {
1313
browserSession: {
1414
setUserAgent: vi.fn(),
15-
webRequest: { onBeforeSendHeaders: vi.fn() },
15+
webRequest: { onBeforeSendHeaders: vi.fn(), onHeadersReceived: vi.fn() },
1616
protocol: { handle: vi.fn(), unhandle: vi.fn() },
1717
on: vi.fn((event: string, listener: typeof willDownloadListener.current) => {
1818
if (event === "will-download") willDownloadListener.current = listener;

apps/desktop/src/browserAutomation/cdpRuntime.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,20 +125,26 @@ export const sendCdpCommand = async <Result = unknown>(
125125
ensureCdpAttached(runtime.webContents);
126126
try {
127127
const operation = runtime.webContents.debugger.sendCommand(method, params) as Promise<Result>;
128-
return await drainOnAbort(
129-
operation,
130-
signal,
131-
method === "Runtime.evaluate" || method === "Runtime.callFunctionOn"
132-
? () => {
133-
if (runtime.webContents.isDestroyed() || !runtime.webContents.debugger.isAttached())
128+
const terminatesJavaScript =
129+
method === "Runtime.evaluate" || method === "Runtime.callFunctionOn";
130+
const onAbort =
131+
errorContext.onAbort || terminatesJavaScript
132+
? async () => {
133+
await errorContext.onAbort?.();
134+
if (
135+
!terminatesJavaScript ||
136+
runtime.webContents.isDestroyed() ||
137+
!runtime.webContents.debugger.isAttached()
138+
) {
134139
return;
135-
return runtime.webContents.debugger.sendCommand("Runtime.terminateExecution").then(
140+
}
141+
await runtime.webContents.debugger.sendCommand("Runtime.terminateExecution").then(
136142
() => undefined,
137143
() => undefined,
138144
);
139145
}
140-
: errorContext.onAbort,
141-
);
146+
: undefined;
147+
return await drainOnAbort(operation, signal, onAbort);
142148
} catch (error) {
143149
if (signal?.aborted) throw abortReason(signal);
144150
if (error instanceof BrowserAutomationHostError) throw error;
@@ -201,6 +207,7 @@ export const callFunctionOn = async <Result = unknown>(
201207
readonly returnByValue?: boolean | undefined;
202208
readonly arguments?: readonly unknown[] | undefined;
203209
readonly effectMayHaveCommitted?: boolean | undefined;
210+
readonly onAbort?: (() => void | Promise<void>) | undefined;
204211
readonly signal?: AbortSignal | undefined;
205212
} = {},
206213
): Promise<CdpRemoteObject & { readonly value?: Result }> => {
@@ -220,6 +227,7 @@ export const callFunctionOn = async <Result = unknown>(
220227
// callFunctionOn executes caller-supplied JavaScript. Default to the safe
221228
// classification; observation-only callers can explicitly opt out.
222229
effectMayHaveCommitted: options.effectMayHaveCommitted ?? true,
230+
onAbort: options.onAbort,
223231
},
224232
);
225233
throwIfAborted(options.signal);

apps/desktop/src/browserAutomation/desktopBrowserAutomationHost.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const createWebContents = () => {
4040
const history = ["https://example.test/", "https://example.test/next"];
4141
let historyIndex = 0;
4242
const debuggerEvents = new EventEmitter();
43+
const webContentsEvents = new EventEmitter();
4344
const emitNavigation = (nextUrl: string) => {
4445
const loaderId = `loader-${crypto.randomUUID()}`;
4546
queueMicrotask(() => {
@@ -110,6 +111,9 @@ const createWebContents = () => {
110111
}
111112
if (method === "Runtime.evaluate") {
112113
const expression = String(params?.expression ?? "");
114+
if (expression === "globalThis.__synaraWebMcpBridgeV1") {
115+
return { result: { objectId: "webmcp-bridge", type: "object" } };
116+
}
113117
if (expression.includes("performance.getEntriesByType")) return { result: { value: 0 } };
114118
if (
115119
expression.includes('const key = "__synaraBrowserAutomationV1"') &&
@@ -172,6 +176,31 @@ const createWebContents = () => {
172176
if (method === "Page.createIsolatedWorld") return { executionContextId: 12 };
173177
if (method === "Runtime.callFunctionOn") {
174178
const declaration = String(params?.functionDeclaration ?? "");
179+
if (declaration.includes("return await this.list()")) {
180+
return {
181+
result: {
182+
value: {
183+
available: true,
184+
implementation: "compatibility",
185+
skippedToolCount: 0,
186+
tools: [
187+
{
188+
index: 0,
189+
signature: "a".repeat(64),
190+
name: "search",
191+
description: "Search this page.",
192+
inputSchema: { type: "object", properties: {} },
193+
origin: "https://example.test",
194+
annotations: { readOnlyHint: true, untrustedContentHint: true },
195+
},
196+
],
197+
},
198+
},
199+
};
200+
}
201+
if (declaration.includes("return await this.invoke")) {
202+
return { result: { value: { status: "completed", result: { ok: true } } } };
203+
}
175204
if (declaration.includes("const timeoutMs =") && declaration.includes("receivesEvents")) {
176205
const actionOptions = (
177206
params?.arguments as
@@ -240,6 +269,8 @@ const createWebContents = () => {
240269
return {};
241270
});
242271
return {
272+
once: webContentsEvents.once.bind(webContentsEvents),
273+
removeListener: webContentsEvents.removeListener.bind(webContentsEvents),
243274
isDestroyed: () => false,
244275
id: 101,
245276
focus: vi.fn(),
@@ -353,6 +384,210 @@ const createManager = () => {
353384
};
354385

355386
describe("DesktopBrowserAutomationHost", () => {
387+
it("releases another session's WebMCP discovery before navigating the shared tab", async () => {
388+
const { manager, webContents } = createManager();
389+
const host = new DesktopBrowserAutomationHost(manager);
390+
391+
await host.executeTool({
392+
sessionId: "session-webmcp-owner",
393+
provider: "codex",
394+
threadId: THREAD_ID,
395+
name: "browser_webmcp_tools",
396+
arguments: {},
397+
});
398+
await host.executeTool({
399+
sessionId: "session-shared-navigation",
400+
provider: "claude",
401+
threadId: THREAD_ID,
402+
name: "browser_navigate",
403+
arguments: { url: "https://example.test/next" },
404+
});
405+
406+
expect(webContents.debugger.sendCommand).toHaveBeenCalledWith("Runtime.releaseObject", {
407+
objectId: "webmcp-bridge",
408+
});
409+
});
410+
411+
it("invalidates WebMCP discovery when the page navigates outside a browser tool", async () => {
412+
const { manager, webContents } = createManager();
413+
const host = new DesktopBrowserAutomationHost(manager);
414+
const discovery = (await host.executeTool({
415+
sessionId: "session-webmcp-spontaneous-navigation",
416+
provider: "codex",
417+
threadId: THREAD_ID,
418+
name: "browser_webmcp_tools",
419+
arguments: {},
420+
})) as {
421+
readonly discoveryId: string;
422+
readonly tools: readonly [{ readonly toolId: string }];
423+
};
424+
425+
webContents.emitDebuggerMessage("Page.frameNavigated", {
426+
frame: { id: "main-frame", url: "https://example.test/delayed" },
427+
});
428+
await Promise.resolve();
429+
430+
await expect(
431+
host.executeTool({
432+
sessionId: "session-webmcp-spontaneous-navigation",
433+
provider: "codex",
434+
threadId: THREAD_ID,
435+
name: "browser_webmcp_call",
436+
arguments: {
437+
discoveryId: discovery.discoveryId,
438+
toolId: discovery.tools[0].toolId,
439+
arguments: {},
440+
},
441+
}),
442+
).rejects.toMatchObject({ browserError: { code: "BrowserWebMcpDiscoveryStale" } });
443+
});
444+
445+
it("preserves the current WebMCP discovery after mismatched caller tokens", async () => {
446+
const { manager, webContents } = createManager();
447+
const host = new DesktopBrowserAutomationHost(manager);
448+
const discovery = (await host.executeTool({
449+
sessionId: "session-webmcp-mismatch",
450+
provider: "codex",
451+
threadId: THREAD_ID,
452+
name: "browser_webmcp_tools",
453+
arguments: {},
454+
})) as {
455+
readonly discoveryId: string;
456+
readonly tools: readonly [{ readonly toolId: string }];
457+
};
458+
const call = (discoveryId: string, toolId: string) =>
459+
host.executeTool({
460+
sessionId: "session-webmcp-mismatch",
461+
provider: "codex",
462+
threadId: THREAD_ID,
463+
name: "browser_webmcp_call",
464+
arguments: { discoveryId, toolId, arguments: {} },
465+
});
466+
467+
await expect(call(crypto.randomUUID(), discovery.tools[0].toolId)).rejects.toMatchObject({
468+
browserError: { code: "BrowserWebMcpDiscoveryStale" },
469+
});
470+
await expect(call(discovery.discoveryId, "w2")).rejects.toMatchObject({
471+
browserError: { code: "BrowserWebMcpDiscoveryStale" },
472+
});
473+
expect(webContents.debugger.sendCommand).not.toHaveBeenCalledWith("Runtime.releaseObject", {
474+
objectId: "webmcp-bridge",
475+
});
476+
await expect(call(discovery.discoveryId, discovery.tools[0].toolId)).resolves.toMatchObject({
477+
status: "completed",
478+
result: { ok: true },
479+
});
480+
});
481+
482+
it("preserves an ambiguous WebMCP invocation error after navigation starts", async () => {
483+
const { manager, webContents } = createManager();
484+
const host = new DesktopBrowserAutomationHost(manager);
485+
const discovery = (await host.executeTool({
486+
sessionId: "session-webmcp-ambiguous",
487+
provider: "codex",
488+
threadId: THREAD_ID,
489+
name: "browser_webmcp_tools",
490+
arguments: {},
491+
})) as {
492+
readonly discoveryId: string;
493+
readonly tools: readonly [{ readonly toolId: string }];
494+
};
495+
const sendCommand = webContents.debugger.sendCommand as ReturnType<typeof vi.fn>;
496+
const original = sendCommand.getMockImplementation() as SendCommand;
497+
sendCommand.mockImplementation(async (method: string, params?: Record<string, unknown>) => {
498+
const declaration = String(params?.functionDeclaration ?? "");
499+
if (method === "Runtime.callFunctionOn" && declaration.includes("return await this.invoke")) {
500+
webContents.reload();
501+
await Promise.resolve();
502+
throw new Error("execution context was destroyed");
503+
}
504+
return original(method, params);
505+
});
506+
507+
await expect(
508+
host.executeTool({
509+
sessionId: "session-webmcp-ambiguous",
510+
provider: "codex",
511+
threadId: THREAD_ID,
512+
name: "browser_webmcp_call",
513+
arguments: {
514+
discoveryId: discovery.discoveryId,
515+
toolId: discovery.tools[0].toolId,
516+
arguments: {},
517+
},
518+
}),
519+
).rejects.toMatchObject({
520+
browserError: {
521+
code: "BrowserAmbiguousResult",
522+
retryable: false,
523+
effectMayHaveCommitted: true,
524+
},
525+
});
526+
});
527+
528+
it("waits for draining CDP work before releasing WebMCP discoveries on disposal", async () => {
529+
const { manager, webContents } = createManager();
530+
const host = new DesktopBrowserAutomationHost(manager);
531+
await host.executeTool({
532+
sessionId: "session-webmcp-dispose",
533+
provider: "codex",
534+
threadId: THREAD_ID,
535+
name: "browser_webmcp_tools",
536+
arguments: {},
537+
});
538+
539+
const sendCommand = webContents.debugger.sendCommand as ReturnType<typeof vi.fn>;
540+
const original = sendCommand.getMockImplementation() as SendCommand;
541+
const evaluation = deferred<unknown>();
542+
sendCommand.mockImplementation((method: string, params?: Record<string, unknown>) => {
543+
if (method === "Runtime.evaluate" && params?.expression === "({answer: 42})") {
544+
return evaluation.promise;
545+
}
546+
return original(method, params);
547+
});
548+
const controller = new AbortController();
549+
const operation = host.executeTool({
550+
sessionId: "session-webmcp-dispose",
551+
provider: "codex",
552+
threadId: THREAD_ID,
553+
name: "browser_evaluate",
554+
arguments: { expression: "({answer: 42})" },
555+
signal: controller.signal,
556+
});
557+
await vi.waitFor(() =>
558+
expect(sendCommand).toHaveBeenCalledWith(
559+
"Runtime.evaluate",
560+
expect.objectContaining({ expression: "({answer: 42})" }),
561+
),
562+
);
563+
controller.abort();
564+
await expect(operation).rejects.toMatchObject({
565+
browserError: { code: "BrowserCancelled" },
566+
});
567+
568+
const disposed = host.dispose();
569+
await Promise.resolve();
570+
expect(disposed).not.toBe(undefined);
571+
expect(sendCommand).not.toHaveBeenCalledWith("Runtime.releaseObject", {
572+
objectId: "webmcp-bridge",
573+
});
574+
575+
evaluation.resolve({ result: { value: { answer: 42 } } });
576+
await disposed;
577+
expect(sendCommand).toHaveBeenCalledWith("Runtime.releaseObject", {
578+
objectId: "webmcp-bridge",
579+
});
580+
await expect(
581+
host.executeTool({
582+
sessionId: "session-after-dispose",
583+
provider: "codex",
584+
threadId: THREAD_ID,
585+
name: "browser_status",
586+
arguments: {},
587+
}),
588+
).rejects.toMatchObject({ browserError: { code: "BrowserHostUnavailable" } });
589+
});
590+
356591
it("blocks new DOM tools while a human annotation picker is interactive", async () => {
357592
const { manager, raw } = createManager();
358593
raw.isAnnotationInteractive.mockReturnValue(true);

0 commit comments

Comments
 (0)