- commit
- 07895cd
- parent
- f684414
- author
- codex@macbookpro
- date
- 2026-03-26 19:49:59 +0800 CST
feat: complete browser relay control path
26 files changed,
+3724,
-291
+4,
-4
1@@ -42,7 +42,7 @@
2 - `GET /describe/business`
3 - 或 `GET /describe/control`
4 4. 如有需要,再调 `GET /v1/capabilities`
5-5. 完成能力感知后,再执行业务查询、Claude 浏览器动作或控制动作;如果要调用 `/v1/exec` 或 `/v1/files/*`,必须带 `Authorization: Bearer <BAA_SHARED_TOKEN>`
6+5. 完成能力感知后,再执行业务查询、通用 browser request、browser/plugin 管理动作或控制动作;如果要调用 `/v1/exec` 或 `/v1/files/*`,必须带 `Authorization: Bearer <BAA_SHARED_TOKEN>`
7
8 ## 当前目录结构
9
10@@ -94,7 +94,7 @@ docs/
11 - 运行中的浏览器插件代码以 [`plugins/baa-firefox`](./plugins/baa-firefox) 为准
12 - 浏览器桥接正式模型已经固定为“登录态元数据持久化 + 单平台单空壳页 + 浏览器本地代发”;页面对话 UI 不是正式主能力
13 - `GET /v1/browser` 是当前正式浏览器桥接读面:返回活跃 bridge 和持久化登录态记录,只暴露 `account`、凭证指纹、端点元数据与 `fresh/stale/lost`
14-- `POST` / `GET /v1/browser/claude/*` 是当前唯一正式浏览器代发面:请求经本地 `/ws/firefox` 转发到 Firefox 插件在浏览器本地代发,`conductor` 不直接持有原始凭证
15+- 正式浏览器写接口已经收口到 `POST /v1/browser/request`、`POST /v1/browser/request/cancel` 和 `POST /v1/browser/actions`;`/v1/browser/claude/*` 只保留为 Claude legacy 包装与辅助读
16 - `codexd` 目前还是半成品,不是已上线组件
17 - `codexd` 必须作为独立常驻进程存在,不接受长期内嵌到 `conductor-daemon`
18 - `codexd` 后续默认以 `app-server` 为主,不以 TUI 或 `exec` 作为主双工接口
19@@ -103,7 +103,7 @@ docs/
20
21 | 面 | 地址 | 定位 | 说明 |
22 | --- | --- | --- | --- |
23-| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/status`、`/v1/status/ui`、`/v1/browser/*`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write`;其中 `GET /v1/browser` 返回浏览器登录态元数据与持久化状态,Claude 专用 `/v1/browser/claude/*` 则走本地插件代发,host-ops 统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>` |
24+| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/status`、`/v1/status/ui`、`/v1/browser/*`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write`;其中 `GET /v1/browser` 返回浏览器登录态元数据与持久化状态,`POST /v1/browser/request` / `cancel` / `actions` 走本地插件代发,Claude 专用 `/v1/browser/claude/*` 只保留 legacy 包装,host-ops 统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>` |
25 | public host | `https://conductor.makefile.so` | 唯一公网域名 | 由 VPS Nginx 回源到 `100.71.210.78:4317`;`/v1/exec` 和 `/v1/files/*` 不再允许匿名调用 |
26 | local status view | `http://100.71.210.78:4318` | 本地只读观察兼容层 | 显式 opt-in 保留,继续提供 `/describe`、`/v1/status`、`/v1/status/ui` 和 `/ui` 等 legacy 合同,不是主控制面 |
27
28@@ -124,7 +124,7 @@ legacy 兼容说明:
29
30 - 保持 `mini` launchd、自启动和本地探针稳定
31 - 保持 `conductor.makefile.so -> 100.71.210.78:4317` 的链路稳定
32-- 保持 `/v1/browser`、浏览器登录态持久化与 Claude 本地代发合同稳定
33+- 保持 `/v1/browser`、通用 browser request/SSE、浏览器登录态持久化与 Claude legacy 包装合同稳定
34 - 保持 `pnpm smoke` 和 browser-control e2e smoke 对浏览器链路持续可回归
35
36 ## 当前已知 gap
1@@ -0,0 +1,608 @@
2+export type BrowserRequestCircuitState = "closed" | "half_open" | "open";
3+export type BrowserRequestLeaseStatus = "cancelled" | "failure" | "success";
4+
5+type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
6+
7+export interface BrowserRequestTarget {
8+ clientId: string;
9+ platform: string;
10+}
11+
12+export interface BrowserRequestStreamPolicyConfig {
13+ idleTimeoutMs: number;
14+ maxBufferedBytes: number;
15+ maxBufferedEvents: number;
16+ openTimeoutMs: number;
17+}
18+
19+export interface BrowserRequestPolicyConfig {
20+ backoff: {
21+ baseMs: number;
22+ maxMs: number;
23+ };
24+ circuitBreaker: {
25+ failureThreshold: number;
26+ openMs: number;
27+ };
28+ concurrency: {
29+ maxInFlightPerClientPlatform: number;
30+ };
31+ jitter: {
32+ maxMs: number;
33+ minMs: number;
34+ muMs: number;
35+ sigmaMs: number;
36+ };
37+ rateLimit: {
38+ requestsPerMinutePerPlatform: number;
39+ windowMs: number;
40+ };
41+ stream: BrowserRequestStreamPolicyConfig;
42+}
43+
44+export interface BrowserRequestLeaseOutcome {
45+ code?: string | null;
46+ message?: string | null;
47+ status: BrowserRequestLeaseStatus;
48+}
49+
50+export interface BrowserRequestAdmission {
51+ admittedAt: number;
52+ backoffDelayMs: number;
53+ circuitState: BrowserRequestCircuitState;
54+ jitterDelayMs: number;
55+ platform: string;
56+ queueDelayMs: number;
57+ rateLimitDelayMs: number;
58+ requestId: string;
59+ requestedAt: number;
60+ targetClientId: string;
61+}
62+
63+export interface BrowserRequestPolicySnapshot {
64+ defaults: BrowserRequestPolicyConfig;
65+ platforms: Array<{
66+ lastDispatchedAt: number | null;
67+ platform: string;
68+ recentDispatchCount: number;
69+ waiting: number;
70+ }>;
71+ targets: Array<{
72+ backoffUntil: number | null;
73+ circuitRetryAt: number | null;
74+ circuitState: BrowserRequestCircuitState;
75+ clientId: string;
76+ consecutiveFailures: number;
77+ inFlight: number;
78+ lastError: string | null;
79+ lastFailureAt: number | null;
80+ lastSuccessAt: number | null;
81+ platform: string;
82+ waiting: number;
83+ }>;
84+}
85+
86+export interface BrowserRequestPolicyLease {
87+ readonly admission: BrowserRequestAdmission;
88+ readonly target: BrowserRequestTarget;
89+ complete(outcome: BrowserRequestLeaseOutcome): void;
90+}
91+
92+export interface BrowserRequestPolicyControllerOptions {
93+ config?: Partial<BrowserRequestPolicyConfig>;
94+ now?: () => number;
95+ random?: () => number;
96+ setTimeoutImpl?: (handler: () => void, timeoutMs: number) => TimeoutHandle;
97+}
98+
99+interface BrowserRequestPlatformState {
100+ busy: boolean;
101+ dispatches: number[];
102+ lastDispatchedAt: number | null;
103+ waiters: Array<() => void>;
104+}
105+
106+interface BrowserRequestTargetState {
107+ backoffUntil: number | null;
108+ circuitRetryAt: number | null;
109+ circuitState: BrowserRequestCircuitState;
110+ consecutiveFailures: number;
111+ inFlight: number;
112+ lastError: string | null;
113+ lastFailureAt: number | null;
114+ lastSuccessAt: number | null;
115+ waiters: Array<() => void>;
116+}
117+
118+const DEFAULT_BROWSER_REQUEST_POLICY: BrowserRequestPolicyConfig = {
119+ backoff: {
120+ baseMs: 1_000,
121+ maxMs: 60_000
122+ },
123+ circuitBreaker: {
124+ failureThreshold: 5,
125+ openMs: 60_000
126+ },
127+ concurrency: {
128+ maxInFlightPerClientPlatform: 1
129+ },
130+ jitter: {
131+ maxMs: 5_000,
132+ minMs: 1_000,
133+ muMs: 2_000,
134+ sigmaMs: 500
135+ },
136+ rateLimit: {
137+ requestsPerMinutePerPlatform: 10,
138+ windowMs: 60_000
139+ },
140+ stream: {
141+ idleTimeoutMs: 30_000,
142+ maxBufferedBytes: 512 * 1024,
143+ maxBufferedEvents: 256,
144+ openTimeoutMs: 10_000
145+ }
146+};
147+
148+function clonePolicyConfig(input: BrowserRequestPolicyConfig): BrowserRequestPolicyConfig {
149+ return {
150+ backoff: {
151+ baseMs: input.backoff.baseMs,
152+ maxMs: input.backoff.maxMs
153+ },
154+ circuitBreaker: {
155+ failureThreshold: input.circuitBreaker.failureThreshold,
156+ openMs: input.circuitBreaker.openMs
157+ },
158+ concurrency: {
159+ maxInFlightPerClientPlatform: input.concurrency.maxInFlightPerClientPlatform
160+ },
161+ jitter: {
162+ maxMs: input.jitter.maxMs,
163+ minMs: input.jitter.minMs,
164+ muMs: input.jitter.muMs,
165+ sigmaMs: input.jitter.sigmaMs
166+ },
167+ rateLimit: {
168+ requestsPerMinutePerPlatform: input.rateLimit.requestsPerMinutePerPlatform,
169+ windowMs: input.rateLimit.windowMs
170+ },
171+ stream: {
172+ idleTimeoutMs: input.stream.idleTimeoutMs,
173+ maxBufferedBytes: input.stream.maxBufferedBytes,
174+ maxBufferedEvents: input.stream.maxBufferedEvents,
175+ openTimeoutMs: input.stream.openTimeoutMs
176+ }
177+ };
178+}
179+
180+function mergePolicyConfig(
181+ defaults: BrowserRequestPolicyConfig,
182+ overrides: Partial<BrowserRequestPolicyConfig>
183+): BrowserRequestPolicyConfig {
184+ return {
185+ backoff: {
186+ ...defaults.backoff,
187+ ...(overrides.backoff ?? {})
188+ },
189+ circuitBreaker: {
190+ ...defaults.circuitBreaker,
191+ ...(overrides.circuitBreaker ?? {})
192+ },
193+ concurrency: {
194+ ...defaults.concurrency,
195+ ...(overrides.concurrency ?? {})
196+ },
197+ jitter: {
198+ ...defaults.jitter,
199+ ...(overrides.jitter ?? {})
200+ },
201+ rateLimit: {
202+ ...defaults.rateLimit,
203+ ...(overrides.rateLimit ?? {})
204+ },
205+ stream: {
206+ ...defaults.stream,
207+ ...(overrides.stream ?? {})
208+ }
209+ };
210+}
211+
212+function normalizeOptionalString(value: string | null | undefined): string | null {
213+ if (value == null) {
214+ return null;
215+ }
216+
217+ const normalized = value.trim();
218+ return normalized === "" ? null : normalized;
219+}
220+
221+function buildTargetKey(target: BrowserRequestTarget): string {
222+ return `${target.clientId}\u0000${target.platform}`;
223+}
224+
225+function prunePlatformDispatches(
226+ state: BrowserRequestPlatformState,
227+ now: number,
228+ windowMs: number
229+): void {
230+ const cutoff = now - windowMs;
231+ state.dispatches = state.dispatches.filter((timestamp) => timestamp > cutoff);
232+}
233+
234+function clamp(value: number, min: number, max: number): number {
235+ return Math.min(max, Math.max(min, value));
236+}
237+
238+function buildTimeoutPromise(
239+ setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle,
240+ timeoutMs: number
241+): Promise<void> {
242+ return new Promise((resolve) => {
243+ setTimeoutImpl(resolve, Math.max(0, timeoutMs));
244+ });
245+}
246+
247+class BrowserRequestPolicyLeaseImpl implements BrowserRequestPolicyLease {
248+ private completed = false;
249+
250+ constructor(
251+ readonly admission: BrowserRequestAdmission,
252+ readonly target: BrowserRequestTarget,
253+ private readonly onComplete: (outcome: BrowserRequestLeaseOutcome) => void
254+ ) {}
255+
256+ complete(outcome: BrowserRequestLeaseOutcome): void {
257+ if (this.completed) {
258+ return;
259+ }
260+
261+ this.completed = true;
262+ this.onComplete(outcome);
263+ }
264+}
265+
266+export class BrowserRequestPolicyError extends Error {
267+ constructor(
268+ readonly code: string,
269+ message: string,
270+ readonly details: Record<string, unknown> = {}
271+ ) {
272+ super(message);
273+ this.name = "BrowserRequestPolicyError";
274+ }
275+}
276+
277+export class BrowserRequestPolicyController {
278+ private readonly config: BrowserRequestPolicyConfig;
279+ private readonly now: () => number;
280+ private readonly platforms = new Map<string, BrowserRequestPlatformState>();
281+ private readonly random: () => number;
282+ private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
283+ private readonly targets = new Map<string, BrowserRequestTargetState>();
284+
285+ constructor(options: BrowserRequestPolicyControllerOptions = {}) {
286+ this.config = clonePolicyConfig(
287+ mergePolicyConfig(DEFAULT_BROWSER_REQUEST_POLICY, options.config ?? {})
288+ );
289+ this.now = options.now ?? (() => Date.now());
290+ this.random = options.random ?? (() => Math.random());
291+ this.setTimeoutImpl = options.setTimeoutImpl ?? ((handler, timeoutMs) => globalThis.setTimeout(handler, timeoutMs));
292+ }
293+
294+ getConfig(): BrowserRequestPolicyConfig {
295+ return clonePolicyConfig(this.config);
296+ }
297+
298+ getSnapshot(): BrowserRequestPolicySnapshot {
299+ const now = this.now();
300+ const platforms = [...this.platforms.entries()]
301+ .map(([platform, state]) => {
302+ prunePlatformDispatches(state, now, this.config.rateLimit.windowMs);
303+ return {
304+ lastDispatchedAt: state.lastDispatchedAt,
305+ platform,
306+ recentDispatchCount: state.dispatches.length,
307+ waiting: state.waiters.length
308+ };
309+ })
310+ .sort((left, right) => left.platform.localeCompare(right.platform));
311+ const targets = [...this.targets.entries()]
312+ .map(([key, state]) => {
313+ const [clientId, platform] = key.split("\u0000");
314+ return {
315+ backoffUntil: state.backoffUntil,
316+ circuitRetryAt: state.circuitRetryAt,
317+ circuitState: state.circuitState,
318+ clientId: clientId ?? "",
319+ consecutiveFailures: state.consecutiveFailures,
320+ inFlight: state.inFlight,
321+ lastError: state.lastError,
322+ lastFailureAt: state.lastFailureAt,
323+ lastSuccessAt: state.lastSuccessAt,
324+ platform: platform ?? "",
325+ waiting: state.waiters.length
326+ };
327+ })
328+ .sort((left, right) => {
329+ const clientCompare = left.clientId.localeCompare(right.clientId);
330+ return clientCompare === 0 ? left.platform.localeCompare(right.platform) : clientCompare;
331+ });
332+
333+ return {
334+ defaults: this.getConfig(),
335+ platforms,
336+ targets
337+ };
338+ }
339+
340+ async beginRequest(
341+ target: BrowserRequestTarget,
342+ requestId: string
343+ ): Promise<BrowserRequestPolicyLease> {
344+ const normalizedTarget = this.normalizeTarget(target);
345+ const requestedAt = this.now();
346+ const targetState = this.getTargetState(normalizedTarget);
347+ await this.acquireTargetSlot(targetState);
348+ let admission: BrowserRequestAdmission | null = null;
349+
350+ try {
351+ admission = await this.admitRequest(normalizedTarget, targetState, requestId, requestedAt);
352+ } catch (error) {
353+ this.releaseTargetSlot(targetState);
354+ throw error;
355+ }
356+
357+ return new BrowserRequestPolicyLeaseImpl(admission, normalizedTarget, (outcome) => {
358+ this.completeRequest(normalizedTarget, targetState, outcome);
359+ });
360+ }
361+
362+ private normalizeTarget(target: BrowserRequestTarget): BrowserRequestTarget {
363+ const clientId = normalizeOptionalString(target.clientId);
364+ const platform = normalizeOptionalString(target.platform);
365+
366+ if (clientId == null || platform == null) {
367+ throw new Error("Browser request policy requires non-empty clientId and platform.");
368+ }
369+
370+ return {
371+ clientId,
372+ platform
373+ };
374+ }
375+
376+ private getPlatformState(platform: string): BrowserRequestPlatformState {
377+ const existing = this.platforms.get(platform);
378+
379+ if (existing != null) {
380+ return existing;
381+ }
382+
383+ const created: BrowserRequestPlatformState = {
384+ busy: false,
385+ dispatches: [],
386+ lastDispatchedAt: null,
387+ waiters: []
388+ };
389+ this.platforms.set(platform, created);
390+ return created;
391+ }
392+
393+ private getTargetState(target: BrowserRequestTarget): BrowserRequestTargetState {
394+ const key = buildTargetKey(target);
395+ const existing = this.targets.get(key);
396+
397+ if (existing != null) {
398+ return existing;
399+ }
400+
401+ const created: BrowserRequestTargetState = {
402+ backoffUntil: null,
403+ circuitRetryAt: null,
404+ circuitState: "closed",
405+ consecutiveFailures: 0,
406+ inFlight: 0,
407+ lastError: null,
408+ lastFailureAt: null,
409+ lastSuccessAt: null,
410+ waiters: []
411+ };
412+ this.targets.set(key, created);
413+ return created;
414+ }
415+
416+ private async acquireTargetSlot(state: BrowserRequestTargetState): Promise<void> {
417+ if (
418+ state.inFlight < this.config.concurrency.maxInFlightPerClientPlatform
419+ && state.waiters.length === 0
420+ ) {
421+ state.inFlight += 1;
422+ return;
423+ }
424+
425+ await new Promise<void>((resolve) => {
426+ state.waiters.push(resolve);
427+ });
428+ }
429+
430+ private releaseTargetSlot(state: BrowserRequestTargetState): void {
431+ const waiter = state.waiters.shift();
432+
433+ if (waiter != null) {
434+ waiter();
435+ return;
436+ }
437+
438+ state.inFlight = Math.max(0, state.inFlight - 1);
439+ }
440+
441+ private async acquirePlatformAdmission(state: BrowserRequestPlatformState): Promise<void> {
442+ if (!state.busy) {
443+ state.busy = true;
444+ return;
445+ }
446+
447+ await new Promise<void>((resolve) => {
448+ state.waiters.push(resolve);
449+ });
450+ }
451+
452+ private releasePlatformAdmission(state: BrowserRequestPlatformState): void {
453+ const waiter = state.waiters.shift();
454+
455+ if (waiter != null) {
456+ waiter();
457+ return;
458+ }
459+
460+ state.busy = false;
461+ }
462+
463+ private async admitRequest(
464+ target: BrowserRequestTarget,
465+ state: BrowserRequestTargetState,
466+ requestId: string,
467+ requestedAt: number
468+ ): Promise<BrowserRequestAdmission> {
469+ const platformState = this.getPlatformState(target.platform);
470+ await this.acquirePlatformAdmission(platformState);
471+ let backoffDelayMs = 0;
472+ let jitterDelayMs = 0;
473+ let rateLimitDelayMs = 0;
474+
475+ try {
476+ this.assertCircuitAllowsRequest(target, state);
477+
478+ const backoffUntil = state.backoffUntil ?? 0;
479+ const now = this.now();
480+ if (backoffUntil > now) {
481+ backoffDelayMs = backoffUntil - now;
482+ await buildTimeoutPromise(this.setTimeoutImpl, backoffDelayMs);
483+ }
484+
485+ const nowAfterBackoff = this.now();
486+ prunePlatformDispatches(platformState, nowAfterBackoff, this.config.rateLimit.windowMs);
487+
488+ if (
489+ platformState.dispatches.length >= this.config.rateLimit.requestsPerMinutePerPlatform
490+ ) {
491+ const earliest = platformState.dispatches[0] ?? nowAfterBackoff;
492+ rateLimitDelayMs = Math.max(
493+ 0,
494+ earliest + this.config.rateLimit.windowMs - nowAfterBackoff
495+ );
496+
497+ if (rateLimitDelayMs > 0) {
498+ await buildTimeoutPromise(this.setTimeoutImpl, rateLimitDelayMs);
499+ }
500+ }
501+
502+ jitterDelayMs = this.sampleJitterDelayMs();
503+ if (jitterDelayMs > 0) {
504+ await buildTimeoutPromise(this.setTimeoutImpl, jitterDelayMs);
505+ }
506+
507+ const admittedAt = this.now();
508+ prunePlatformDispatches(platformState, admittedAt, this.config.rateLimit.windowMs);
509+ platformState.dispatches.push(admittedAt);
510+ platformState.lastDispatchedAt = admittedAt;
511+
512+ return {
513+ admittedAt,
514+ backoffDelayMs,
515+ circuitState: state.circuitState,
516+ jitterDelayMs,
517+ platform: target.platform,
518+ queueDelayMs: Math.max(0, admittedAt - requestedAt - backoffDelayMs - rateLimitDelayMs - jitterDelayMs),
519+ rateLimitDelayMs,
520+ requestId,
521+ requestedAt,
522+ targetClientId: target.clientId
523+ };
524+ } finally {
525+ this.releasePlatformAdmission(platformState);
526+ }
527+ }
528+
529+ private assertCircuitAllowsRequest(
530+ target: BrowserRequestTarget,
531+ state: BrowserRequestTargetState
532+ ): void {
533+ if (state.circuitState !== "open") {
534+ return;
535+ }
536+
537+ const now = this.now();
538+ const retryAt = state.circuitRetryAt ?? 0;
539+
540+ if (retryAt > now) {
541+ throw new BrowserRequestPolicyError(
542+ "circuit_open",
543+ `Browser request circuit is open for ${target.platform} on client "${target.clientId}".`,
544+ {
545+ client_id: target.clientId,
546+ platform: target.platform,
547+ retry_after_ms: retryAt - now
548+ }
549+ );
550+ }
551+
552+ state.circuitState = "half_open";
553+ }
554+
555+ private sampleJitterDelayMs(): number {
556+ const { maxMs, minMs, muMs, sigmaMs } = this.config.jitter;
557+ const u1 = Math.max(Number.EPSILON, this.random());
558+ const u2 = Math.max(Number.EPSILON, this.random());
559+ const gaussian = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
560+ return Math.round(clamp(muMs + gaussian * sigmaMs, minMs, maxMs));
561+ }
562+
563+ private completeRequest(
564+ target: BrowserRequestTarget,
565+ state: BrowserRequestTargetState,
566+ outcome: BrowserRequestLeaseOutcome
567+ ): void {
568+ const now = this.now();
569+
570+ if (outcome.status === "success") {
571+ state.backoffUntil = null;
572+ state.circuitRetryAt = null;
573+ state.circuitState = "closed";
574+ state.consecutiveFailures = 0;
575+ state.lastError = null;
576+ state.lastSuccessAt = now;
577+ this.releaseTargetSlot(state);
578+ return;
579+ }
580+
581+ if (outcome.status === "cancelled") {
582+ this.releaseTargetSlot(state);
583+ return;
584+ }
585+
586+ state.consecutiveFailures += 1;
587+ state.lastError = normalizeOptionalString(outcome.code) ?? normalizeOptionalString(outcome.message);
588+ state.lastFailureAt = now;
589+ const backoffDelay = Math.min(
590+ this.config.backoff.maxMs,
591+ this.config.backoff.baseMs * Math.pow(2, Math.max(0, state.consecutiveFailures - 1))
592+ );
593+ state.backoffUntil = now + backoffDelay;
594+
595+ if (
596+ state.circuitState === "half_open"
597+ || state.consecutiveFailures >= this.config.circuitBreaker.failureThreshold
598+ ) {
599+ state.circuitState = "open";
600+ state.circuitRetryAt = now + this.config.circuitBreaker.openMs;
601+ }
602+
603+ this.releaseTargetSlot(state);
604+ }
605+}
606+
607+export function createDefaultBrowserRequestPolicyConfig(): BrowserRequestPolicyConfig {
608+ return clonePolicyConfig(DEFAULT_BROWSER_REQUEST_POLICY);
609+}
+101,
-0
1@@ -58,6 +58,12 @@ export interface BrowserBridgeDispatchReceipt {
2 type: string;
3 }
4
5+export interface BrowserBridgeRequestCancelReceipt extends BrowserBridgeDispatchReceipt {
6+ reason?: string | null;
7+ requestId: string;
8+ streamId?: string | null;
9+}
10+
11 export interface BrowserBridgeApiResponse {
12 body: unknown;
13 clientId: string;
14@@ -69,17 +75,97 @@ export interface BrowserBridgeApiResponse {
15 status: number | null;
16 }
17
18+export interface BrowserBridgeStreamPartialState {
19+ buffered_bytes: number;
20+ event_count: number;
21+ last_seq: number;
22+ opened: boolean;
23+}
24+
25+export type BrowserBridgeStreamEvent =
26+ | {
27+ clientId: string;
28+ connectionId: string;
29+ meta: unknown;
30+ openedAt: number;
31+ requestId: string;
32+ status: number | null;
33+ streamId: string;
34+ type: "stream_open";
35+ }
36+ | {
37+ clientId: string;
38+ connectionId: string;
39+ data: unknown;
40+ event: string | null;
41+ raw: string | null;
42+ receivedAt: number;
43+ requestId: string;
44+ seq: number;
45+ streamId: string;
46+ type: "stream_event";
47+ }
48+ | {
49+ clientId: string;
50+ connectionId: string;
51+ endedAt: number;
52+ partial: BrowserBridgeStreamPartialState;
53+ requestId: string;
54+ status: number | null;
55+ streamId: string;
56+ type: "stream_end";
57+ }
58+ | {
59+ clientId: string;
60+ code: string;
61+ connectionId: string;
62+ erroredAt: number;
63+ message: string;
64+ partial: BrowserBridgeStreamPartialState;
65+ requestId: string;
66+ status: number | null;
67+ streamId: string;
68+ type: "stream_error";
69+ };
70+
71+export interface BrowserBridgeApiStream extends AsyncIterable<BrowserBridgeStreamEvent> {
72+ readonly clientId: string;
73+ readonly connectionId: string;
74+ readonly requestId: string;
75+ readonly streamId: string;
76+ cancel(reason?: string | null): void;
77+}
78+
79 export interface BrowserBridgeController {
80 apiRequest(input: {
81 body?: unknown;
82 clientId?: string | null;
83 headers?: Record<string, string> | null;
84 id?: string | null;
85+ idleTimeoutMs?: number | null;
86+ maxBufferedBytes?: number | null;
87+ maxBufferedEvents?: number | null;
88 method?: string | null;
89+ openTimeoutMs?: number | null;
90 path: string;
91 platform: string;
92+ responseMode?: "buffered" | "sse" | null;
93+ streamId?: string | null;
94 timeoutMs?: number | null;
95 }): Promise<BrowserBridgeApiResponse>;
96+ cancelApiRequest(input: {
97+ clientId?: string | null;
98+ platform?: string | null;
99+ reason?: string | null;
100+ requestId: string;
101+ streamId?: string | null;
102+ }): BrowserBridgeRequestCancelReceipt;
103+ dispatchPluginAction(input: {
104+ action: string;
105+ clientId?: string | null;
106+ platform?: string | null;
107+ reason?: string | null;
108+ }): BrowserBridgeDispatchReceipt;
109 openTab(input?: {
110 clientId?: string | null;
111 platform?: string | null;
112@@ -88,6 +174,21 @@ export interface BrowserBridgeController {
113 clientId?: string | null;
114 reason?: string | null;
115 }): BrowserBridgeDispatchReceipt;
116+ streamRequest(input: {
117+ body?: unknown;
118+ clientId?: string | null;
119+ headers?: Record<string, string> | null;
120+ id?: string | null;
121+ idleTimeoutMs?: number | null;
122+ maxBufferedBytes?: number | null;
123+ maxBufferedEvents?: number | null;
124+ method?: string | null;
125+ openTimeoutMs?: number | null;
126+ path: string;
127+ platform: string;
128+ streamId?: string | null;
129+ timeoutMs?: number | null;
130+ }): BrowserBridgeApiStream;
131 requestCredentials(input?: {
132 clientId?: string | null;
133 platform?: string | null;
+876,
-17
1@@ -1,23 +1,35 @@
2 import { randomUUID } from "node:crypto";
3
4 const DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS = 15_000;
5+const DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS = 30_000;
6+const DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES = 512 * 1024;
7+const DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS = 256;
8+const DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS = 10_000;
9
10 type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
11
12+export type FirefoxBridgeResponseMode = "buffered" | "sse";
13 export type FirefoxBridgeOutboundCommandType =
14+ | "api_request"
15+ | "controller_reload"
16 | "open_tab"
17- | "request_credentials"
18+ | "plugin_status"
19 | "reload"
20- | "api_request";
21+ | "request_cancel"
22+ | "request_credentials"
23+ | "tab_focus"
24+ | "tab_restore"
25+ | "ws_reconnect";
26
27 export type FirefoxBridgeErrorCode =
28+ | "client_disconnected"
29 | "client_not_found"
30+ | "client_replaced"
31 | "duplicate_request_id"
32 | "no_active_client"
33+ | "request_not_found"
34 | "request_timeout"
35 | "send_failed"
36- | "client_disconnected"
37- | "client_replaced"
38 | "service_stopped";
39
40 export interface FirefoxBridgeRegisteredClient {
41@@ -52,16 +64,41 @@ export interface FirefoxReloadCommandInput extends FirefoxBridgeCommandTarget {
42 reason?: string | null;
43 }
44
45+export interface FirefoxPluginActionCommandInput extends FirefoxBridgeCommandTarget {
46+ action: "controller_reload" | "plugin_status" | "tab_focus" | "tab_restore" | "ws_reconnect";
47+ platform?: string | null;
48+ reason?: string | null;
49+}
50+
51 export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarget {
52 body?: unknown;
53 headers?: Record<string, string> | null;
54 id?: string | null;
55+ idleTimeoutMs?: number | null;
56+ maxBufferedBytes?: number | null;
57+ maxBufferedEvents?: number | null;
58 method?: string | null;
59+ openTimeoutMs?: number | null;
60 path: string;
61 platform: string;
62+ responseMode?: FirefoxBridgeResponseMode | null;
63+ streamId?: string | null;
64 timeoutMs?: number | null;
65 }
66
67+export interface FirefoxRequestCancelCommandInput extends FirefoxBridgeCommandTarget {
68+ platform?: string | null;
69+ reason?: string | null;
70+ requestId: string;
71+ streamId?: string | null;
72+}
73+
74+export interface FirefoxBridgeCancelReceipt extends FirefoxBridgeDispatchReceipt {
75+ reason: string | null;
76+ requestId: string;
77+ streamId: string | null;
78+}
79+
80 export interface FirefoxApiResponsePayload {
81 body: unknown;
82 error: string | null;
83@@ -83,6 +120,105 @@ export interface FirefoxBridgeConnectionClosedEvent {
84 reason?: string | null;
85 }
86
87+export interface FirefoxBridgeStreamPartialState {
88+ buffered_bytes: number;
89+ event_count: number;
90+ last_seq: number;
91+ opened: boolean;
92+}
93+
94+export interface FirefoxBridgeStreamOpenEvent {
95+ clientId: string;
96+ connectionId: string;
97+ meta: unknown;
98+ openedAt: number;
99+ requestId: string;
100+ status: number | null;
101+ streamId: string;
102+ type: "stream_open";
103+}
104+
105+export interface FirefoxBridgeStreamDataEvent {
106+ clientId: string;
107+ connectionId: string;
108+ data: unknown;
109+ event: string | null;
110+ raw: string | null;
111+ receivedAt: number;
112+ requestId: string;
113+ seq: number;
114+ streamId: string;
115+ type: "stream_event";
116+}
117+
118+export interface FirefoxBridgeStreamEndEvent {
119+ clientId: string;
120+ connectionId: string;
121+ endedAt: number;
122+ partial: FirefoxBridgeStreamPartialState;
123+ requestId: string;
124+ status: number | null;
125+ streamId: string;
126+ type: "stream_end";
127+}
128+
129+export interface FirefoxBridgeStreamErrorEvent {
130+ clientId: string;
131+ code: string;
132+ connectionId: string;
133+ erroredAt: number;
134+ message: string;
135+ partial: FirefoxBridgeStreamPartialState;
136+ requestId: string;
137+ status: number | null;
138+ streamId: string;
139+ type: "stream_error";
140+}
141+
142+export type FirefoxBridgeStreamEvent =
143+ | FirefoxBridgeStreamDataEvent
144+ | FirefoxBridgeStreamEndEvent
145+ | FirefoxBridgeStreamErrorEvent
146+ | FirefoxBridgeStreamOpenEvent;
147+
148+export interface FirefoxBridgeApiStream extends AsyncIterable<FirefoxBridgeStreamEvent> {
149+ readonly clientId: string;
150+ readonly connectionId: string;
151+ readonly requestId: string;
152+ readonly streamId: string;
153+ cancel(reason?: string | null): void;
154+}
155+
156+export interface FirefoxStreamOpenPayload {
157+ id: string;
158+ meta?: unknown;
159+ status: number | null;
160+ streamId?: string | null;
161+}
162+
163+export interface FirefoxStreamEventPayload {
164+ data?: unknown;
165+ event?: string | null;
166+ id: string;
167+ raw?: string | null;
168+ seq: number;
169+ streamId?: string | null;
170+}
171+
172+export interface FirefoxStreamEndPayload {
173+ id: string;
174+ status: number | null;
175+ streamId?: string | null;
176+}
177+
178+export interface FirefoxStreamErrorPayload {
179+ code?: string | null;
180+ id: string;
181+ message?: string | null;
182+ status: number | null;
183+ streamId?: string | null;
184+}
185+
186 interface FirefoxPendingApiRequest {
187 clientId: string;
188 connectionId: string;
189@@ -100,6 +236,27 @@ interface FirefoxCommandBrokerOptions {
190 setTimeoutImpl?: (handler: () => void, timeoutMs: number) => TimeoutHandle;
191 }
192
193+interface FirefoxStreamSessionOptions {
194+ clearTimeoutImpl: (handle: TimeoutHandle) => void;
195+ clientId: string;
196+ connectionId: string;
197+ idleTimeoutMs: number;
198+ maxBufferedBytes: number;
199+ maxBufferedEvents: number;
200+ now: () => number;
201+ onCancel: (reason?: string | null) => void;
202+ onClose: (requestId: string) => void;
203+ openTimeoutMs: number;
204+ requestId: string;
205+ setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
206+ streamId: string;
207+}
208+
209+interface FirefoxQueuedStreamEvent {
210+ event: FirefoxBridgeStreamEvent;
211+ size: number;
212+}
213+
214 function normalizeOptionalString(value: unknown): string | null {
215 if (typeof value !== "string") {
216 return null;
217@@ -131,14 +288,22 @@ function normalizeHeaderRecord(
218 return Object.keys(normalized).length > 0 ? normalized : undefined;
219 }
220
221-function normalizeTimeoutMs(value: number | null | undefined): number {
222+function normalizeTimeoutMs(value: number | null | undefined, fallback: number): number {
223 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
224- return DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS;
225+ return fallback;
226 }
227
228 return Math.round(value);
229 }
230
231+function normalizePositiveInteger(value: number | null | undefined, fallback: number): number {
232+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
233+ return fallback;
234+ }
235+
236+ return Math.max(1, Math.round(value));
237+}
238+
239 function normalizeStatus(value: number | null | undefined): number | null {
240 if (typeof value !== "number" || !Number.isFinite(value)) {
241 return null;
242@@ -159,6 +324,28 @@ function compactRecord(input: Record<string, unknown>): Record<string, unknown>
243 return output;
244 }
245
246+function estimateEventSize(event: FirefoxBridgeStreamEvent): number {
247+ try {
248+ return Buffer.from(JSON.stringify(event), "utf8").length;
249+ } catch {
250+ return 0;
251+ }
252+}
253+
254+function buildPartialState(
255+ opened: boolean,
256+ eventCount: number,
257+ lastSeq: number,
258+ bufferedBytes: number
259+): FirefoxBridgeStreamPartialState {
260+ return {
261+ buffered_bytes: bufferedBytes,
262+ event_count: eventCount,
263+ last_seq: lastSeq,
264+ opened
265+ };
266+}
267+
268 export class FirefoxBridgeError extends Error {
269 readonly clientId: string | null;
270 readonly code: FirefoxBridgeErrorCode;
271@@ -183,10 +370,364 @@ export class FirefoxBridgeError extends Error {
272 }
273 }
274
275+class FirefoxBridgeApiStreamSession implements FirefoxBridgeApiStream {
276+ readonly clientId: string;
277+ readonly connectionId: string;
278+ readonly requestId: string;
279+
280+ private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
281+ private readonly idleTimeoutMs: number;
282+ private readonly maxBufferedBytes: number;
283+ private readonly maxBufferedEvents: number;
284+ private readonly now: () => number;
285+ private readonly onCancel: (reason?: string | null) => void;
286+ private readonly onClose: (requestId: string) => void;
287+ private readonly openTimeoutMs: number;
288+ private readonly queue: FirefoxQueuedStreamEvent[] = [];
289+ private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
290+ private readonly waiters: Array<(result: IteratorResult<FirefoxBridgeStreamEvent>) => void> = [];
291+ private bufferedBytes = 0;
292+ private cancelSent = false;
293+ private closed = false;
294+ private eventCount = 0;
295+ private idleTimer: TimeoutHandle | null = null;
296+ private lastSeq = 0;
297+ private openTimer: TimeoutHandle | null = null;
298+ private opened = false;
299+ private streamIdValue: string;
300+
301+ constructor(options: FirefoxStreamSessionOptions) {
302+ this.clearTimeoutImpl = options.clearTimeoutImpl;
303+ this.clientId = options.clientId;
304+ this.connectionId = options.connectionId;
305+ this.idleTimeoutMs = options.idleTimeoutMs;
306+ this.maxBufferedBytes = options.maxBufferedBytes;
307+ this.maxBufferedEvents = options.maxBufferedEvents;
308+ this.now = options.now;
309+ this.onCancel = options.onCancel;
310+ this.onClose = options.onClose;
311+ this.openTimeoutMs = options.openTimeoutMs;
312+ this.requestId = options.requestId;
313+ this.setTimeoutImpl = options.setTimeoutImpl;
314+ this.streamIdValue = options.streamId;
315+ this.resetOpenTimer();
316+ }
317+
318+ get streamId(): string {
319+ return this.streamIdValue;
320+ }
321+
322+ [Symbol.asyncIterator](): AsyncIterableIterator<FirefoxBridgeStreamEvent> {
323+ return {
324+ [Symbol.asyncIterator]: () => this[Symbol.asyncIterator](),
325+ next: async () => await this.next()
326+ };
327+ }
328+
329+ cancel(reason?: string | null): void {
330+ this.dispatchCancel(reason);
331+ this.fail("request_cancelled", normalizeOptionalString(reason) ?? "Stream request was cancelled.");
332+ }
333+
334+ next(): Promise<IteratorResult<FirefoxBridgeStreamEvent>> {
335+ const queued = this.queue.shift();
336+
337+ if (queued != null) {
338+ this.bufferedBytes = Math.max(0, this.bufferedBytes - queued.size);
339+ return Promise.resolve({
340+ done: false,
341+ value: queued.event
342+ });
343+ }
344+
345+ if (this.closed) {
346+ return Promise.resolve({
347+ done: true,
348+ value: undefined
349+ });
350+ }
351+
352+ return new Promise((resolve) => {
353+ this.waiters.push(resolve);
354+ });
355+ }
356+
357+ markOpen(payload: FirefoxStreamOpenPayload): boolean {
358+ if (this.closed) {
359+ return false;
360+ }
361+
362+ if (normalizeOptionalString(payload.streamId) != null) {
363+ this.streamIdValue = normalizeOptionalString(payload.streamId) ?? this.streamIdValue;
364+ }
365+
366+ this.opened = true;
367+ this.resetIdleTimer();
368+ return this.enqueue({
369+ clientId: this.clientId,
370+ connectionId: this.connectionId,
371+ meta: payload.meta ?? null,
372+ openedAt: this.now(),
373+ requestId: this.requestId,
374+ status: normalizeStatus(payload.status),
375+ streamId: this.streamIdValue,
376+ type: "stream_open"
377+ });
378+ }
379+
380+ markEvent(payload: FirefoxStreamEventPayload): boolean {
381+ if (this.closed) {
382+ return false;
383+ }
384+
385+ if (!this.opened) {
386+ this.markOpen({
387+ id: this.requestId,
388+ status: null,
389+ streamId: payload.streamId ?? this.streamIdValue
390+ });
391+ }
392+
393+ if (normalizeOptionalString(payload.streamId) != null) {
394+ this.streamIdValue = normalizeOptionalString(payload.streamId) ?? this.streamIdValue;
395+ }
396+
397+ const seq = normalizePositiveInteger(payload.seq, this.lastSeq + 1);
398+ this.eventCount += 1;
399+ this.lastSeq = seq;
400+ this.resetIdleTimer();
401+ return this.enqueue({
402+ clientId: this.clientId,
403+ connectionId: this.connectionId,
404+ data: payload.data ?? null,
405+ event: normalizeOptionalString(payload.event),
406+ raw: normalizeOptionalString(payload.raw),
407+ receivedAt: this.now(),
408+ requestId: this.requestId,
409+ seq,
410+ streamId: this.streamIdValue,
411+ type: "stream_event"
412+ });
413+ }
414+
415+ markEnd(payload: FirefoxStreamEndPayload): boolean {
416+ if (this.closed) {
417+ return false;
418+ }
419+
420+ return this.finishWithEvent({
421+ clientId: this.clientId,
422+ connectionId: this.connectionId,
423+ endedAt: this.now(),
424+ partial: buildPartialState(
425+ this.opened,
426+ this.eventCount,
427+ this.lastSeq,
428+ this.bufferedBytes
429+ ),
430+ requestId: this.requestId,
431+ status: normalizeStatus(payload.status),
432+ streamId:
433+ normalizeOptionalString(payload.streamId) ?? this.streamIdValue,
434+ type: "stream_end"
435+ });
436+ }
437+
438+ markError(payload: FirefoxStreamErrorPayload): boolean {
439+ if (this.closed) {
440+ return false;
441+ }
442+
443+ return this.finishWithEvent({
444+ clientId: this.clientId,
445+ code: normalizeOptionalString(payload.code) ?? "stream_error",
446+ connectionId: this.connectionId,
447+ erroredAt: this.now(),
448+ message: normalizeOptionalString(payload.message) ?? "Browser stream failed.",
449+ partial: buildPartialState(
450+ this.opened,
451+ this.eventCount,
452+ this.lastSeq,
453+ this.bufferedBytes
454+ ),
455+ requestId: this.requestId,
456+ status: normalizeStatus(payload.status),
457+ streamId:
458+ normalizeOptionalString(payload.streamId) ?? this.streamIdValue,
459+ type: "stream_error"
460+ });
461+ }
462+
463+ fail(code: string, message: string, status: number | null = null): boolean {
464+ return this.markError({
465+ code,
466+ id: this.requestId,
467+ message,
468+ status,
469+ streamId: this.streamIdValue
470+ });
471+ }
472+
473+ private enqueue(event: FirefoxBridgeStreamEvent): boolean {
474+ if (this.waiters.length > 0) {
475+ const waiter = this.waiters.shift();
476+
477+ waiter?.({
478+ done: false,
479+ value: event
480+ });
481+ return true;
482+ }
483+
484+ const size = estimateEventSize(event);
485+
486+ if (
487+ event.type === "stream_event"
488+ && (
489+ this.queue.length + 1 > this.maxBufferedEvents
490+ || this.bufferedBytes + size > this.maxBufferedBytes
491+ )
492+ ) {
493+ this.dispatchCancel("stream_buffer_overflow");
494+ return this.finishWithEvent({
495+ clientId: this.clientId,
496+ code: "stream_buffer_overflow",
497+ connectionId: this.connectionId,
498+ erroredAt: this.now(),
499+ message: "Browser stream exceeded the conductor buffering limit.",
500+ partial: buildPartialState(
501+ this.opened,
502+ this.eventCount,
503+ this.lastSeq,
504+ this.bufferedBytes
505+ ),
506+ requestId: this.requestId,
507+ status: null,
508+ streamId: this.streamIdValue,
509+ type: "stream_error"
510+ });
511+ }
512+
513+ this.queue.push({
514+ event,
515+ size
516+ });
517+ this.bufferedBytes += size;
518+ return true;
519+ }
520+
521+ private finishWithEvent(event: FirefoxBridgeStreamEndEvent | FirefoxBridgeStreamErrorEvent): boolean {
522+ if (this.closed) {
523+ return false;
524+ }
525+
526+ if (this.waiters.length > 0) {
527+ const waiter = this.waiters.shift();
528+ waiter?.({
529+ done: false,
530+ value: event
531+ });
532+ } else {
533+ const size = estimateEventSize(event);
534+ this.queue.push({
535+ event,
536+ size
537+ });
538+ this.bufferedBytes += size;
539+ }
540+
541+ this.close();
542+ return true;
543+ }
544+
545+ private close(): void {
546+ if (this.closed) {
547+ return;
548+ }
549+
550+ this.closed = true;
551+ this.clearTimers();
552+ this.onClose(this.requestId);
553+
554+ if (this.queue.length === 0) {
555+ while (this.waiters.length > 0) {
556+ const waiter = this.waiters.shift();
557+ waiter?.({
558+ done: true,
559+ value: undefined
560+ });
561+ }
562+ }
563+ }
564+
565+ private dispatchCancel(reason?: string | null): void {
566+ if (this.cancelSent) {
567+ return;
568+ }
569+
570+ this.cancelSent = true;
571+ this.onCancel(reason);
572+ }
573+
574+ private clearTimers(): void {
575+ if (this.openTimer != null) {
576+ this.clearTimeoutImpl(this.openTimer);
577+ this.openTimer = null;
578+ }
579+
580+ if (this.idleTimer != null) {
581+ this.clearTimeoutImpl(this.idleTimer);
582+ this.idleTimer = null;
583+ }
584+ }
585+
586+ private resetOpenTimer(): void {
587+ if (this.openTimeoutMs <= 0 || this.closed) {
588+ return;
589+ }
590+
591+ if (this.openTimer != null) {
592+ this.clearTimeoutImpl(this.openTimer);
593+ }
594+
595+ this.openTimer = this.setTimeoutImpl(() => {
596+ this.dispatchCancel("stream_open_timeout");
597+ this.fail(
598+ "stream_open_timeout",
599+ `Browser stream "${this.requestId}" did not open within ${this.openTimeoutMs}ms.`
600+ );
601+ }, this.openTimeoutMs);
602+ }
603+
604+ private resetIdleTimer(): void {
605+ if (this.openTimer != null) {
606+ this.clearTimeoutImpl(this.openTimer);
607+ this.openTimer = null;
608+ }
609+
610+ if (this.idleTimeoutMs <= 0 || this.closed) {
611+ return;
612+ }
613+
614+ if (this.idleTimer != null) {
615+ this.clearTimeoutImpl(this.idleTimer);
616+ }
617+
618+ this.idleTimer = this.setTimeoutImpl(() => {
619+ this.dispatchCancel("stream_idle_timeout");
620+ this.fail(
621+ "stream_idle_timeout",
622+ `Browser stream "${this.requestId}" was idle for more than ${this.idleTimeoutMs}ms.`
623+ );
624+ }, this.idleTimeoutMs);
625+ }
626+}
627+
628 export class FirefoxCommandBroker {
629 private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
630 private readonly now: () => number;
631 private readonly pendingApiRequests = new Map<string, FirefoxPendingApiRequest>();
632+ private readonly pendingStreamRequests = new Map<string, FirefoxBridgeApiStreamSession>();
633 private readonly resolveActiveClient: () => FirefoxBridgeRegisteredClient | null;
634 private readonly resolveClientById: (clientId: string) => FirefoxBridgeRegisteredClient | null;
635 private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
636@@ -237,7 +778,7 @@ export class FirefoxCommandBroker {
637 timeoutMs?: number | null;
638 }
639 ): Promise<FirefoxBridgeApiResponse> {
640- if (this.pendingApiRequests.has(options.requestId)) {
641+ if (this.pendingApiRequests.has(options.requestId) || this.pendingStreamRequests.has(options.requestId)) {
642 throw new FirefoxBridgeError(
643 "duplicate_request_id",
644 `Firefox bridge request id "${options.requestId}" is already in flight.`,
645@@ -248,10 +789,11 @@ export class FirefoxCommandBroker {
646 }
647
648 const client = this.selectClient(options);
649- const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
650+ const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS);
651 const envelope = compactRecord({
652 ...payload,
653 id: options.requestId,
654+ response_mode: "buffered",
655 type: "api_request"
656 });
657
658@@ -297,10 +839,158 @@ export class FirefoxCommandBroker {
659 });
660 }
661
662- handleApiResponse(
663- connectionId: string,
664- payload: FirefoxApiResponsePayload
665- ): boolean {
666+ openApiStream(
667+ payload: Record<string, unknown>,
668+ options: FirefoxBridgeCommandTarget & {
669+ idleTimeoutMs?: number | null;
670+ maxBufferedBytes?: number | null;
671+ maxBufferedEvents?: number | null;
672+ openTimeoutMs?: number | null;
673+ requestId: string;
674+ streamId: string;
675+ }
676+ ): FirefoxBridgeApiStream {
677+ if (this.pendingApiRequests.has(options.requestId) || this.pendingStreamRequests.has(options.requestId)) {
678+ throw new FirefoxBridgeError(
679+ "duplicate_request_id",
680+ `Firefox bridge request id "${options.requestId}" is already in flight.`,
681+ {
682+ requestId: options.requestId
683+ }
684+ );
685+ }
686+
687+ const client = this.selectClient(options);
688+ const streamSession = new FirefoxBridgeApiStreamSession({
689+ clearTimeoutImpl: this.clearTimeoutImpl,
690+ clientId: client.clientId,
691+ connectionId: client.connectionId,
692+ idleTimeoutMs: normalizeTimeoutMs(
693+ options.idleTimeoutMs,
694+ DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS
695+ ),
696+ maxBufferedBytes: normalizePositiveInteger(
697+ options.maxBufferedBytes,
698+ DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES
699+ ),
700+ maxBufferedEvents: normalizePositiveInteger(
701+ options.maxBufferedEvents,
702+ DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS
703+ ),
704+ now: this.now,
705+ onCancel: (reason) => {
706+ try {
707+ this.dispatch("request_cancel", {
708+ id: options.requestId,
709+ reason: normalizeOptionalString(reason) ?? undefined,
710+ stream_id: options.streamId
711+ }, {
712+ clientId: client.clientId
713+ });
714+ } catch {
715+ // Best-effort remote cancel.
716+ }
717+ },
718+ onClose: (requestId) => {
719+ this.pendingStreamRequests.delete(requestId);
720+ },
721+ openTimeoutMs: normalizeTimeoutMs(
722+ options.openTimeoutMs,
723+ DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS
724+ ),
725+ requestId: options.requestId,
726+ setTimeoutImpl: this.setTimeoutImpl,
727+ streamId: options.streamId
728+ });
729+ this.pendingStreamRequests.set(options.requestId, streamSession);
730+
731+ const envelope = compactRecord({
732+ ...payload,
733+ id: options.requestId,
734+ response_mode: "sse",
735+ stream_id: options.streamId,
736+ type: "api_request"
737+ });
738+
739+ if (!client.sendJson(envelope)) {
740+ this.pendingStreamRequests.delete(options.requestId);
741+ streamSession.fail(
742+ "send_failed",
743+ `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`
744+ );
745+ throw new FirefoxBridgeError(
746+ "send_failed",
747+ `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`,
748+ {
749+ clientId: client.clientId,
750+ connectionId: client.connectionId,
751+ requestId: options.requestId
752+ }
753+ );
754+ }
755+
756+ return streamSession;
757+ }
758+
759+ cancelRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
760+ const pendingApiRequest = this.pendingApiRequests.get(input.requestId) ?? null;
761+ const pendingStreamRequest = this.pendingStreamRequests.get(input.requestId) ?? null;
762+ const requestedClientId = normalizeOptionalString(input.clientId);
763+
764+ if (pendingApiRequest == null && pendingStreamRequest == null) {
765+ throw new FirefoxBridgeError(
766+ "request_not_found",
767+ `Firefox bridge request "${input.requestId}" is not in flight.`,
768+ {
769+ requestId: input.requestId
770+ }
771+ );
772+ }
773+
774+ const targetClientId =
775+ pendingApiRequest?.clientId
776+ ?? pendingStreamRequest?.clientId
777+ ?? null;
778+
779+ if (requestedClientId != null && targetClientId != null && requestedClientId !== targetClientId) {
780+ throw new FirefoxBridgeError(
781+ "client_not_found",
782+ `Firefox bridge request "${input.requestId}" is not running on client "${requestedClientId}".`,
783+ {
784+ clientId: requestedClientId,
785+ requestId: input.requestId
786+ }
787+ );
788+ }
789+
790+ const receipt = this.dispatch(
791+ "request_cancel",
792+ compactRecord({
793+ id: input.requestId,
794+ platform: normalizeOptionalString(input.platform) ?? undefined,
795+ reason: normalizeOptionalString(input.reason) ?? undefined,
796+ stream_id:
797+ normalizeOptionalString(input.streamId)
798+ ?? pendingStreamRequest?.streamId
799+ ?? undefined
800+ }),
801+ {
802+ clientId: targetClientId ?? requestedClientId
803+ }
804+ );
805+
806+ return {
807+ ...receipt,
808+ reason: normalizeOptionalString(input.reason),
809+ requestId: input.requestId,
810+ streamId:
811+ normalizeOptionalString(input.streamId)
812+ ?? pendingStreamRequest?.streamId
813+ ?? null
814+ };
815+ }
816+
817+ handleApiResponse(connectionId: string, payload: FirefoxApiResponsePayload): boolean {
818 const pending = this.pendingApiRequests.get(payload.id);
819
820 if (pending == null || pending.connectionId !== connectionId) {
821@@ -321,14 +1011,53 @@ export class FirefoxCommandBroker {
822 return true;
823 }
824
825+ handleStreamOpen(connectionId: string, payload: FirefoxStreamOpenPayload): boolean {
826+ const pending = this.pendingStreamRequests.get(payload.id);
827+
828+ if (pending == null || pending.connectionId !== connectionId) {
829+ return false;
830+ }
831+
832+ return pending.markOpen(payload);
833+ }
834+
835+ handleStreamEvent(connectionId: string, payload: FirefoxStreamEventPayload): boolean {
836+ const pending = this.pendingStreamRequests.get(payload.id);
837+
838+ if (pending == null || pending.connectionId !== connectionId) {
839+ return false;
840+ }
841+
842+ return pending.markEvent(payload);
843+ }
844+
845+ handleStreamEnd(connectionId: string, payload: FirefoxStreamEndPayload): boolean {
846+ const pending = this.pendingStreamRequests.get(payload.id);
847+
848+ if (pending == null || pending.connectionId !== connectionId) {
849+ return false;
850+ }
851+
852+ return pending.markEnd(payload);
853+ }
854+
855+ handleStreamError(connectionId: string, payload: FirefoxStreamErrorPayload): boolean {
856+ const pending = this.pendingStreamRequests.get(payload.id);
857+
858+ if (pending == null || pending.connectionId !== connectionId) {
859+ return false;
860+ }
861+
862+ return pending.markError(payload);
863+ }
864+
865 handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
866 const requestIds = [...this.pendingApiRequests.values()]
867 .filter((entry) => entry.connectionId === event.connectionId)
868 .map((entry) => entry.requestId);
869-
870- if (requestIds.length === 0) {
871- return;
872- }
873+ const streamIds = [...this.pendingStreamRequests.values()]
874+ .filter((entry) => entry.connectionId === event.connectionId)
875+ .map((entry) => entry.requestId);
876
877 const errorCode: FirefoxBridgeErrorCode =
878 event.code === 4001 ? "client_replaced" : "client_disconnected";
879@@ -357,10 +1086,26 @@ export class FirefoxCommandBroker {
880 )
881 );
882 }
883+
884+ for (const requestId of streamIds) {
885+ const pending = this.pendingStreamRequests.get(requestId);
886+
887+ if (pending == null) {
888+ continue;
889+ }
890+
891+ pending.fail(
892+ errorCode,
893+ errorCode === "client_replaced"
894+ ? `Firefox client "${clientLabel}" was replaced before stream "${requestId}" completed${reasonSuffix}.`
895+ : `Firefox client "${clientLabel}" disconnected before stream "${requestId}" completed${reasonSuffix}.`
896+ );
897+ }
898 }
899
900 stop(): void {
901 const requestIds = [...this.pendingApiRequests.keys()];
902+ const streamIds = [...this.pendingStreamRequests.keys()];
903
904 for (const requestId of requestIds) {
905 const pending = this.clearPendingRequest(requestId);
906@@ -381,6 +1126,19 @@ export class FirefoxCommandBroker {
907 )
908 );
909 }
910+
911+ for (const requestId of streamIds) {
912+ const pending = this.pendingStreamRequests.get(requestId);
913+
914+ if (pending == null) {
915+ continue;
916+ }
917+
918+ pending.fail(
919+ "service_stopped",
920+ `Firefox bridge stopped before stream "${requestId}" completed.`
921+ );
922+ }
923 }
924
925 private clearPendingRequest(requestId: string): FirefoxPendingApiRequest | null {
926@@ -426,6 +1184,19 @@ export class FirefoxCommandBroker {
927 export class FirefoxBridgeService {
928 constructor(private readonly broker: FirefoxCommandBroker) {}
929
930+ dispatchPluginAction(
931+ input: FirefoxPluginActionCommandInput
932+ ): FirefoxBridgeDispatchReceipt {
933+ return this.broker.dispatch(
934+ input.action,
935+ compactRecord({
936+ platform: normalizeOptionalString(input.platform) ?? undefined,
937+ reason: normalizeOptionalString(input.reason) ?? undefined
938+ }),
939+ input
940+ );
941+ }
942+
943 openTab(input: FirefoxOpenTabCommandInput = {}): FirefoxBridgeDispatchReceipt {
944 const platform = normalizeOptionalString(input.platform);
945
946@@ -481,7 +1252,12 @@ export class FirefoxBridgeService {
947 headers: normalizeHeaderRecord(input.headers),
948 method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
949 path,
950- platform
951+ platform,
952+ response_mode: "buffered",
953+ timeout_ms: normalizeTimeoutMs(
954+ input.timeoutMs,
955+ DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS
956+ )
957 }),
958 {
959 clientId: input.clientId,
960@@ -491,6 +1267,64 @@ export class FirefoxBridgeService {
961 );
962 }
963
964+ streamRequest(input: FirefoxApiRequestCommandInput): FirefoxBridgeApiStream {
965+ const platform = normalizeOptionalString(input.platform);
966+ const path = normalizeOptionalString(input.path);
967+
968+ if (platform == null) {
969+ throw new Error("Firefox bridge stream_request requires a non-empty platform.");
970+ }
971+
972+ if (path == null) {
973+ throw new Error("Firefox bridge stream_request requires a non-empty path.");
974+ }
975+
976+ const requestId = normalizeOptionalString(input.id) ?? randomUUID();
977+ const streamId = normalizeOptionalString(input.streamId) ?? requestId;
978+
979+ return this.broker.openApiStream(
980+ compactRecord({
981+ body: input.body ?? null,
982+ headers: normalizeHeaderRecord(input.headers),
983+ method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
984+ path,
985+ platform,
986+ response_mode: "sse",
987+ stream_id: streamId,
988+ timeout_ms: normalizeTimeoutMs(
989+ input.timeoutMs,
990+ DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS
991+ )
992+ }),
993+ {
994+ clientId: input.clientId,
995+ idleTimeoutMs: input.idleTimeoutMs,
996+ maxBufferedBytes: input.maxBufferedBytes,
997+ maxBufferedEvents: input.maxBufferedEvents,
998+ openTimeoutMs: input.openTimeoutMs,
999+ requestId,
1000+ streamId
1001+ }
1002+ );
1003+ }
1004+
1005+ cancelApiRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
1006+ return this.cancelRequest(input);
1007+ }
1008+
1009+ cancelRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
1010+ const requestId = normalizeOptionalString(input.requestId);
1011+
1012+ if (requestId == null) {
1013+ throw new Error("Firefox bridge cancel_request requires a non-empty requestId.");
1014+ }
1015+
1016+ return this.broker.cancelRequest({
1017+ ...input,
1018+ requestId
1019+ });
1020+ }
1021+
1022 handleApiResponse(connectionId: string, payload: FirefoxApiResponsePayload): boolean {
1023 return this.broker.handleApiResponse(connectionId, {
1024 body: payload.body,
1025@@ -501,6 +1335,31 @@ export class FirefoxBridgeService {
1026 });
1027 }
1028
1029+ handleStreamOpen(connectionId: string, payload: FirefoxStreamOpenPayload): boolean {
1030+ return this.broker.handleStreamOpen(connectionId, {
1031+ ...payload,
1032+ status: normalizeStatus(payload.status)
1033+ });
1034+ }
1035+
1036+ handleStreamEvent(connectionId: string, payload: FirefoxStreamEventPayload): boolean {
1037+ return this.broker.handleStreamEvent(connectionId, payload);
1038+ }
1039+
1040+ handleStreamEnd(connectionId: string, payload: FirefoxStreamEndPayload): boolean {
1041+ return this.broker.handleStreamEnd(connectionId, {
1042+ ...payload,
1043+ status: normalizeStatus(payload.status)
1044+ });
1045+ }
1046+
1047+ handleStreamError(connectionId: string, payload: FirefoxStreamErrorPayload): boolean {
1048+ return this.broker.handleStreamError(connectionId, {
1049+ ...payload,
1050+ status: normalizeStatus(payload.status)
1051+ });
1052+ }
1053+
1054 handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
1055 this.broker.handleConnectionClosed(event);
1056 }
+115,
-1
1@@ -859,6 +859,18 @@ export class ConductorFirefoxWebSocketServer {
2 case "api_response":
3 this.handleApiResponse(connection, message);
4 return;
5+ case "stream_open":
6+ this.handleStreamOpen(connection, message);
7+ return;
8+ case "stream_event":
9+ this.handleStreamEvent(connection, message);
10+ return;
11+ case "stream_end":
12+ this.handleStreamEnd(connection, message);
13+ return;
14+ case "stream_error":
15+ this.handleStreamError(connection, message);
16+ return;
17 default:
18 this.sendError(connection, "unsupported_message_type", `Unsupported WS message type: ${type}.`);
19 }
20@@ -899,7 +911,11 @@ export class ConductorFirefoxWebSocketServer {
21 "credentials",
22 "api_endpoints",
23 "client_log",
24- "api_response"
25+ "api_response",
26+ "stream_open",
27+ "stream_event",
28+ "stream_end",
29+ "stream_error"
30 ],
31 outbound: [
32 "hello_ack",
33@@ -907,8 +923,13 @@ export class ConductorFirefoxWebSocketServer {
34 "action_result",
35 "request_credentials",
36 "open_tab",
37+ "plugin_status",
38+ "ws_reconnect",
39+ "controller_reload",
40+ "tab_restore",
41 "reload",
42 "api_request",
43+ "request_cancel",
44 "error"
45 ]
46 }
47@@ -1153,6 +1174,99 @@ export class ConductorFirefoxWebSocketServer {
48 }
49 }
50
51+ private handleStreamOpen(
52+ connection: FirefoxWebSocketConnection,
53+ message: Record<string, unknown>
54+ ): void {
55+ const id = readFirstString(message, ["id", "requestId", "request_id"]);
56+
57+ if (id == null) {
58+ this.sendError(connection, "invalid_message", "stream_open requires a non-empty id field.");
59+ return;
60+ }
61+
62+ this.bridgeService.handleStreamOpen(connection.getConnectionId(), {
63+ id,
64+ meta: message.meta ?? null,
65+ status:
66+ typeof message.status === "number" && Number.isFinite(message.status)
67+ ? Math.round(message.status)
68+ : null,
69+ streamId: readFirstString(message, ["streamId", "stream_id"])
70+ });
71+ }
72+
73+ private handleStreamEvent(
74+ connection: FirefoxWebSocketConnection,
75+ message: Record<string, unknown>
76+ ): void {
77+ const id = readFirstString(message, ["id", "requestId", "request_id"]);
78+ const rawSeq = message.seq;
79+
80+ if (id == null) {
81+ this.sendError(connection, "invalid_message", "stream_event requires a non-empty id field.");
82+ return;
83+ }
84+
85+ if (typeof rawSeq !== "number" || !Number.isFinite(rawSeq) || rawSeq <= 0) {
86+ this.sendError(connection, "invalid_message", "stream_event requires a positive numeric seq field.");
87+ return;
88+ }
89+
90+ this.bridgeService.handleStreamEvent(connection.getConnectionId(), {
91+ data: message.data ?? null,
92+ event: readFirstString(message, ["event"]),
93+ id,
94+ raw: readFirstString(message, ["raw", "chunk"]),
95+ seq: Math.round(rawSeq),
96+ streamId: readFirstString(message, ["streamId", "stream_id"])
97+ });
98+ }
99+
100+ private handleStreamEnd(
101+ connection: FirefoxWebSocketConnection,
102+ message: Record<string, unknown>
103+ ): void {
104+ const id = readFirstString(message, ["id", "requestId", "request_id"]);
105+
106+ if (id == null) {
107+ this.sendError(connection, "invalid_message", "stream_end requires a non-empty id field.");
108+ return;
109+ }
110+
111+ this.bridgeService.handleStreamEnd(connection.getConnectionId(), {
112+ id,
113+ status:
114+ typeof message.status === "number" && Number.isFinite(message.status)
115+ ? Math.round(message.status)
116+ : null,
117+ streamId: readFirstString(message, ["streamId", "stream_id"])
118+ });
119+ }
120+
121+ private handleStreamError(
122+ connection: FirefoxWebSocketConnection,
123+ message: Record<string, unknown>
124+ ): void {
125+ const id = readFirstString(message, ["id", "requestId", "request_id"]);
126+
127+ if (id == null) {
128+ this.sendError(connection, "invalid_message", "stream_error requires a non-empty id field.");
129+ return;
130+ }
131+
132+ this.bridgeService.handleStreamError(connection.getConnectionId(), {
133+ code: readFirstString(message, ["code", "error"]),
134+ id,
135+ message: readFirstString(message, ["message"]),
136+ status:
137+ typeof message.status === "number" && Number.isFinite(message.status)
138+ ? Math.round(message.status)
139+ : null,
140+ streamId: readFirstString(message, ["streamId", "stream_id"])
141+ });
142+ }
143+
144 private async refreshBrowserLoginStateStatuses(): Promise<void> {
145 await this.repository.markBrowserLoginStatesLost(
146 this.now() - Math.ceil(FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS / 1000)
1@@ -3,12 +3,14 @@ export interface ConductorHttpRequest {
2 headers?: Record<string, string | undefined>;
3 method: string;
4 path: string;
5+ signal?: AbortSignal;
6 }
7
8 export interface ConductorHttpResponse {
9 body: string;
10 headers: Record<string, string>;
11 status: number;
12+ streamBody?: AsyncIterable<string> | null;
13 }
14
15 export const JSON_RESPONSE_HEADERS = {
+189,
-3
1@@ -467,6 +467,37 @@ function parseJsonBody(response) {
2 return JSON.parse(response.body);
3 }
4
5+async function readResponseBodyText(response) {
6+ let text = response.body;
7+
8+ if (response.streamBody != null) {
9+ for await (const chunk of response.streamBody) {
10+ text += chunk;
11+ }
12+ }
13+
14+ return text;
15+}
16+
17+function parseSseFrames(text) {
18+ return String(text || "")
19+ .split(/\n\n+/u)
20+ .map((chunk) => chunk.trim())
21+ .filter(Boolean)
22+ .map((chunk) => {
23+ const lines = chunk.split("\n");
24+ const eventLine = lines.find((line) => line.startsWith("event:"));
25+ const dataLines = lines
26+ .filter((line) => line.startsWith("data:"))
27+ .map((line) => line.slice(5).trimStart());
28+
29+ return {
30+ data: JSON.parse(dataLines.join("\n")),
31+ event: eventLine ? eventLine.slice(6).trim() : null
32+ };
33+ });
34+}
35+
36 function createBrowserBridgeStub() {
37 const calls = [];
38 const browserState = {
39@@ -535,6 +566,25 @@ function createBrowserBridgeStub() {
40 status
41 });
42
43+ const buildApiStream = ({ events, input }) => ({
44+ clientId: input.clientId || "firefox-claude",
45+ connectionId: "conn-firefox-claude",
46+ requestId: input.id || `browser-stream-${calls.length + 1}`,
47+ streamId: input.streamId || input.id || `browser-stream-${calls.length + 1}`,
48+ cancel(reason = null) {
49+ calls.push({
50+ id: input.id,
51+ kind: "streamCancel",
52+ reason
53+ });
54+ },
55+ async *[Symbol.asyncIterator]() {
56+ for (const event of events) {
57+ yield event;
58+ }
59+ }
60+ });
61+
62 return {
63 calls,
64 context: {
65@@ -614,6 +664,35 @@ function createBrowserBridgeStub() {
66
67 throw new Error(`unexpected browser proxy path: ${input.path}`);
68 },
69+ cancelApiRequest(input = {}) {
70+ calls.push({
71+ ...input,
72+ kind: "cancelApiRequest"
73+ });
74+
75+ return {
76+ clientId: input.clientId || "firefox-claude",
77+ connectionId: "conn-firefox-claude",
78+ dispatchedAt: 1710000004500,
79+ reason: input.reason || null,
80+ requestId: input.requestId || "browser-cancel",
81+ streamId: input.streamId || null,
82+ type: "request_cancel"
83+ };
84+ },
85+ dispatchPluginAction(input = {}) {
86+ calls.push({
87+ ...input,
88+ kind: "dispatchPluginAction"
89+ });
90+
91+ return {
92+ clientId: input.clientId || "firefox-claude",
93+ connectionId: "conn-firefox-claude",
94+ dispatchedAt: 1710000004750,
95+ type: input.action || "plugin_status"
96+ };
97+ },
98 openTab(input = {}) {
99 calls.push({
100 ...input,
101@@ -652,6 +731,63 @@ function createBrowserBridgeStub() {
102 dispatchedAt: 1710000007000,
103 type: "request_credentials"
104 };
105+ },
106+ streamRequest(input) {
107+ calls.push({
108+ ...input,
109+ kind: "streamRequest"
110+ });
111+
112+ return buildApiStream({
113+ input,
114+ events: [
115+ {
116+ clientId: input.clientId || "firefox-claude",
117+ connectionId: "conn-firefox-claude",
118+ meta: {
119+ path: input.path
120+ },
121+ openedAt: 1710000008000,
122+ requestId: input.id || "browser-stream-1",
123+ status: 200,
124+ streamId: input.streamId || input.id || "browser-stream-1",
125+ type: "stream_open"
126+ },
127+ {
128+ clientId: input.clientId || "firefox-claude",
129+ connectionId: "conn-firefox-claude",
130+ data: {
131+ type: "content_block_delta",
132+ delta: {
133+ type: "text_delta",
134+ text: "hello from claude stream"
135+ }
136+ },
137+ event: "message",
138+ raw: 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hello from claude stream"}}',
139+ receivedAt: 1710000008001,
140+ requestId: input.id || "browser-stream-1",
141+ seq: 1,
142+ streamId: input.streamId || input.id || "browser-stream-1",
143+ type: "stream_event"
144+ },
145+ {
146+ clientId: input.clientId || "firefox-claude",
147+ connectionId: "conn-firefox-claude",
148+ endedAt: 1710000008002,
149+ partial: {
150+ buffered_bytes: 120,
151+ event_count: 1,
152+ last_seq: 1,
153+ opened: true
154+ },
155+ requestId: input.id || "browser-stream-1",
156+ status: 200,
157+ streamId: input.streamId || input.id || "browser-stream-1",
158+ type: "stream_end"
159+ }
160+ ]
161+ });
162 }
163 },
164 browserStateLoader: () => browserState
165@@ -1428,6 +1564,10 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
166 assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/runs/u);
167 assert.equal(businessDescribePayload.data.codex.backend, "independent_codexd");
168 assert.equal(businessDescribePayload.data.browser.request_contract.route.path, "/v1/browser/request");
169+ assert.match(
170+ JSON.stringify(businessDescribePayload.data.browser.request_contract.supported_response_modes),
171+ /"sse"/u
172+ );
173
174 const controlDescribeResponse = await handleConductorHttpRequest(
175 {
176@@ -1506,6 +1646,20 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
177 assert.equal(browserActionsResponse.status, 200);
178 assert.equal(parseJsonBody(browserActionsResponse).data.action, "tab_open");
179
180+ const browserPluginActionResponse = await handleConductorHttpRequest(
181+ {
182+ body: JSON.stringify({
183+ action: "plugin_status",
184+ client_id: "firefox-claude"
185+ }),
186+ method: "POST",
187+ path: "/v1/browser/actions"
188+ },
189+ localApiContext
190+ );
191+ assert.equal(browserPluginActionResponse.status, 200);
192+ assert.equal(parseJsonBody(browserPluginActionResponse).data.action, "plugin_status");
193+
194 const browserRequestResponse = await handleConductorHttpRequest(
195 {
196 body: JSON.stringify({
197@@ -1526,21 +1680,48 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
198 browserRequestPayload.data.proxy.path,
199 "/api/organizations/org-1/chat_conversations/conv-1/completion"
200 );
201+ assert.equal(browserRequestPayload.data.policy.target_client_id, "firefox-claude");
202+
203+ const browserStreamResponse = await handleConductorHttpRequest(
204+ {
205+ body: JSON.stringify({
206+ platform: "claude",
207+ prompt: "hello browser stream",
208+ responseMode: "sse",
209+ requestId: "browser-stream-123"
210+ }),
211+ method: "POST",
212+ path: "/v1/browser/request"
213+ },
214+ localApiContext
215+ );
216+ assert.equal(browserStreamResponse.status, 200);
217+ assert.equal(browserStreamResponse.headers["content-type"], "text/event-stream; charset=utf-8");
218+ const browserStreamText = await readResponseBodyText(browserStreamResponse);
219+ const browserStreamFrames = parseSseFrames(browserStreamText);
220+ assert.deepEqual(
221+ browserStreamFrames.map((frame) => frame.event),
222+ ["stream_open", "stream_event", "stream_end"]
223+ );
224+ assert.equal(browserStreamFrames[0].data.request_id, "browser-stream-123");
225+ assert.equal(browserStreamFrames[1].data.seq, 1);
226+ assert.equal(browserStreamFrames[2].data.stream_id, "browser-stream-123");
227
228 const browserRequestCancelResponse = await handleConductorHttpRequest(
229 {
230 body: JSON.stringify({
231 platform: "claude",
232- request_id: "browser-123"
233+ request_id: "browser-stream-123"
234 }),
235 method: "POST",
236 path: "/v1/browser/request/cancel"
237 },
238 localApiContext
239 );
240- assert.equal(browserRequestCancelResponse.status, 501);
241+ assert.equal(browserRequestCancelResponse.status, 200);
242 const browserRequestCancelPayload = parseJsonBody(browserRequestCancelResponse);
243- assert.equal(browserRequestCancelPayload.error, "browser_request_cancel_not_supported");
244+ assert.equal(browserRequestCancelPayload.data.status, "cancel_requested");
245+ assert.equal(browserRequestCancelPayload.data.type, "request_cancel");
246
247 const browserOpenResponse = await handleConductorHttpRequest(
248 {
249@@ -1935,9 +2116,14 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
250 browser.calls.map((entry) => entry.kind === "apiRequest" ? `${entry.kind}:${entry.method}:${entry.path}` : `${entry.kind}:${entry.platform || "-"}`),
251 [
252 "openTab:claude",
253+ "dispatchPluginAction:-",
254 "apiRequest:GET:/api/organizations",
255 "apiRequest:GET:/api/organizations/org-1/chat_conversations",
256 "apiRequest:POST:/api/organizations/org-1/chat_conversations/conv-1/completion",
257+ "apiRequest:GET:/api/organizations",
258+ "apiRequest:GET:/api/organizations/org-1/chat_conversations",
259+ "streamRequest:claude",
260+ "cancelApiRequest:claude",
261 "openTab:claude",
262 "apiRequest:GET:/api/organizations",
263 "apiRequest:GET:/api/organizations/org-1/chat_conversations",
+76,
-8
1@@ -15,6 +15,10 @@ import {
2 ConductorFirefoxWebSocketServer,
3 buildFirefoxWebSocketUrl
4 } from "./firefox-ws.js";
5+import {
6+ BrowserRequestPolicyController,
7+ type BrowserRequestPolicyControllerOptions
8+} from "./browser-request-policy.js";
9 import type { FirefoxBridgeService } from "./firefox-bridge.js";
10 import { handleConductorHttpRequest as handleConductorLocalHttpRequest } from "./local-api.js";
11 import { ConductorLocalControlPlane } from "./local-control-plane.js";
12@@ -31,6 +35,10 @@ export {
13 type FirefoxReloadCommandInput,
14 type FirefoxRequestCredentialsCommandInput
15 } from "./firefox-bridge.js";
16+export {
17+ BrowserRequestPolicyController,
18+ type BrowserRequestPolicyControllerOptions
19+} from "./browser-request-policy.js";
20 export { handleConductorHttpRequest } from "./local-api.js";
21
22 export type ConductorRole = "primary" | "standby";
23@@ -229,7 +237,9 @@ export interface ConductorDaemonOptions {
24 setIntervalImpl?: (handler: () => void, intervalMs: number) => TimerHandle;
25 }
26
27-export interface ConductorRuntimeOptions extends ConductorDaemonOptions {}
28+export interface ConductorRuntimeOptions extends ConductorDaemonOptions {
29+ browserRequestPolicyOptions?: BrowserRequestPolicyControllerOptions;
30+}
31
32 export type ConductorEnvironment = Record<string, string | undefined>;
33
34@@ -523,17 +533,54 @@ function resolveLocalApiListenConfig(localApiBase: string): LocalApiListenConfig
35 };
36 }
37
38-function writeHttpResponse(
39+async function writeHttpResponse(
40 response: ServerResponse<IncomingMessage>,
41 payload: ConductorHttpResponse
42-): void {
43+): Promise<void> {
44+ const writableResponse = response as ServerResponse<IncomingMessage> & {
45+ destroyed?: boolean;
46+ on?(event: string, listener: () => void): unknown;
47+ writableEnded?: boolean;
48+ write?(chunk: string): boolean;
49+ };
50 response.statusCode = payload.status;
51
52 for (const [name, value] of Object.entries(payload.headers)) {
53 response.setHeader(name, value);
54 }
55
56- response.end(payload.body);
57+ if (payload.streamBody == null) {
58+ response.end(payload.body);
59+ return;
60+ }
61+
62+ if (payload.body !== "" && typeof writableResponse.write === "function") {
63+ if (!writableResponse.write(payload.body)) {
64+ await new Promise<void>((resolve) => {
65+ writableResponse.on?.("drain", resolve);
66+ });
67+ }
68+ }
69+
70+ for await (const chunk of payload.streamBody) {
71+ if (writableResponse.destroyed === true) {
72+ break;
73+ }
74+
75+ if (typeof writableResponse.write !== "function") {
76+ continue;
77+ }
78+
79+ if (!writableResponse.write(chunk)) {
80+ await new Promise<void>((resolve) => {
81+ writableResponse.on?.("drain", resolve);
82+ });
83+ }
84+ }
85+
86+ if (writableResponse.writableEnded !== true) {
87+ response.end();
88+ }
89 }
90
91 async function readIncomingRequestBody(request: IncomingMessage): Promise<string | null> {
92@@ -576,6 +623,7 @@ function normalizeIncomingRequestHeaders(
93 }
94
95 class ConductorLocalHttpServer {
96+ private readonly browserRequestPolicy: BrowserRequestPolicyController;
97 private readonly codexdLocalApiBase: string | null;
98 private readonly fetchImpl: typeof fetch;
99 private readonly firefoxWebSocketServer: ConductorFirefoxWebSocketServer;
100@@ -596,8 +644,10 @@ class ConductorLocalHttpServer {
101 fetchImpl: typeof fetch,
102 sharedToken: string | null,
103 version: string | null,
104- now: () => number
105+ now: () => number,
106+ browserRequestPolicyOptions: BrowserRequestPolicyControllerOptions = {}
107 ) {
108+ this.browserRequestPolicy = new BrowserRequestPolicyController(browserRequestPolicyOptions);
109 this.codexdLocalApiBase = codexdLocalApiBase;
110 this.fetchImpl = fetchImpl;
111 this.localApiBase = localApiBase;
112@@ -635,15 +685,32 @@ class ConductorLocalHttpServer {
113 const listenConfig = resolveLocalApiListenConfig(this.localApiBase);
114 const server = createServer((request, response) => {
115 void (async () => {
116+ const requestAbortController = new AbortController();
117+ const abortRequest = () => {
118+ if (!requestAbortController.signal.aborted) {
119+ requestAbortController.abort();
120+ }
121+ };
122+ const requestWithExtendedEvents = request as IncomingMessage & {
123+ on?(event: string, listener: () => void): unknown;
124+ };
125+ const responseWithExtendedEvents = response as ServerResponse<IncomingMessage> & {
126+ on?(event: string, listener: () => void): unknown;
127+ };
128+ requestWithExtendedEvents.on?.("aborted", abortRequest);
129+ responseWithExtendedEvents.on?.("close", abortRequest);
130+
131 const payload = await handleConductorLocalHttpRequest(
132 {
133 body: await readIncomingRequestBody(request),
134 headers: normalizeIncomingRequestHeaders(request.headers),
135 method: request.method ?? "GET",
136- path: request.url ?? "/"
137+ path: request.url ?? "/",
138+ signal: requestAbortController.signal
139 },
140 {
141 browserBridge: this.firefoxWebSocketServer.getBridgeService(),
142+ browserRequestPolicy: this.browserRequestPolicy,
143 browserStateLoader: () => this.firefoxWebSocketServer.getStateSnapshot(),
144 codexdLocalApiBase: this.codexdLocalApiBase,
145 fetchImpl: this.fetchImpl,
146@@ -654,7 +721,7 @@ class ConductorLocalHttpServer {
147 }
148 );
149
150- writeHttpResponse(response, payload);
151+ await writeHttpResponse(response, payload);
152 })().catch((error: unknown) => {
153 response.statusCode = 500;
154 response.setHeader("cache-control", "no-store");
155@@ -1899,7 +1966,8 @@ export class ConductorRuntime {
156 options.fetchImpl ?? globalThis.fetch,
157 this.config.sharedToken,
158 this.config.version,
159- this.now
160+ this.now,
161+ options.browserRequestPolicyOptions
162 );
163 }
164
+899,
-173
1@@ -1,3 +1,4 @@
2+import { randomUUID } from "node:crypto";
3 import {
4 AUTOMATION_STATE_KEY,
5 DEFAULT_AUTOMATION_MODE,
6@@ -40,19 +41,29 @@ import {
7 type ConductorHttpResponse
8 } from "./http-types.js";
9 import type {
10+ BrowserBridgeApiStream,
11 BrowserBridgeApiResponse,
12 BrowserBridgeClientSnapshot,
13 BrowserBridgeController,
14 BrowserBridgeCredentialSnapshot,
15 BrowserBridgeRequestHookSnapshot,
16+ BrowserBridgeStreamEvent,
17 BrowserBridgeStateSnapshot
18 } from "./browser-types.js";
19+import {
20+ BrowserRequestPolicyController,
21+ BrowserRequestPolicyError,
22+ createDefaultBrowserRequestPolicyConfig,
23+ type BrowserRequestAdmission,
24+ type BrowserRequestPolicyLease
25+} from "./browser-request-policy.js";
26
27 const DEFAULT_LIST_LIMIT = 20;
28 const DEFAULT_LOG_LIMIT = 200;
29 const MAX_LIST_LIMIT = 100;
30 const MAX_LOG_LIMIT = 500;
31 const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
32+const DEFAULT_BROWSER_REQUEST_POLICY_CONFIG = createDefaultBrowserRequestPolicyConfig();
33 const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
34 const BROWSER_LOGIN_STATUS_SET = new Set<BrowserLoginStateStatus>(["fresh", "stale", "lost"]);
35 const CODEXD_LOCAL_API_ENV = "BAA_CODEXD_LOCAL_API_BASE";
36@@ -70,6 +81,10 @@ const STATUS_VIEW_HTML_HEADERS = {
37 "cache-control": "no-store",
38 "content-type": "text/html; charset=utf-8"
39 } as const;
40+const SSE_RESPONSE_HEADERS = {
41+ "cache-control": "no-store",
42+ "content-type": "text/event-stream; charset=utf-8"
43+} as const;
44 const BROWSER_CLAUDE_PLATFORM = "claude";
45 const BROWSER_CLAUDE_ROOT_URL = "https://claude.ai/";
46 const BROWSER_CLAUDE_ORGANIZATIONS_PATH = "/api/organizations";
47@@ -77,19 +92,18 @@ const BROWSER_CLAUDE_CONVERSATIONS_PATH = "/api/organizations/{id}/chat_conversa
48 const BROWSER_CLAUDE_CONVERSATION_PATH = "/api/organizations/{id}/chat_conversations/{id}";
49 const BROWSER_CLAUDE_COMPLETION_PATH = "/api/organizations/{id}/chat_conversations/{id}/completion";
50 const SUPPORTED_BROWSER_ACTIONS = [
51+ "controller_reload",
52+ "plugin_status",
53 "request_credentials",
54 "tab_focus",
55 "tab_open",
56- "tab_reload"
57-] as const;
58-const RESERVED_BROWSER_ACTIONS = [
59- "controller_reload",
60- "plugin_status",
61+ "tab_reload",
62 "tab_restore",
63 "ws_reconnect"
64 ] as const;
65-const SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES = ["buffered"] as const;
66-const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = ["sse"] as const;
67+const RESERVED_BROWSER_ACTIONS = [] as const;
68+const SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES = ["buffered", "sse"] as const;
69+const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = [] as const;
70
71 type LocalApiRouteMethod = "GET" | "POST";
72 type LocalApiRouteKind = "probe" | "read" | "write";
73@@ -166,6 +180,7 @@ type UpstreamErrorEnvelope = JsonObject & {
74
75 interface LocalApiRequestContext {
76 browserBridge: BrowserBridgeController | null;
77+ browserRequestPolicy: BrowserRequestPolicyController | null;
78 browserStateLoader: () => BrowserBridgeStateSnapshot | null;
79 codexdLocalApiBase: string | null;
80 fetchImpl: typeof fetch;
81@@ -212,6 +227,7 @@ export interface ConductorRuntimeApiSnapshot {
82
83 export interface ConductorLocalApiContext {
84 browserBridge?: BrowserBridgeController | null;
85+ browserRequestPolicy?: BrowserRequestPolicyController | null;
86 browserStateLoader?: (() => BrowserBridgeStateSnapshot | null) | null;
87 codexdLocalApiBase?: string | null;
88 fetchImpl?: typeof fetch;
89@@ -1145,8 +1161,10 @@ interface BrowserActionDispatchResult {
90 interface BrowserRequestExecutionResult {
91 client_id: string;
92 conversation: ClaudeConversationSummary | null;
93+ lease: BrowserRequestPolicyLease | null;
94 organization: ClaudeOrganizationSummary | null;
95 platform: string;
96+ policy: BrowserRequestAdmission;
97 request_body: JsonValue | null;
98 request_id: string;
99 request_method: string;
100@@ -1155,6 +1173,7 @@ interface BrowserRequestExecutionResult {
101 response: JsonValue | string | null;
102 response_mode: BrowserRequestResponseMode;
103 status: number | null;
104+ stream: BrowserBridgeApiStream | null;
105 }
106
107 function asUnknownRecord(value: unknown): Record<string, unknown> | null {
108@@ -1263,6 +1282,71 @@ function parseBrowserProxyBody(body: unknown): JsonValue | string | null {
109 return String(body);
110 }
111
112+function serializeSseFrame(event: string, data: JsonValue): string {
113+ const serialized = JSON.stringify(data);
114+ return `event: ${event}\ndata: ${serialized}\n\n`;
115+}
116+
117+function parseSseJsonValue(value: string): JsonValue | string {
118+ try {
119+ return JSON.parse(value) as JsonValue;
120+ } catch {
121+ return value;
122+ }
123+}
124+
125+function parseBrowserSseChunks(
126+ rawBody: string
127+): Array<{
128+ data: JsonValue | string;
129+ event: string | null;
130+ raw: string;
131+}> {
132+ const chunks = String(rawBody || "")
133+ .split(/\r?\n\r?\n/gu)
134+ .map((chunk) => chunk.trim())
135+ .filter((chunk) => chunk !== "");
136+
137+ return chunks.map((chunk) => {
138+ const lines = chunk.split(/\r?\n/gu);
139+ let event: string | null = null;
140+ const dataLines: string[] = [];
141+
142+ for (const line of lines) {
143+ if (line.startsWith("event:")) {
144+ event = line.slice("event:".length).trim() || null;
145+ continue;
146+ }
147+
148+ if (line.startsWith("data:")) {
149+ dataLines.push(line.slice("data:".length).trimStart());
150+ }
151+ }
152+
153+ const joinedData = dataLines.join("\n");
154+
155+ return {
156+ data: parseSseJsonValue(joinedData === "" ? chunk : joinedData),
157+ event,
158+ raw: chunk
159+ };
160+ });
161+}
162+
163+function createSseResponse(
164+ body: string,
165+ streamBody: AsyncIterable<string> | null = null
166+): ConductorHttpResponse {
167+ return {
168+ status: 200,
169+ headers: {
170+ ...SSE_RESPONSE_HEADERS
171+ },
172+ body,
173+ streamBody
174+ };
175+}
176+
177 function readOptionalQueryString(url: URL, ...fieldNames: string[]): string | undefined {
178 for (const fieldName of fieldNames) {
179 const value = url.searchParams.get(fieldName);
180@@ -1793,6 +1877,13 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
181 "The requested browser proxy request id is already in flight.",
182 details
183 );
184+ case "request_not_found":
185+ return new LocalApiHttpError(
186+ 404,
187+ "browser_request_not_found",
188+ `The requested browser proxy request is not in flight for ${action}.`,
189+ details
190+ );
191 case "request_timeout":
192 return new LocalApiHttpError(
193 504,
194@@ -1820,6 +1911,33 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
195 }
196 }
197
198+function createBrowserPolicyHttpError(action: string, error: BrowserRequestPolicyError): LocalApiHttpError {
199+ switch (error.code) {
200+ case "circuit_open":
201+ return new LocalApiHttpError(
202+ 429,
203+ "browser_risk_limited",
204+ `Browser request risk controls rejected ${action}.`,
205+ compactJsonObject({
206+ ...error.details,
207+ action,
208+ error_code: error.code
209+ })
210+ );
211+ default:
212+ return new LocalApiHttpError(
213+ 503,
214+ "browser_risk_limited",
215+ `Browser request risk controls could not schedule ${action}.`,
216+ compactJsonObject({
217+ ...error.details,
218+ action,
219+ error_code: error.code
220+ })
221+ );
222+ }
223+}
224+
225 function requireBrowserBridge(context: LocalApiRequestContext): BrowserBridgeController {
226 if (context.browserBridge == null) {
227 throw new LocalApiHttpError(
228@@ -1832,6 +1950,59 @@ function requireBrowserBridge(context: LocalApiRequestContext): BrowserBridgeCon
229 return context.browserBridge;
230 }
231
232+function resolveBrowserRequestPolicy(context: LocalApiRequestContext): BrowserRequestPolicyController {
233+ return context.browserRequestPolicy ?? new BrowserRequestPolicyController({
234+ config: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG
235+ });
236+}
237+
238+function selectBrowserClient(
239+ state: BrowserBridgeStateSnapshot,
240+ requestedClientId?: string | null
241+): BrowserBridgeClientSnapshot | null {
242+ const normalizedRequestedClientId = normalizeOptionalString(requestedClientId);
243+
244+ if (normalizedRequestedClientId != null) {
245+ return state.clients.find((entry) => entry.client_id === normalizedRequestedClientId) ?? null;
246+ }
247+
248+ return (
249+ state.clients.find((entry) => entry.client_id === state.active_client_id)
250+ ?? [...state.clients].sort((left, right) => right.last_message_at - left.last_message_at)[0]
251+ ?? null
252+ );
253+}
254+
255+function ensureBrowserClientReady(
256+ client: BrowserBridgeClientSnapshot | null,
257+ platform: string,
258+ requestedClientId?: string | null
259+): BrowserBridgeClientSnapshot {
260+ if (client != null) {
261+ return client;
262+ }
263+
264+ const normalizedRequestedClientId = normalizeOptionalString(requestedClientId);
265+
266+ if (normalizedRequestedClientId == null) {
267+ throw new LocalApiHttpError(
268+ 503,
269+ "browser_bridge_unavailable",
270+ `No active Firefox bridge client is connected for ${platform} requests.`
271+ );
272+ }
273+
274+ throw new LocalApiHttpError(
275+ 409,
276+ "browser_client_not_found",
277+ `Firefox bridge client "${normalizedRequestedClientId}" is not connected.`,
278+ compactJsonObject({
279+ client_id: normalizedRequestedClientId,
280+ platform
281+ })
282+ );
283+}
284+
285 function ensureClaudeBridgeReady(
286 selection: ClaudeBrowserSelection,
287 requestedClientId?: string | null
288@@ -2255,6 +2426,29 @@ async function resolveClaudeOrganization(
289 return currentOrganization ?? organizations[0]!;
290 }
291
292+function resolveBrowserRequestId(requestId?: string | null): string {
293+ return normalizeOptionalString(requestId) ?? randomUUID();
294+}
295+
296+async function beginBrowserRequestLease(
297+ context: LocalApiRequestContext,
298+ target: {
299+ clientId: string;
300+ platform: string;
301+ },
302+ requestId: string
303+): Promise<BrowserRequestPolicyLease> {
304+ try {
305+ return await resolveBrowserRequestPolicy(context).beginRequest(target, requestId);
306+ } catch (error) {
307+ if (error instanceof BrowserRequestPolicyError) {
308+ throw createBrowserPolicyHttpError("browser request", error);
309+ }
310+
311+ throw error;
312+ }
313+}
314+
315 async function listClaudeConversations(
316 context: LocalApiRequestContext,
317 selection: ClaudeBrowserSelection,
318@@ -2419,6 +2613,7 @@ async function readClaudeConversationCurrentData(
319
320 async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<JsonObject> {
321 const browserState = loadBrowserState(context);
322+ const policySnapshot = resolveBrowserRequestPolicy(context).getSnapshot();
323 const filters = readBrowserStatusFilters(context.url);
324 const records = await listBrowserMergedRecords(context, browserState, filters);
325 const currentClient =
326@@ -2468,6 +2663,40 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
327 supported: true
328 },
329 filters: summarizeBrowserFilters(filters),
330+ policy: {
331+ defaults: {
332+ backoff: policySnapshot.defaults.backoff,
333+ circuit_breaker: policySnapshot.defaults.circuitBreaker,
334+ concurrency: policySnapshot.defaults.concurrency,
335+ jitter: policySnapshot.defaults.jitter,
336+ rate_limit: policySnapshot.defaults.rateLimit,
337+ stream: {
338+ idle_timeout_ms: policySnapshot.defaults.stream.idleTimeoutMs,
339+ max_buffered_bytes: policySnapshot.defaults.stream.maxBufferedBytes,
340+ max_buffered_events: policySnapshot.defaults.stream.maxBufferedEvents,
341+ open_timeout_ms: policySnapshot.defaults.stream.openTimeoutMs
342+ }
343+ },
344+ platforms: policySnapshot.platforms.map((entry) => compactJsonObject({
345+ last_dispatched_at: entry.lastDispatchedAt ?? undefined,
346+ platform: entry.platform,
347+ recent_dispatch_count: entry.recentDispatchCount,
348+ waiting: entry.waiting
349+ })),
350+ targets: policySnapshot.targets.map((entry) => compactJsonObject({
351+ backoff_until: entry.backoffUntil ?? undefined,
352+ circuit_retry_at: entry.circuitRetryAt ?? undefined,
353+ circuit_state: entry.circuitState,
354+ client_id: entry.clientId,
355+ consecutive_failures: entry.consecutiveFailures,
356+ in_flight: entry.inFlight,
357+ last_error: entry.lastError ?? undefined,
358+ last_failure_at: entry.lastFailureAt ?? undefined,
359+ last_success_at: entry.lastSuccessAt ?? undefined,
360+ platform: entry.platform,
361+ waiting: entry.waiting
362+ }))
363+ },
364 records: records.map((record) =>
365 serializeBrowserMergedRecord(record, browserState.active_client_id)
366 ),
367@@ -2693,14 +2922,23 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
368 "credentials",
369 "api_endpoints",
370 "client_log",
371- "api_response"
372+ "api_response",
373+ "stream_open",
374+ "stream_event",
375+ "stream_end",
376+ "stream_error"
377 ],
378 outbound_messages: [
379 "hello_ack",
380 "state_snapshot",
381 "action_result",
382 "open_tab",
383+ "plugin_status",
384+ "ws_reconnect",
385+ "controller_reload",
386+ "tab_restore",
387 "api_request",
388+ "request_cancel",
389 "request_credentials",
390 "reload",
391 "error"
392@@ -2717,10 +2955,10 @@ function buildBrowserActionContract(origin: string): JsonObject {
393 route: describeRoute(requireRouteDefinition("browser.actions")),
394 request_body: {
395 action:
396- "必填字符串。当前正式支持 request_credentials、tab_open、tab_focus、tab_reload;plugin_status、ws_reconnect、controller_reload、tab_restore 已保留进合同但当前返回 501。",
397- platform: "tab_open、tab_focus、request_credentials 建议带非空平台字符串;当前正式平台仍是 claude。",
398+ "必填字符串。当前正式支持 plugin_status、request_credentials、tab_open、tab_focus、tab_reload、tab_restore、ws_reconnect、controller_reload。",
399+ platform: "tab_open、tab_focus、request_credentials、tab_restore 建议带非空平台字符串;当前正式平台仍是 claude。",
400 clientId: "可选字符串;指定目标 Firefox bridge client。",
401- reason: "可选字符串;tab_reload 和 request_credentials 会原样透传给浏览器侧。"
402+ reason: "可选字符串;request_credentials、tab_reload、tab_restore、ws_reconnect、controller_reload 会原样透传给浏览器侧。"
403 },
404 supported_actions: [...SUPPORTED_BROWSER_ACTIONS],
405 reserved_actions: [...RESERVED_BROWSER_ACTIONS],
406@@ -2743,8 +2981,7 @@ function buildBrowserActionContract(origin: string): JsonObject {
407 ],
408 error_semantics: [
409 "503 browser_bridge_unavailable: 当前没有可用 Firefox bridge client。",
410- "409 browser_client_not_found: 指定的 clientId 当前未连接。",
411- "501 browser_action_not_supported: 合同已预留,但当前 bridge 还没有实现该管理动作。"
412+ "409 browser_client_not_found: 指定的 clientId 当前未连接。"
413 ]
414 };
415 }
416@@ -2764,7 +3001,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
417 organizationId: "可选 Claude 字段;覆盖自动选择的 organization。",
418 conversationId: "可选 Claude 字段;覆盖自动选择的 conversation。",
419 responseMode:
420- '可选字符串 buffered 或 sse;当前正式只实现 buffered,sse 会返回 501。',
421+ '可选字符串 buffered 或 sse;buffered 返回 JSON,sse 返回 text/event-stream,并按 stream_open / stream_event / stream_end / stream_error 编码。',
422 timeoutMs: `可选整数 > 0;默认 ${DEFAULT_BROWSER_PROXY_TIMEOUT_MS}。`
423 },
424 supported_response_modes: [...SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES],
425@@ -2790,8 +3027,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
426 "400 invalid_request: 缺字段或组合不合法;例如既没有 path 也没有 Claude prompt。",
427 "409 claude_credentials_unavailable: Claude prompt 模式还没有捕获到可用凭证。",
428 "503 browser_bridge_unavailable: 当前没有活跃 Firefox bridge client。",
429- "4xx/5xx browser_upstream_error: 浏览器本地代理已返回上游 HTTP 错误。",
430- "501 browser_streaming_not_supported: responseMode=sse 已入合同,但当前 HTTP 面还没有实现。"
431+ "4xx/5xx browser_upstream_error: 浏览器本地代理已返回上游 HTTP 错误;responseMode=sse 时会在事件流里交付 stream_error。"
432 ]
433 };
434 }
435@@ -2802,14 +3038,16 @@ function buildBrowserRequestCancelContract(): JsonObject {
436 request_body: {
437 requestId: "必填字符串;对应 /v1/browser/request 的 requestId 或响应里返回的 proxy.request_id。",
438 platform: "必填字符串;与原始请求平台保持一致。",
439- clientId: "可选字符串;用于未来精确定位执行侧。",
440+ clientId: "可选字符串;优先用于校验调用方期望的执行侧。",
441 reason: "可选字符串;取消原因。"
442 },
443- current_state: "reserved",
444- implementation_status: "当前返回 501,等待 browser bridge 增加 request_cancel plumbing。",
445+ current_state: "active",
446+ implementation_status: "会把 cancel 请求转发给当前执行中的 Firefox bridge client,并让原始 request 尽快以取消错误结束。",
447 error_semantics: [
448 "400 invalid_request: requestId 或 platform 缺失。",
449- "501 browser_request_cancel_not_supported: 合同已固定,但本轮不实现真实取消链路。"
450+ "404 browser_request_not_found: 对应 requestId 当前不在执行中。",
451+ "409 browser_request_client_mismatch: 指定了 clientId,但与实际执行中的 client 不一致。",
452+ "503 browser_bridge_unavailable: 当前执行中的 Firefox bridge client 已断开。"
453 ]
454 };
455 }
456@@ -2846,7 +3084,7 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
457 "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
458 "GET /v1/browser remains the shared read model for login-state metadata and plugin connectivity.",
459 "The generic browser HTTP surface currently supports Claude only and expects a local Firefox bridge client.",
460- "POST /v1/browser/request with responseMode=sse and POST /v1/browser/request/cancel are already reserved in the contract but intentionally return 501 in this task.",
461+ "POST /v1/browser/request now supports buffered JSON and formal SSE event envelopes; POST /v1/browser/request/cancel cancels an in-flight browser request by requestId.",
462 "The /v1/browser/claude/* routes remain available as legacy wrappers during the migration window."
463 ]
464 };
465@@ -3430,7 +3668,7 @@ async function handleScopedDescribeRead(
466 notes: [
467 "This surface is intended to be enough for business-query discovery without reading external docs.",
468 "Use GET /v1/status for the narrow read-only compatibility snapshot and GET /v1/status/ui for the matching HTML panel.",
469- "Business-facing browser work now lands on POST /v1/browser/request; POST /v1/browser/request/cancel is already reserved in the contract but currently returns 501.",
470+ "Business-facing browser work now lands on POST /v1/browser/request; POST /v1/browser/request/cancel cancels an in-flight request by requestId.",
471 "GET /v1/browser/claude/current and POST /v1/browser/claude/send remain available as legacy Claude helpers during migration.",
472 "All /v1/codex routes proxy the independent codexd daemon instead of an in-process bridge.",
473 "If you pivot to /describe/control for /v1/exec or /v1/files/*, those host-ops routes require Authorization: Bearer <BAA_SHARED_TOKEN>.",
474@@ -3653,6 +3891,278 @@ function serializeClaudeConversationSummary(summary: ClaudeConversationSummary |
475 };
476 }
477
478+function serializeBrowserRequestPolicyAdmission(admission: BrowserRequestAdmission): JsonObject {
479+ return compactJsonObject({
480+ admitted_at: admission.admittedAt,
481+ backoff_delay_ms: admission.backoffDelayMs || undefined,
482+ circuit_state: admission.circuitState,
483+ jitter_delay_ms: admission.jitterDelayMs || undefined,
484+ platform: admission.platform,
485+ queue_delay_ms: admission.queueDelayMs || undefined,
486+ rate_limit_delay_ms: admission.rateLimitDelayMs || undefined,
487+ request_id: admission.requestId,
488+ requested_at: admission.requestedAt,
489+ target_client_id: admission.targetClientId
490+ });
491+}
492+
493+function serializeBrowserStreamPartialState(
494+ partial: {
495+ buffered_bytes: number;
496+ event_count: number;
497+ last_seq: number;
498+ opened: boolean;
499+ }
500+): JsonObject {
501+ return {
502+ buffered_bytes: partial.buffered_bytes,
503+ event_count: partial.event_count,
504+ last_seq: partial.last_seq,
505+ opened: partial.opened
506+ };
507+}
508+
509+function isBrowserRequestCancelledError(error: unknown): boolean {
510+ if (!(error instanceof Error)) {
511+ return false;
512+ }
513+
514+ const bridgeCode = readBridgeErrorCode(error);
515+ return bridgeCode === "request_cancelled" || bridgeCode === "downstream_disconnected";
516+}
517+
518+function buildBrowserRequestLeaseOutcome(error: unknown): {
519+ code?: string | null;
520+ message?: string | null;
521+ status: "cancelled" | "failure";
522+} {
523+ if (isBrowserRequestCancelledError(error)) {
524+ return {
525+ code: readBridgeErrorCode(error),
526+ message: error instanceof Error ? error.message : String(error),
527+ status: "cancelled"
528+ };
529+ }
530+
531+ return {
532+ code:
533+ readBridgeErrorCode(error)
534+ ?? (error instanceof LocalApiHttpError ? error.error : null),
535+ message: error instanceof Error ? error.message : String(error),
536+ status: "failure"
537+ };
538+}
539+
540+async function nextBrowserStreamResult(
541+ iterator: AsyncIterator<BrowserBridgeStreamEvent>,
542+ signal?: AbortSignal
543+): Promise<IteratorResult<BrowserBridgeStreamEvent> | null> {
544+ if (signal == null) {
545+ return await iterator.next();
546+ }
547+
548+ if (signal.aborted) {
549+ return null;
550+ }
551+
552+ return await new Promise<IteratorResult<BrowserBridgeStreamEvent> | null>((resolve, reject) => {
553+ const onAbort = () => {
554+ signal.removeEventListener("abort", onAbort);
555+ resolve(null);
556+ };
557+
558+ signal.addEventListener("abort", onAbort, {
559+ once: true
560+ });
561+
562+ iterator.next().then(
563+ (result) => {
564+ signal.removeEventListener("abort", onAbort);
565+ resolve(result);
566+ },
567+ (error) => {
568+ signal.removeEventListener("abort", onAbort);
569+ reject(error);
570+ }
571+ );
572+ });
573+}
574+
575+function buildBrowserSseSuccessResponse(
576+ execution: BrowserRequestExecutionResult,
577+ signal?: AbortSignal
578+): ConductorHttpResponse {
579+ if (execution.stream == null || execution.lease == null) {
580+ return createSseResponse(
581+ serializeSseFrame(
582+ "stream_error",
583+ compactJsonObject({
584+ error: "browser_stream_unavailable",
585+ message: "Browser stream execution did not return an active stream.",
586+ platform: execution.platform,
587+ request_id: execution.request_id,
588+ stream_id: execution.request_id
589+ })
590+ )
591+ );
592+ }
593+
594+ const stream = execution.stream;
595+ const iterator = stream[Symbol.asyncIterator]();
596+ let leaseCompleted = false;
597+
598+ const completeLease = (outcome: {
599+ code?: string | null;
600+ message?: string | null;
601+ status: "cancelled" | "failure" | "success";
602+ }) => {
603+ if (leaseCompleted) {
604+ return;
605+ }
606+
607+ leaseCompleted = true;
608+ execution.lease?.complete(outcome);
609+ };
610+
611+ const streamBody = (async function* (): AsyncGenerator<string> {
612+ try {
613+ while (true) {
614+ const next = await nextBrowserStreamResult(iterator, signal);
615+
616+ if (next == null) {
617+ stream.cancel("downstream_disconnected");
618+ completeLease({
619+ code: "downstream_disconnected",
620+ message: "The HTTP SSE client disconnected before the browser stream completed.",
621+ status: "cancelled"
622+ });
623+ return;
624+ }
625+
626+ if (next.done) {
627+ completeLease({
628+ status: "success"
629+ });
630+ return;
631+ }
632+
633+ const event = next.value;
634+
635+ switch (event.type) {
636+ case "stream_open":
637+ yield serializeSseFrame(
638+ "stream_open",
639+ compactJsonObject({
640+ client_id: execution.client_id,
641+ conversation: serializeClaudeConversationSummary(execution.conversation) ?? undefined,
642+ meta: parseBrowserProxyBody(event.meta) ?? undefined,
643+ organization: serializeClaudeOrganizationSummary(execution.organization) ?? undefined,
644+ platform: execution.platform,
645+ policy: serializeBrowserRequestPolicyAdmission(execution.policy),
646+ request_id: execution.request_id,
647+ request_method: execution.request_method,
648+ request_mode: execution.request_mode,
649+ request_path: execution.request_path,
650+ response_mode: execution.response_mode,
651+ status: event.status ?? undefined,
652+ stream_id: event.streamId
653+ })
654+ );
655+ break;
656+ case "stream_event":
657+ yield serializeSseFrame(
658+ "stream_event",
659+ compactJsonObject({
660+ data: parseBrowserProxyBody(event.data) ?? null,
661+ event: event.event ?? undefined,
662+ raw: event.raw ?? undefined,
663+ request_id: execution.request_id,
664+ seq: event.seq,
665+ stream_id: event.streamId
666+ })
667+ );
668+ break;
669+ case "stream_end":
670+ yield serializeSseFrame(
671+ "stream_end",
672+ compactJsonObject({
673+ partial: serializeBrowserStreamPartialState(event.partial),
674+ request_id: execution.request_id,
675+ status: event.status ?? undefined,
676+ stream_id: event.streamId
677+ })
678+ );
679+ completeLease({
680+ status: "success"
681+ });
682+ return;
683+ case "stream_error":
684+ yield serializeSseFrame(
685+ "stream_error",
686+ compactJsonObject({
687+ error: event.code,
688+ message: event.message,
689+ partial: serializeBrowserStreamPartialState(event.partial),
690+ request_id: execution.request_id,
691+ status: event.status ?? undefined,
692+ stream_id: event.streamId
693+ })
694+ );
695+ completeLease(
696+ event.code === "request_cancelled" || event.code === "downstream_disconnected"
697+ ? {
698+ code: event.code,
699+ message: event.message,
700+ status: "cancelled"
701+ }
702+ : {
703+ code: event.code,
704+ message: event.message,
705+ status: "failure"
706+ }
707+ );
708+ return;
709+ }
710+ }
711+ } finally {
712+ if (!leaseCompleted) {
713+ stream.cancel("stream_closed");
714+ completeLease({
715+ code: "stream_closed",
716+ message: "Browser stream was closed before the conductor completed the SSE relay.",
717+ status: "cancelled"
718+ });
719+ }
720+ }
721+ })();
722+
723+ return createSseResponse("", streamBody);
724+}
725+
726+function buildBrowserSseErrorResponse(
727+ input: {
728+ error: string;
729+ message: string;
730+ platform: string;
731+ requestId: string;
732+ status?: number | null;
733+ }
734+): ConductorHttpResponse {
735+ return createSseResponse(
736+ serializeSseFrame(
737+ "stream_error",
738+ compactJsonObject({
739+ error: input.error,
740+ message: input.message,
741+ platform: input.platform,
742+ request_id: input.requestId,
743+ status: input.status ?? undefined,
744+ stream_id: input.requestId
745+ })
746+ )
747+ );
748+}
749+
750 function dispatchBrowserAction(
751 context: LocalApiRequestContext,
752 input: {
753@@ -3719,18 +4229,25 @@ function dispatchBrowserAction(
754 case "plugin_status":
755 case "ws_reconnect":
756 case "controller_reload":
757- case "tab_restore":
758- throw new LocalApiHttpError(
759- 501,
760- "browser_action_not_supported",
761- `Browser action "${input.action}" is reserved in the HTTP contract but not implemented yet.`,
762- compactJsonObject({
763- action: input.action,
764- browser_status_route: input.action === "plugin_status" ? "/v1/browser" : undefined,
765- route: "/v1/browser/actions",
766- supported_actions: [...SUPPORTED_BROWSER_ACTIONS]
767- })
768- );
769+ case "tab_restore": {
770+ const receipt = requireBrowserBridge(context).dispatchPluginAction({
771+ action: input.action,
772+ clientId: input.clientId,
773+ platform: input.platform,
774+ reason: input.reason
775+ });
776+
777+ return {
778+ action: input.action,
779+ client_id: receipt.clientId,
780+ connection_id: receipt.connectionId,
781+ dispatched_at: receipt.dispatchedAt,
782+ platform: input.platform ?? null,
783+ reason: input.reason ?? null,
784+ status: "dispatched",
785+ type: receipt.type
786+ };
787+ }
788 }
789 } catch (error) {
790 if (error instanceof LocalApiHttpError) {
791@@ -3759,18 +4276,7 @@ async function executeBrowserRequest(
792 }
793 ): Promise<BrowserRequestExecutionResult> {
794 const responseMode = input.responseMode ?? "buffered";
795-
796- if (responseMode === "sse") {
797- throw new LocalApiHttpError(
798- 501,
799- "browser_streaming_not_supported",
800- "Browser SSE relay is reserved in the HTTP contract but not implemented on this surface yet.",
801- {
802- platform: input.platform,
803- route: "/v1/browser/request"
804- }
805- );
806- }
807+ const requestId = resolveBrowserRequestId(input.requestId);
808
809 const explicitPath = normalizeOptionalString(input.path);
810 const prompt = normalizeOptionalString(input.prompt);
811@@ -3801,81 +4307,205 @@ async function executeBrowserRequest(
812 selectClaudeBrowserClient(loadBrowserState(context), input.clientId),
813 input.clientId
814 );
815- const organization = await resolveClaudeOrganization(
816- context,
817- selection,
818- input.organizationId,
819- input.timeoutMs
820- );
821- const conversation = await resolveClaudeConversation(
822+ const lease = await beginBrowserRequestLease(
823 context,
824- selection,
825- organization.id,
826 {
827- conversationId: input.conversationId,
828- createIfMissing: true,
829- timeoutMs: input.timeoutMs
830- }
831- );
832- const requestPath = buildClaudeRequestPath(
833- BROWSER_CLAUDE_COMPLETION_PATH,
834- organization.id,
835- conversation.id
836+ clientId: selection.client.client_id,
837+ platform: input.platform
838+ },
839+ requestId
840 );
841+
842+ try {
843+ const organization = await resolveClaudeOrganization(
844+ context,
845+ selection,
846+ input.organizationId,
847+ input.timeoutMs
848+ );
849+ const conversation = await resolveClaudeConversation(
850+ context,
851+ selection,
852+ organization.id,
853+ {
854+ conversationId: input.conversationId,
855+ createIfMissing: true,
856+ timeoutMs: input.timeoutMs
857+ }
858+ );
859+ const requestPath = buildClaudeRequestPath(
860+ BROWSER_CLAUDE_COMPLETION_PATH,
861+ organization.id,
862+ conversation.id
863+ );
864+
865+ if (responseMode === "sse") {
866+ const stream = requireBrowserBridge(context).streamRequest({
867+ body: requestBody ?? { prompt: "" },
868+ clientId: selection.client.client_id,
869+ headers: input.headers,
870+ id: requestId,
871+ idleTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.idleTimeoutMs,
872+ maxBufferedBytes: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedBytes,
873+ maxBufferedEvents: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedEvents,
874+ method: requestMethod,
875+ openTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.openTimeoutMs,
876+ path: requestPath,
877+ platform: input.platform,
878+ streamId: requestId,
879+ timeoutMs: input.timeoutMs
880+ });
881+
882+ return {
883+ client_id: selection.client.client_id,
884+ conversation,
885+ lease,
886+ organization,
887+ platform: input.platform,
888+ policy: lease.admission,
889+ request_body: requestBody ?? null,
890+ request_id: requestId,
891+ request_method: requestMethod,
892+ request_mode: "claude_prompt",
893+ request_path: requestPath,
894+ response: null,
895+ response_mode: responseMode,
896+ status: null,
897+ stream
898+ };
899+ }
900+
901+ const result = await requestBrowserProxy(context, {
902+ action: "browser request",
903+ body: requestBody ?? { prompt: "" },
904+ clientId: selection.client.client_id,
905+ headers: input.headers,
906+ id: requestId,
907+ method: requestMethod,
908+ path: requestPath,
909+ platform: input.platform,
910+ timeoutMs: input.timeoutMs
911+ });
912+ lease.complete({
913+ status: "success"
914+ });
915+
916+ return {
917+ client_id: result.apiResponse.clientId,
918+ conversation,
919+ lease: null,
920+ organization,
921+ platform: input.platform,
922+ policy: lease.admission,
923+ request_body: requestBody ?? null,
924+ request_id: result.apiResponse.id,
925+ request_method: requestMethod,
926+ request_mode: "claude_prompt",
927+ request_path: requestPath,
928+ response: result.body,
929+ response_mode: responseMode,
930+ status: result.apiResponse.status,
931+ stream: null
932+ };
933+ } catch (error) {
934+ lease.complete(buildBrowserRequestLeaseOutcome(error));
935+ throw error;
936+ }
937+ }
938+
939+ const targetClient =
940+ input.platform === BROWSER_CLAUDE_PLATFORM
941+ ? ensureClaudeBridgeReady(
942+ selectClaudeBrowserClient(loadBrowserState(context), input.clientId),
943+ input.clientId
944+ ).client
945+ : ensureBrowserClientReady(
946+ selectBrowserClient(loadBrowserState(context), input.clientId),
947+ input.platform,
948+ input.clientId
949+ );
950+ const lease = await beginBrowserRequestLease(
951+ context,
952+ {
953+ clientId: targetClient.client_id,
954+ platform: input.platform
955+ },
956+ requestId
957+ );
958+
959+ try {
960+ if (responseMode === "sse") {
961+ const stream = requireBrowserBridge(context).streamRequest({
962+ body: requestBody,
963+ clientId: targetClient.client_id,
964+ headers: input.headers,
965+ id: requestId,
966+ idleTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.idleTimeoutMs,
967+ maxBufferedBytes: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedBytes,
968+ maxBufferedEvents: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedEvents,
969+ method: requestMethod,
970+ openTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.openTimeoutMs,
971+ path: explicitPath,
972+ platform: input.platform,
973+ streamId: requestId,
974+ timeoutMs: input.timeoutMs
975+ });
976+
977+ return {
978+ client_id: targetClient.client_id,
979+ conversation: null,
980+ lease,
981+ organization: null,
982+ platform: input.platform,
983+ policy: lease.admission,
984+ request_body: requestBody ?? null,
985+ request_id: requestId,
986+ request_method: requestMethod,
987+ request_mode: "api_request",
988+ request_path: explicitPath,
989+ response: null,
990+ response_mode: responseMode,
991+ status: null,
992+ stream
993+ };
994+ }
995+
996 const result = await requestBrowserProxy(context, {
997 action: "browser request",
998- body: requestBody ?? { prompt: "" },
999- clientId: selection.client.client_id,
1000+ body: requestBody,
1001+ clientId: targetClient.client_id,
1002 headers: input.headers,
1003- id: input.requestId,
1004+ id: requestId,
1005 method: requestMethod,
1006- path: requestPath,
1007+ path: explicitPath,
1008 platform: input.platform,
1009 timeoutMs: input.timeoutMs
1010 });
1011+ lease.complete({
1012+ status: "success"
1013+ });
1014
1015 return {
1016 client_id: result.apiResponse.clientId,
1017- conversation,
1018- organization,
1019+ conversation: null,
1020+ lease: null,
1021+ organization: null,
1022 platform: input.platform,
1023+ policy: lease.admission,
1024 request_body: requestBody ?? null,
1025 request_id: result.apiResponse.id,
1026 request_method: requestMethod,
1027- request_mode: "claude_prompt",
1028- request_path: requestPath,
1029+ request_mode: "api_request",
1030+ request_path: explicitPath,
1031 response: result.body,
1032 response_mode: responseMode,
1033- status: result.apiResponse.status
1034+ status: result.apiResponse.status,
1035+ stream: null
1036 };
1037+ } catch (error) {
1038+ lease.complete(buildBrowserRequestLeaseOutcome(error));
1039+ throw error;
1040 }
1041-
1042- const result = await requestBrowserProxy(context, {
1043- action: "browser request",
1044- body: requestBody,
1045- clientId: input.clientId,
1046- headers: input.headers,
1047- id: input.requestId,
1048- method: requestMethod,
1049- path: explicitPath,
1050- platform: input.platform,
1051- timeoutMs: input.timeoutMs
1052- });
1053-
1054- return {
1055- client_id: result.apiResponse.clientId,
1056- conversation: null,
1057- organization: null,
1058- platform: input.platform,
1059- request_body: requestBody ?? null,
1060- request_id: result.apiResponse.id,
1061- request_method: requestMethod,
1062- request_mode: "api_request",
1063- request_path: explicitPath,
1064- response: result.body,
1065- response_mode: responseMode,
1066- status: result.apiResponse.status
1067- };
1068 }
1069
1070 async function handleBrowserActions(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1071@@ -3915,6 +4545,8 @@ async function handleBrowserActions(context: LocalApiRequestContext): Promise<Co
1072 async function handleBrowserRequest(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1073 const body = readBodyObject(context.request, true);
1074 const platform = readOptionalStringBodyField(body, "platform");
1075+ const responseMode = readBrowserRequestResponseMode(body);
1076+ const requestId = readOptionalStringBodyField(body, "requestId", "request_id", "id");
1077
1078 if (platform == null) {
1079 throw new LocalApiHttpError(
1080@@ -3927,46 +4559,70 @@ async function handleBrowserRequest(context: LocalApiRequestContext): Promise<Co
1081 );
1082 }
1083
1084- const execution = await executeBrowserRequest(context, {
1085- clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1086- conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
1087- headers:
1088- readOptionalStringMap(body, "headers")
1089- ?? readOptionalStringMap(body, "request_headers")
1090- ?? undefined,
1091- method: readOptionalStringBodyField(body, "method"),
1092- organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
1093- path: readOptionalStringBodyField(body, "path"),
1094- platform,
1095- prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
1096- requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
1097- requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
1098- responseMode: readBrowserRequestResponseMode(body),
1099- timeoutMs: readOptionalTimeoutMs(body, context.url)
1100- });
1101+ try {
1102+ const execution = await executeBrowserRequest(context, {
1103+ clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1104+ conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
1105+ headers:
1106+ readOptionalStringMap(body, "headers")
1107+ ?? readOptionalStringMap(body, "request_headers")
1108+ ?? undefined,
1109+ method: readOptionalStringBodyField(body, "method"),
1110+ organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
1111+ path: readOptionalStringBodyField(body, "path"),
1112+ platform,
1113+ prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
1114+ requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
1115+ requestId,
1116+ responseMode,
1117+ timeoutMs: readOptionalTimeoutMs(body, context.url)
1118+ });
1119
1120- return buildSuccessEnvelope(context.requestId, 200, {
1121- client_id: execution.client_id,
1122- conversation: serializeClaudeConversationSummary(execution.conversation),
1123- organization: serializeClaudeOrganizationSummary(execution.organization),
1124- platform: execution.platform,
1125- proxy: {
1126- method: execution.request_method,
1127- path: execution.request_path,
1128- request_body: execution.request_body,
1129- request_id: execution.request_id,
1130- response_mode: execution.response_mode,
1131- status: execution.status
1132- },
1133- request_mode: execution.request_mode,
1134- response: execution.response
1135- });
1136+ if (responseMode === "sse") {
1137+ return buildBrowserSseSuccessResponse(execution, context.request.signal);
1138+ }
1139+
1140+ return buildSuccessEnvelope(context.requestId, 200, {
1141+ client_id: execution.client_id,
1142+ conversation: serializeClaudeConversationSummary(execution.conversation),
1143+ organization: serializeClaudeOrganizationSummary(execution.organization),
1144+ platform: execution.platform,
1145+ policy: serializeBrowserRequestPolicyAdmission(execution.policy),
1146+ proxy: {
1147+ method: execution.request_method,
1148+ path: execution.request_path,
1149+ request_body: execution.request_body,
1150+ request_id: execution.request_id,
1151+ response_mode: execution.response_mode,
1152+ status: execution.status
1153+ },
1154+ request_mode: execution.request_mode,
1155+ response: execution.response
1156+ });
1157+ } catch (error) {
1158+ if (responseMode !== "sse" || !(error instanceof LocalApiHttpError)) {
1159+ throw error;
1160+ }
1161+
1162+ return buildBrowserSseErrorResponse({
1163+ error: error.error,
1164+ message: error.message,
1165+ platform,
1166+ requestId:
1167+ readUnknownString(asUnknownRecord(error.details), ["bridge_request_id"])
1168+ ?? requestId
1169+ ?? context.requestId,
1170+ status: error.status
1171+ });
1172+ }
1173 }
1174
1175 async function handleBrowserRequestCancel(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1176 const body = readBodyObject(context.request, true);
1177 const requestId = readOptionalStringBodyField(body, "requestId", "request_id", "id");
1178 const platform = readOptionalStringBodyField(body, "platform");
1179+ const clientId = readOptionalStringBodyField(body, "clientId", "client_id");
1180+ const reason = readOptionalStringBodyField(body, "reason");
1181
1182 if (requestId == null) {
1183 throw new LocalApiHttpError(
1184@@ -3990,18 +4646,63 @@ async function handleBrowserRequestCancel(context: LocalApiRequestContext): Prom
1185 );
1186 }
1187
1188- throw new LocalApiHttpError(
1189- 501,
1190- "browser_request_cancel_not_supported",
1191- "Browser request cancel is reserved in the HTTP contract but not implemented yet.",
1192- compactJsonObject({
1193- client_id: readOptionalStringBodyField(body, "clientId", "client_id"),
1194+ let dispatch;
1195+
1196+ try {
1197+ dispatch = requireBrowserBridge(context).cancelApiRequest({
1198+ clientId,
1199 platform,
1200- reason: readOptionalStringBodyField(body, "reason") ?? undefined,
1201- request_id: requestId,
1202- route: "/v1/browser/request/cancel"
1203- })
1204- );
1205+ reason,
1206+ requestId
1207+ });
1208+ } catch (error) {
1209+ if (error instanceof LocalApiHttpError) {
1210+ throw error;
1211+ }
1212+
1213+ if (readBridgeErrorCode(error) === "request_not_found") {
1214+ throw new LocalApiHttpError(
1215+ 404,
1216+ "browser_request_not_found",
1217+ `Browser request "${requestId}" is not in flight.`,
1218+ compactJsonObject({
1219+ client_id: clientId ?? undefined,
1220+ platform,
1221+ reason: reason ?? undefined,
1222+ request_id: requestId,
1223+ route: "/v1/browser/request/cancel"
1224+ })
1225+ );
1226+ }
1227+
1228+ if (readBridgeErrorCode(error) === "client_not_found" && clientId != null) {
1229+ throw new LocalApiHttpError(
1230+ 409,
1231+ "browser_request_client_mismatch",
1232+ `Browser request "${requestId}" is not running on the requested Firefox bridge client.`,
1233+ compactJsonObject({
1234+ client_id: clientId,
1235+ platform,
1236+ reason: reason ?? undefined,
1237+ request_id: requestId,
1238+ route: "/v1/browser/request/cancel"
1239+ })
1240+ );
1241+ }
1242+
1243+ throw createBrowserBridgeHttpError(`browser request cancel ${requestId}`, error);
1244+ }
1245+
1246+ return buildSuccessEnvelope(context.requestId, 200, {
1247+ client_id: dispatch.clientId,
1248+ connection_id: dispatch.connectionId,
1249+ dispatched_at: dispatch.dispatchedAt,
1250+ platform,
1251+ reason: reason ?? null,
1252+ request_id: dispatch.requestId,
1253+ status: "cancel_requested",
1254+ type: dispatch.type
1255+ });
1256 }
1257
1258 async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1259@@ -4024,36 +4725,60 @@ async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise
1260
1261 async function handleBrowserClaudeSend(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1262 const body = readBodyObject(context.request, true);
1263- const execution = await executeBrowserRequest(context, {
1264- clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1265- conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
1266- headers:
1267- readOptionalStringMap(body, "headers")
1268- ?? readOptionalStringMap(body, "request_headers")
1269- ?? undefined,
1270- method: readOptionalStringBodyField(body, "method") ?? "POST",
1271- organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
1272- path: readOptionalStringBodyField(body, "path"),
1273- platform: BROWSER_CLAUDE_PLATFORM,
1274- prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
1275- requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
1276- requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
1277- timeoutMs: readOptionalTimeoutMs(body, context.url)
1278- });
1279+ const responseMode = readBrowserRequestResponseMode(body);
1280+ try {
1281+ const execution = await executeBrowserRequest(context, {
1282+ clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1283+ conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
1284+ headers:
1285+ readOptionalStringMap(body, "headers")
1286+ ?? readOptionalStringMap(body, "request_headers")
1287+ ?? undefined,
1288+ method: readOptionalStringBodyField(body, "method") ?? "POST",
1289+ organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
1290+ path: readOptionalStringBodyField(body, "path"),
1291+ platform: BROWSER_CLAUDE_PLATFORM,
1292+ prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
1293+ requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
1294+ requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
1295+ responseMode,
1296+ timeoutMs: readOptionalTimeoutMs(body, context.url)
1297+ });
1298
1299- return buildSuccessEnvelope(context.requestId, 200, {
1300- client_id: execution.client_id,
1301- conversation: serializeClaudeConversationSummary(execution.conversation),
1302- organization: serializeClaudeOrganizationSummary(execution.organization),
1303- platform: BROWSER_CLAUDE_PLATFORM,
1304- proxy: {
1305- path: execution.request_path,
1306- request_body: execution.request_body,
1307- request_id: execution.request_id,
1308- status: execution.status
1309- },
1310- response: execution.response
1311- });
1312+ if (responseMode === "sse") {
1313+ return buildBrowserSseSuccessResponse(execution, context.request.signal);
1314+ }
1315+
1316+ return buildSuccessEnvelope(context.requestId, 200, {
1317+ client_id: execution.client_id,
1318+ conversation: serializeClaudeConversationSummary(execution.conversation),
1319+ organization: serializeClaudeOrganizationSummary(execution.organization),
1320+ platform: BROWSER_CLAUDE_PLATFORM,
1321+ policy: serializeBrowserRequestPolicyAdmission(execution.policy),
1322+ proxy: {
1323+ path: execution.request_path,
1324+ request_body: execution.request_body,
1325+ request_id: execution.request_id,
1326+ status: execution.status
1327+ },
1328+ response: execution.response
1329+ });
1330+ } catch (error) {
1331+ if (responseMode !== "sse" || !(error instanceof LocalApiHttpError)) {
1332+ throw error;
1333+ }
1334+
1335+ return buildBrowserSseErrorResponse({
1336+ error: error.error,
1337+ message: error.message,
1338+ platform: BROWSER_CLAUDE_PLATFORM,
1339+ requestId:
1340+ readUnknownString(asUnknownRecord(error.details), ["bridge_request_id"])
1341+ ?? readOptionalStringBodyField(body, "requestId", "request_id", "id")
1342+ ?? context.requestId,
1343+ status: error.status
1344+ });
1345+ }
1346 }
1347
1348 async function handleBrowserClaudeCurrent(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1349@@ -4683,6 +5408,7 @@ export async function handleConductorHttpRequest(
1350 matchedRoute,
1351 {
1352 browserBridge: context.browserBridge ?? null,
1353+ browserRequestPolicy: context.browserRequestPolicy ?? null,
1354 browserStateLoader: context.browserStateLoader ?? (() => null),
1355 codexdLocalApiBase:
1356 normalizeOptionalString(context.codexdLocalApiBase) ?? context.snapshotLoader().codexd.localApiBase,
+19,
-9
1@@ -129,15 +129,15 @@
2 - `GET /v1/browser`:浏览器登录态元数据、持久化状态和插件在线状态读面
3 - `POST /v1/browser/request`:通用 browser 代发入口
4 - `POST /v1/browser/actions`:通用 browser/plugin 管理动作入口
5-- `POST /v1/browser/request/cancel`:请求或流取消入口;合同已固定,但当前返回 `501`
6+- `POST /v1/browser/request/cancel`:请求或流取消入口
7 - `POST` / `GET /v1/browser/claude/*`:Claude 专用 legacy 兼容包装层
8
9 | 方法 | 路径 | 说明 |
10 | --- | --- | --- |
11 | `GET` | `/v1/browser` | 读取活跃 Firefox bridge、插件在线状态、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
12-| `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload` |
13-| `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 buffered 请求 |
14-| `POST` | `/v1/browser/request/cancel` | 取消请求或流;合同已固定,但当前返回 `501`,等待 bridge 侧补齐 cancel plumbing |
15+| `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` |
16+| `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 buffered 与 SSE 请求 |
17+| `POST` | `/v1/browser/request/cancel` | 取消请求或流;会向对应 Firefox client 派发 `request_cancel` |
18 | `POST` | `/v1/browser/claude/open` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
19 | `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
20 | `GET` | `/v1/browser/claude/current` | legacy 辅助读:读取当前 Claude 代理回读结果;不是未来通用主模型 |
21@@ -155,9 +155,10 @@ Browser 面约定:
22 - 连接断开或流量老化后,持久化记录仍可读,但状态会从 `fresh` 变成 `stale` / `lost`
23 - 当前浏览器本地代发面只支持 `claude`;ChatGPT / Gemini 目前只有壳页和元数据上报,不在正式 HTTP relay 合同里
24 - `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式
25-- `POST /v1/browser/request` 当前只正式支持 `responseMode=buffered`;`responseMode=sse` 已纳入合同,但当前返回 `501`
26-- `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`
27-- `plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` 已在合同中保留,但当前通过 `POST /v1/browser/actions` 调用会返回 `501`
28+- `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
29+- SSE 响应固定用 `stream_open`、`stream_event`、`stream_end`、`stream_error` 作为 event name;`stream_event` 带递增 `seq`
30+- `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`
31+- `GET /v1/browser` 会回显当前风控默认值和运行时 target/platform 状态,便于观察抖动、限流、退避和熔断
32 - `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
33 - `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
34 - 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
35@@ -204,7 +205,16 @@ curl -X POST "${LOCAL_API_BASE}/v1/browser/request" \
36 -d '{"platform":"claude","prompt":"Summarize the current bridge state."}'
37 ```
38
39-取消合同占位:
40+通过 SSE 模式直连浏览器流:
41+
42+```bash
43+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
44+curl -N -X POST "${LOCAL_API_BASE}/v1/browser/request" \
45+ -H 'Content-Type: application/json' \
46+ -d '{"platform":"claude","prompt":"Stream the current bridge state.","responseMode":"sse","requestId":"browser-stream-demo"}'
47+```
48+
49+取消运行中的 browser request / stream:
50
51 ```bash
52 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
53@@ -313,7 +323,7 @@ host-ops 约定:
54 - 浏览器发来的 `credentials` / `api_endpoints` 会被转换成 `account`、凭证指纹、端点元数据和 `fresh/stale/lost` 持久化记录
55 - `headers` 只保留名称与数量;原始 `cookie` / `token` / header 值既不会入库,也不会在 snapshot 或 `/v1/browser` 中回显
56 - `GET /v1/browser` 会合并当前活跃连接和持久化记录;即使 client 断开或 daemon 重启,最近一次记录仍可读取
57-- `/v1/browser/request` 和 legacy 的 `/v1/browser/claude/current` 会复用这条 WS bridge 的 `api_request` / `api_response` 做 Claude 页面内 HTTP 代理
58+- `/v1/browser/request`、`/v1/browser/request/cancel` 和 legacy 的 `/v1/browser/claude/current` 会复用这条 WS bridge 的 `api_request` / `api_response` / `stream_*` / `request_cancel` 做 Claude 页面内 HTTP 代理
59
60 详细消息模型和 smoke 示例见:
61
+7,
-4
1@@ -64,8 +64,8 @@
2
3 | 方法 | 路径 | 作用 |
4 | --- | --- | --- |
5-| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude buffered 请求 |
6-| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;合同已保留,但当前返回 `501` |
7+| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude buffered 与 SSE 请求 |
8+| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;会向对应 Firefox client 下发正式 `request_cancel` |
9 | `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
10
11 说明:
12@@ -75,7 +75,10 @@
13 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
14 - 当前浏览器代发面只支持 `claude`
15 - `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式
16-- `POST /v1/browser/request` 当前只正式支持 `responseMode=buffered`;`responseMode=sse` 已入合同,但当前返回 `501`
17+- `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
18+- `responseMode=sse` 会返回 `text/event-stream`,事件名固定为 `stream_open`、`stream_event`、`stream_end`、`stream_error`
19+- `stream_event` 都带递增 `seq`;失败、超时或取消时会带着已收到的 partial 状态落到 `stream_error`
20+- `GET /v1/browser` 会返回当前浏览器风控默认值和运行中 target/platform 状态摘要,便于观察限流、退避和熔断
21 - `send` / `current` 不是 DOM 自动化,而是通过插件已有的页面内 HTTP 代理完成
22 - 如果没有活跃 Firefox bridge client,会返回 `503`
23 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
24@@ -208,7 +211,7 @@ curl "${BASE_URL}/v1/tasks/${TASK_ID}/logs?limit=50"
25 - 业务类接口当前以“只读查询”为主
26 - 浏览器业务面当前以“登录态元数据读面 + 通用 browser request 合同”收口,不把页面对话 UI 当成正式能力
27 - 浏览器 relay 当前只正式支持 Claude,且依赖本地 Firefox bridge 已连接
28-- `/v1/browser/request/cancel` 和 `responseMode=sse` 已保留进合同,但当前不在本轮实现范围内
29+- `/v1/browser/request/cancel` 已正式接到 Firefox bridge;`responseMode=sse` 会返回真实 `text/event-stream`
30 - `/v1/codex/*` 是少数已经正式开放的业务写接口,但后端固定代理到独立 `codexd`
31 - 控制动作例如 `pause` / `resume` / `drain` 不在本文件讨论范围内
32 - 本机能力接口 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 也不在本文件讨论范围内
+7,
-4
1@@ -76,7 +76,7 @@
2
3 | 方法 | 路径 | 作用 |
4 | --- | --- | --- |
5-| `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload` |
6+| `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` |
7 | `POST` | `/v1/browser/claude/open` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
8 | `POST` | `/v1/browser/claude/reload` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
9
10@@ -88,7 +88,10 @@ browser/plugin 管理约定:
11 - `tab_open`
12 - `tab_focus`
13 - `tab_reload`
14-- `plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` 已保留进合同,但当前会返回 `501`
15+ - `plugin_status`
16+ - `ws_reconnect`
17+ - `controller_reload`
18+ - `tab_restore`
19 - 当前正式平台仍是 `claude`
20 - 如果没有活跃 Firefox bridge client,会返回 `503`
21 - 如果指定了不存在的 `clientId`,会返回 `409`
22@@ -224,8 +227,8 @@ curl -X POST "${BASE_URL}/v1/files/write" \
23
24 - 当前控制面是单节点 `mini`
25 - 控制动作默认作用于当前唯一活动节点
26-- browser/plugin 管理动作已经纳入 control,但当前正式只实现 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`
27-- `plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` 已保留在合同里,但当前不在本轮实现范围内
28+- browser/plugin 管理动作已经纳入 control;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`
29+- 当前 `/v1/browser/actions` 返回稳定的 dispatch ack;浏览器端最终 runtime 结果仍以 `GET /v1/browser` 和插件侧状态为准
30 - Codex 会话能力不在本文件主讨论范围;它通过 `/v1/codex/*` 代理到独立 `codexd`
31 - 业务查询不在本文件讨论范围内
32 - 业务类接口见 [`business-interfaces.md`](./business-interfaces.md)
+126,
-8
1@@ -43,6 +43,10 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
2 | `credentials` | 上送账号、凭证指纹、新鲜度和脱敏 header 名称摘要;server 只持久化最小元数据 |
3 | `api_endpoints` | 上送当前可代发的 endpoint 列表及其 `endpoint_metadata` |
4 | `api_response` | 对服务端下发的 `api_request` 回包,按 `id` 做 request-response 关联 |
5+| `stream_open` | 对服务端下发的 SSE `api_request` 回传流已打开,附带 `stream_id` 和上游状态码 |
6+| `stream_event` | 回传单个流事件;每条都带递增 `seq` |
7+| `stream_end` | 回传流已正常结束 |
8+| `stream_error` | 回传流失败、超时、取消或本地执行错误 |
9 | `client_log` | 可选日志消息;当前 server 只接收,不做业务处理 |
10
11 ### server -> client
12@@ -53,7 +57,9 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
13 | `state_snapshot` | 当前 server/system/browser 摘要 |
14 | `action_result` | `action_request` 的执行结果;成功时直接回传最新 `system` |
15 | `open_tab` | 指示浏览器打开或激活目标平台标签页 |
16-| `api_request` | 由 server 发起、浏览器代发的 API 请求;浏览器完成后回 `api_response` |
17+| `plugin_status` / `ws_reconnect` / `controller_reload` / `tab_restore` | 正式插件管理动作;server 直接用动作名下发消息 |
18+| `api_request` | 由 server 发起、浏览器代发的 API 请求;buffered 模式回 `api_response`,SSE 模式回 `stream_*` |
19+| `request_cancel` | 请求浏览器取消当前 `api_request` 或流 |
20 | `request_credentials` | 提示浏览器重新发送 `credentials` |
21 | `reload` | 指示插件管理页重载当前 controller 页面 |
22 | `error` | 非法 JSON、未知消息类型或未实现消息 |
23@@ -83,8 +89,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
24 "wsUrl": "ws://100.71.210.78:4317/ws/firefox",
25 "localApiBase": "http://100.71.210.78:4317",
26 "supports": {
27- "inbound": ["hello", "state_request", "action_request", "credentials", "api_endpoints", "client_log", "api_response"],
28- "outbound": ["hello_ack", "state_snapshot", "action_result", "open_tab", "api_request", "request_credentials", "reload", "error"]
29+ "inbound": ["hello", "state_request", "action_request", "credentials", "api_endpoints", "client_log", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
30+ "outbound": ["hello_ack", "state_snapshot", "action_result", "open_tab", "plugin_status", "ws_reconnect", "controller_reload", "tab_restore", "api_request", "request_cancel", "request_credentials", "reload", "error"]
31 }
32 }
33 ```
34@@ -226,6 +232,117 @@ server 行为:
35 - `GET /v1/browser` 会把这些持久化记录和当前活跃 WS 连接视图合并
36 - client 断开或 daemon 重启后,最近一次元数据仍可通过 `/v1/browser` 读取
37
38+### `api_request`
39+
40+buffered 请求示例:
41+
42+```json
43+{
44+ "type": "api_request",
45+ "id": "browser-request-1",
46+ "platform": "claude",
47+ "method": "GET",
48+ "path": "/api/organizations",
49+ "response_mode": "buffered"
50+}
51+```
52+
53+SSE 请求示例:
54+
55+```json
56+{
57+ "type": "api_request",
58+ "id": "browser-stream-1",
59+ "platform": "claude",
60+ "method": "POST",
61+ "path": "/api/organizations/org-1/chat_conversations/conv-1/completion",
62+ "body": {
63+ "prompt": "Summarize the current bridge state."
64+ },
65+ "response_mode": "sse",
66+ "stream_id": "browser-stream-1"
67+}
68+```
69+
70+说明:
71+
72+- `response_mode=buffered` 时浏览器仍走 `api_response`
73+- `response_mode=sse` 时浏览器必须改走 `stream_open` / `stream_event` / `stream_end` / `stream_error`
74+- `stream_id` 首版默认与 `id` 保持一致
75+
76+### `request_cancel`
77+
78+```json
79+{
80+ "type": "request_cancel",
81+ "id": "browser-stream-1",
82+ "stream_id": "browser-stream-1",
83+ "reason": "browser_request_cancelled"
84+}
85+```
86+
87+说明:
88+
89+- 浏览器收到后应尽快中止本地 fetch / SSE reader
90+- 如果流已经开始,推荐回 `stream_error`
91+- 如果是 buffered 请求,本地请求 promise 应尽快失败并释放占位
92+
93+### `stream_open`
94+
95+```json
96+{
97+ "type": "stream_open",
98+ "id": "browser-stream-1",
99+ "stream_id": "browser-stream-1",
100+ "status": 200,
101+ "meta": {
102+ "method": "POST",
103+ "platform": "claude",
104+ "url": "https://claude.ai/api/organizations/org-1/chat_conversations/conv-1/completion"
105+ }
106+}
107+```
108+
109+### `stream_event`
110+
111+```json
112+{
113+ "type": "stream_event",
114+ "id": "browser-stream-1",
115+ "stream_id": "browser-stream-1",
116+ "seq": 1,
117+ "event": "message",
118+ "data": {
119+ "type": "content_block_delta"
120+ },
121+ "raw": "event: message\ndata: {\"type\":\"content_block_delta\"}"
122+}
123+```
124+
125+### `stream_end`
126+
127+```json
128+{
129+ "type": "stream_end",
130+ "id": "browser-stream-1",
131+ "stream_id": "browser-stream-1",
132+ "status": 200
133+}
134+```
135+
136+### `stream_error`
137+
138+```json
139+{
140+ "type": "stream_error",
141+ "id": "browser-stream-1",
142+ "stream_id": "browser-stream-1",
143+ "status": 499,
144+ "code": "request_cancelled",
145+ "message": "browser_request_cancelled"
146+}
147+```
148+
149 ### `open_tab`
150
151 ```json
152@@ -310,7 +427,7 @@ server 行为:
153
154 - 不把 `/ws/firefox` 直接暴露成公网产品接口
155 - 不把页面对话 UI、聊天 DOM 自动化或多标签会话编排写成正式 bridge 能力
156-- Claude 正式 HTTP 面统一收口到 `GET /v1/browser`、`POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current`
157+- 正式 HTTP 面已经收口到 `GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`;`/v1/browser/claude/*` 只保留 legacy 包装与 Claude 辅助读
158 - 本文仍只讨论 WS transport、client registry 和 request-response 基础能力
159
160 ## 最小 smoke
161@@ -358,7 +475,8 @@ EOF
162 - `GET /v1/browser` 持久化记录在断连和重启后的可读性
163 - `GET /v1/browser` 上 `fresh` / `stale` / `lost` 的状态变化
164 - `GET /v1/browser` 不回显原始 `cookie` / `token` / header 值
165-- `POST /v1/browser/claude/open`
166-- `POST /v1/browser/claude/send`
167-- `GET /v1/browser/claude/current`
168-- 以及 `/ws/firefox` 上的 `open_tab` 和 `api_request` / `api_response`
169+- `POST /v1/browser/actions`
170+- `POST /v1/browser/request`
171+- `POST /v1/browser/request/cancel`
172+- 正式 SSE 与 Claude legacy wrapper
173+- 以及 `/ws/firefox` 上的 `open_tab`、`request_cancel`、`api_request` / `api_response` / `stream_*`
+36,
-22
1@@ -23,7 +23,7 @@
2
3 当前插件默认同时使用两条链路:
4
5-- 本地 WS:负责 Firefox bridge 握手、浏览器元数据同步、服务端 `api_request` / `api_response`
6+- 本地 WS:负责 Firefox bridge 握手、浏览器元数据同步、服务端 `api_request` / `request_cancel` 与 `stream_*`
7 - 本地 HTTP:负责控制面状态同步,以及 `pause` / `resume` / `drain`
8
9 插件管理页不再允许手工编辑地址。
10@@ -77,8 +77,8 @@
11 1. 先读 [`../api/business-interfaces.md`](../api/business-interfaces.md)
12 2. 再调 `GET /describe/business`
13 3. 再调 `GET /v1/browser`,确认需要的平台记录、`status` 和 `view`
14-4. 如果目标是 Claude 浏览器代发,再调 `POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`
15-5. 只有在需要 Claude 辅助回读时,才调 `GET /v1/browser/claude/current`
16+4. 如果目标是浏览器代发,先走 `POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`
17+5. 只有在需要 Claude legacy 包装或 Claude 辅助回读时,才调 `/v1/browser/claude/*`
18
19 不要把这条链路当成通用公网浏览器自动化服务;正式链路依赖 `mini` 本地 Firefox 插件和本地 `/ws/firefox`。
20
21@@ -144,19 +144,23 @@
22 - 原始凭证值仍只停留在浏览器本地,用于后续同源代发
23 - client 断开或流量长时间老化后,持久化记录仍可读,但会从 `fresh` 变成 `stale` / `lost`
24
25-## Claude 浏览器本地代发
26+## 浏览器本地代发
27
28-当前正式对外 HTTP relay 只支持 Claude:
29+当前正式对外 HTTP relay 已经收口到:
30
31 1. `GET /v1/browser`
32-2. `POST /v1/browser/claude/open`
33-3. `POST /v1/browser/claude/send`
34-4. `GET /v1/browser/claude/current`
35+2. `POST /v1/browser/actions`
36+3. `POST /v1/browser/request`
37+4. `POST /v1/browser/request/cancel`
38+5. `POST` / `GET /v1/browser/claude/*`(legacy 包装与 Claude 辅助读)
39
40 这条链路的关键边界:
41
42-- `send` / `current` 通过插件已有的页面内 HTTP 代理完成,不是 DOM 自动化
43+- 当前正式 relay 平台仍只支持 Claude,但合同本身已经是通用 browser request / cancel / SSE
44+- `request` / `current` 通过插件已有的页面内 HTTP 代理完成,不是 DOM 自动化
45 - `conductor` 不直接持有原始 Claude 凭证
46+- `responseMode=sse` 时,浏览器会通过 `stream_open` / `stream_event` / `stream_end` / `stream_error` 回传
47+- `POST /v1/browser/request/cancel` 会向插件下发正式 `request_cancel`
48 - 如果没有活跃 Firefox bridge client,会返回 `503`
49 - 如果 client 还没有 Claude 凭证和 endpoint,会返回 `409`
50 - ChatGPT / Gemini 当前不在正式 HTTP relay 合同里
51@@ -189,7 +193,7 @@
52 - 持久化记录在断连和重启后的可读性
53 - `fresh` / `stale` / `lost` 状态变化
54 - 读接口不泄露原始凭证值
55-- Claude `open` / `send` / `current` 的最小浏览器本地代发闭环
56+- 通用 browser actions / request / cancel、正式 SSE 和 Claude legacy wrapper 的最小浏览器本地代发闭环
57
58 随后插件会继续上送:
59
60@@ -197,10 +201,14 @@
61 - `api_endpoints`
62 - `client_log`
63 - `api_response`
64+- `stream_open`
65+- `stream_event`
66+- `stream_end`
67+- `stream_error`
68
69 其中:
70
71-- `credentials` / `api_endpoints` / `api_response` 属于当前正式 bridge 合同
72+- `credentials` / `api_endpoints` / `api_response` / `stream_*` 属于当前正式 bridge 合同
73 - `network_log` / `sse_event` 仍是插件侧诊断数据,不属于当前服务端正式承诺的 runtime message
74
75 同时也会消费服务端下发的:
76@@ -208,13 +216,18 @@
77 - `hello_ack`
78 - `state_snapshot`
79 - `open_tab`
80+- `plugin_status`
81+- `ws_reconnect`
82+- `controller_reload`
83+- `tab_restore`
84 - `api_request`
85+- `request_cancel`
86 - `request_credentials`
87 - `reload`
88 - `action_result`
89 - `error`
90
91-其中 `api_request` 仍然走统一 proxy 通道;Claude 的 runtime message 只是先在插件本地把这条能力补齐,后续由 `conductor` 的 WS bridge 调用。
92+其中 `api_request` 仍然走统一 proxy 通道;Claude legacy message 只是兼容包装,正式主模型已经回到通用 browser request / cancel / SSE。
93
94 ## 验收建议
95
96@@ -232,7 +245,7 @@
97 - 持久化记录在断连和重启后的可读性
98 - `fresh` / `stale` / `lost` 状态变化
99 - 读接口不泄露原始凭证值
100-- Claude `open` / `send` / `current` 的最小浏览器本地代发闭环
101+- 通用 browser actions / request / cancel、正式 SSE 和 Claude legacy wrapper 的最小浏览器本地代发闭环
102
103 ### 1. 元数据与持久化
104
105@@ -259,14 +272,15 @@
106 4. 继续等待老化窗口
107 5. 确认同一条记录进一步进入 `lost`
108
109-### 3. Claude relay
110+### 3. Browser relay
111
112-1. 调 `POST /v1/browser/claude/open`
113-2. 确认 Claude shell tab 被打开或聚焦
114-3. 调 `POST /v1/browser/claude/send`
115-4. 确认请求经 `/ws/firefox` 转给浏览器本地代理,并收到 Claude API 回包
116-5. 如需辅助回读,再调 `GET /v1/browser/claude/current`
117-6. 把这个接口理解为 Claude relay 的辅助读面,不是浏览器桥接持久化主模型
118+1. 调 `POST /v1/browser/actions` 打开或聚焦 Claude shell tab
119+2. 调 `POST /v1/browser/request`
120+3. 确认请求经 `/ws/firefox` 转给浏览器本地代理,并收到 Claude API 回包
121+4. 再调 `POST /v1/browser/request` + `responseMode=sse`
122+5. 确认浏览器回传 `stream_open` / `stream_event` / `stream_end`
123+6. 如需终止 in-flight request 或流,调 `POST /v1/browser/request/cancel`
124+7. 如需 Claude 辅助回读或旧调用方兼容,再调 `/v1/browser/claude/*`
125
126 ### 4. 控制面
127
128@@ -276,10 +290,10 @@
129
130 ## 已知限制
131
132-- 当前正式浏览器本地代发 HTTP 面只有 Claude
133+- 当前正式浏览器本地代发 HTTP relay 平台只有 Claude
134 - ChatGPT / Gemini 当前只保留空壳页和元数据上报,不在正式 `/v1/browser/*` relay 合同里
135 - 必须先在真实 Claude 页面里产生过请求,插件才能学到可用凭证和 `org-id`
136-- `claude_send` / `claude_current` 走浏览器本地 HTTP 代理,不会驱动 Claude 页面 DOM,也不会把页面会话历史持久化到 `conductor`
137+- `browser/request`、`claude_send` / `claude_current` 走浏览器本地 HTTP 代理,不会驱动 Claude 页面 DOM,也不会把页面会话历史持久化到 `conductor`
138 - 当前 `/v1/browser/claude/current` 只是辅助回读最近一段 Claude 状态,不提供长期历史合同
139
140 ## 相关文件
+1,
-1
1@@ -89,7 +89,7 @@
2 - 会起临时 `codexd` + `conductor`,覆盖 `codexd status`、`GET /v1/codex`、session create/read、turn create/read,以及 `logs/codexd/**`、`state/codexd/**` 落盘
3 - 浏览器控制链路 smoke:
4 - `./scripts/runtime/browser-control-e2e-smoke.sh`
5- - 会起临时 `conductor` + fake Firefox bridge client,覆盖 `GET /v1/browser` 的元数据上报、持久化读取、`fresh/stale/lost`、敏感值不泄露,以及 `POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current` 和 `/ws/firefox` 上的 `open_tab` 与 `api_request/api_response`
6+ - 会起临时 `conductor` + fake Firefox bridge client,覆盖 `GET /v1/browser` 的元数据上报、持久化读取、`fresh/stale/lost`、敏感值不泄露,以及 `POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、legacy Claude wrapper 和 `/ws/firefox` 上的 `open_tab` / `request_cancel` / `api_request` / `stream_*`
7
8 职责边界:
9
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- `TODO`
6+- `已落地(T-S017` 到 `T-S024` 已完成主线收口)`
7 - 优先级:`high`
8 - 记录时间:`2026-03-26`
9
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- `TODO`
6+- `已落地(T-S021` 到 `T-S024` 已收口主线实现)`
7 - 优先级:`high`
8 - 记录时间:`2026-03-26`
9
+15,
-10
1@@ -9,12 +9,12 @@
2 - 主线基线:`main@5d4febb`
3 - 任务文档已统一收口到 `tasks/`
4 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
5-- `T-S001` 到 `T-S022` 已经完成
6+- `T-S001` 到 `T-S024` 已经完成
7
8 ## 当前状态分类
9
10-- `已完成`:`T-S001` 到 `T-S022`
11-- `当前 TODO`:`T-S023`、`T-S024`
12+- `已完成`:`T-S001` 到 `T-S024`
13+- `当前 TODO`:无高优先级主线任务
14 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
15
16 当前新的主需求文档:
17@@ -35,6 +35,7 @@
18 - `status-api` 当前默认读 `BAA_CONDUCTOR_LOCAL_API` / `4317`,`BAA_CONTROL_API_BASE` 只保留为兼容覆盖入口
19 - `status-api` 当前结论是继续保留为显式 opt-in 的本地兼容包装层,不立即删除
20 - 浏览器桥接当前正式模型已经收口为“登录态元数据持久化 + 空壳标签页 + 浏览器本地代发请求”
21+- 通用 browser request / cancel / SSE 与插件管理动作已经进入正式主线合同
22
23 ## 当前在线面
24
25@@ -55,16 +56,16 @@
26 - 单节点的 Nginx / DNS 计划脚本
27 - 迁移期兼容件:`apps/status-api`、`ops/cloudflare/**`、`tests/control-api/**`、`BAA_CONTROL_API_BASE`
28
29-## 当前主 TODO
30+## 当前主线收口情况
31
32-当前主线已切到浏览器桥接第二阶段开发:
33+浏览器桥接第二阶段已经完成主线收口:
34
35-1. `T-S023`:打通通用 browser request/SSE 链路与 `conductor` 风控策略
36-2. `T-S024`:回写文档、补 smoke 并同步主线状态
37+1. `T-S023`:已完成,通用 browser request / cancel / SSE 链路和首版风控策略已接入主线
38+2. `T-S024`:已完成,README / docs / smoke / 状态视图已同步到正式口径
39
40-当前开发策略:
41+当前策略:
42
43-- 先完成 `T-S023`、`T-S024`
44+- 当前没有高优先级主线 blocker
45 - 当前不把大文件拆分当作主线 blocker
46 - 以下重构工作顺延到下一轮专门重构任务:
47 - `apps/conductor-daemon/src/local-api.ts`
48@@ -105,6 +106,8 @@
49 - `T-S020`:浏览器桥接文档、browser smoke 和状态视图已经回写到“登录态元数据持久化 + 空壳标签页 + 浏览器本地代发”正式模型
50 - `T-S021`:`conductor` 的浏览器能力发现已继续收口到 `business` / `control` 两层 describe,并定义了通用 browser HTTP 合同与 legacy Claude 包装位次
51 - `T-S022`:Firefox 插件侧已完成空壳页 runtime、`desired/actual` 状态模型和插件管理类 payload 准备
52+- `T-S023`:通用 browser request / cancel / SSE 链路、`stream_*` 事件模型和首版浏览器风控策略已经接入 `conductor` 与 Firefox bridge
53+- `T-S024`:README、API / Firefox / runtime 文档、browser smoke 和任务状态视图已同步到正式主线口径
54 - 根级 `pnpm smoke` 已进主线,覆盖 runtime public-api compatibility、legacy absence、codexd e2e 和 browser-control e2e smoke
55
56 ## 4318 依赖盘点与结论
57@@ -134,6 +137,8 @@
58 - runtime smoke 当前仍假定仓库根已经存在 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮浏览器桥接功能改动本身
59 - `pnpm verify:mini` 只收口 on-node 静态检查和运行态探针,不替代会话级 smoke
60 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
61+- 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
62+- 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,没有接入通用 request / SSE 合同
63 - 这轮还没跑真实 Firefox 手工 smoke,因此“手动关 tab -> tab_restore -> WS 重连后状态回报”的浏览器端闭环仍未实测
64-- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有正式的插件管理动作回执合同;插件侧 runtime 和 payload 已准备好,后续由 `T-S023` / `T-S024` 接入
65+- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有 richer 的插件管理动作结果合同;这两项已降为后续增量,不再算当前主线 blocker
66 - 当前多个源码文件已超过常规体量,但拆分重构已明确延后到浏览器桥接主线收口之后再做
+3,
-2
1@@ -159,10 +159,11 @@ browser.runtime.sendMessage({
2 - Metadata: account / fingerprint / endpoint panels populate without exposing raw header values
3 - `GET /v1/browser`: merged record shows `view` / `status` and does not expose raw credential values
4 - Disconnect / aging: persisted record remains readable after disconnect and later changes `fresh -> stale -> lost`
5-- Proxying: server-initiated `api_request` still completes through the shell tab
6+- Proxying: server-initiated `api_request`、`request_cancel` 和 SSE `stream_*` 仍会通过 shell tab 完成
7
8 ## Conductor 对接现状
9
10 - `GET /v1/browser` 已经会合并活跃 bridge 和持久化记录,并返回 `view`、`status`、`account`、凭证指纹和端点元数据
11 - client 断连或长时间老化后,持久化记录仍可读取,但状态会转成 `stale` / `lost`
12-- 当前正式浏览器本地代发 HTTP 面只有 Claude:`/v1/browser/claude/*`
13+- 当前正式浏览器写接口已经收口到 `POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`
14+- `/v1/browser/claude/*` 只保留 legacy 包装与 Claude 辅助读
+13,
-5
1@@ -32,11 +32,19 @@ window.addEventListener("__baa_proxy_response__", (event) => {
2
3 browser.runtime.onMessage.addListener((message) => {
4 if (!message || typeof message !== "object") return undefined;
5- if (message.type !== "baa_page_proxy_request") return undefined;
6-
7- window.dispatchEvent(new CustomEvent("__baa_proxy_request__", {
8- detail: JSON.stringify(message.data || {})
9- }));
10+ if (message.type === "baa_page_proxy_request") {
11+ window.dispatchEvent(new CustomEvent("__baa_proxy_request__", {
12+ detail: JSON.stringify(message.data || {})
13+ }));
14+
15+ return undefined;
16+ }
17+
18+ if (message.type === "baa_page_proxy_cancel") {
19+ window.dispatchEvent(new CustomEvent("__baa_proxy_cancel__", {
20+ detail: JSON.stringify(message.data || {})
21+ }));
22+ }
23
24 return undefined;
25 });
+180,
-1
1@@ -811,6 +811,31 @@ function parseClaudeSseText(text) {
2 };
3 }
4
5+function parseStreamChunkPayload(chunk) {
6+ const source = String(chunk || "");
7+ const eventMatch = source.match(/(?:^|\n)event:\s*([^\n]+)/u);
8+ const dataLines = source
9+ .split("\n")
10+ .filter((line) => line.startsWith("data:"))
11+ .map((line) => line.slice(5).trimStart());
12+ const dataText = dataLines.join("\n");
13+ const payloadText = dataText || source;
14+
15+ try {
16+ return {
17+ data: JSON.parse(payloadText),
18+ event: eventMatch?.[1]?.trim() || null,
19+ raw: source
20+ };
21+ } catch (_) {
22+ return {
23+ data: payloadText,
24+ event: eventMatch?.[1]?.trim() || null,
25+ raw: source
26+ };
27+ }
28+}
29+
30 function createPlatformMap(factory) {
31 const out = {};
32 for (const platform of PLATFORM_ORDER) {
33@@ -3443,6 +3468,29 @@ function createPendingProxyRequest(id, meta = {}) {
34 return entry;
35 }
36
37+function cancelPendingProxyRequest(id, reason = "browser_request_cancelled") {
38+ const pending = pendingProxyRequests.get(id);
39+
40+ if (!pending) {
41+ return false;
42+ }
43+
44+ const tabId = Number.isInteger(pending.tabId) ? pending.tabId : null;
45+
46+ if (tabId != null) {
47+ browser.tabs.sendMessage(tabId, {
48+ type: "baa_page_proxy_cancel",
49+ data: {
50+ id,
51+ reason
52+ }
53+ }).catch(() => {});
54+ }
55+
56+ pending.reject(new Error(reason));
57+ return true;
58+}
59+
60 async function executeProxyRequest(payload, meta = {}) {
61 const platform = payload?.platform;
62 if (!platform || !PLATFORMS[platform]) {
63@@ -3455,7 +3503,8 @@ async function executeProxyRequest(payload, meta = {}) {
64 ...meta,
65 platform,
66 method: payload.method,
67- path: payload.path
68+ path: payload.path,
69+ tabId: tab.id
70 });
71
72 try {
73@@ -3708,6 +3757,28 @@ function connectWs(options = {}) {
74 break;
75 }
76 case "api_request":
77+ if (String(message.response_mode || message.responseMode || "buffered").toLowerCase() === "sse") {
78+ proxyApiRequest(message).catch((error) => {
79+ if (error?.streamReported === true) {
80+ addLog("error", `代理流 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
81+ return;
82+ }
83+
84+ const streamId = trimToNull(message.stream_id) || trimToNull(message.streamId) || message.id;
85+ const errorCode = error.message === "browser_request_cancelled" ? "request_cancelled" : "proxy_failed";
86+ wsSend({
87+ type: "stream_error",
88+ id: message.id,
89+ stream_id: streamId,
90+ status: null,
91+ code: errorCode,
92+ message: error.message
93+ });
94+ addLog("error", `代理流 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
95+ });
96+ break;
97+ }
98+
99 proxyApiRequest(message).then((result) => {
100 sendApiResponse(message.id, result.ok, result.status, result.body, result.error);
101 if (!result.ok) {
102@@ -3718,6 +3789,20 @@ function connectWs(options = {}) {
103 addLog("error", `代理 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
104 });
105 break;
106+ case "api_request_cancel":
107+ case "request_cancel": {
108+ const requestId = trimToNull(message.requestId) || trimToNull(message.id);
109+ const cancelled = requestId
110+ ? cancelPendingProxyRequest(requestId, trimToNull(message.reason) || "browser_request_cancelled")
111+ : false;
112+
113+ if (cancelled) {
114+ addLog("info", `已取消本地代理请求 ${requestId}`, false);
115+ } else if (requestId) {
116+ addLog("warn", `待取消的本地代理请求不存在:${requestId}`, false);
117+ }
118+ break;
119+ }
120 case "request_credentials":
121 sendCredentialSnapshot(message.platform || null, true);
122 break;
123@@ -4182,6 +4267,80 @@ function handlePageSse(data, sender) {
124 if (context.platform === "claude") {
125 applyObservedClaudeSse(data, context.tabId);
126 }
127+
128+ const pending = data.id ? pendingProxyRequests.get(data.id) : null;
129+ if (!pending || pending.responseMode !== "sse") {
130+ return;
131+ }
132+
133+ const streamId = trimToNull(data.stream_id) || pending.streamId || pending.id;
134+ const status = Number.isFinite(data.status) ? data.status : null;
135+
136+ if (!pending.streamOpened || data.open === true) {
137+ pending.streamOpened = true;
138+ pending.streamId = streamId;
139+ wsSend({
140+ type: "stream_open",
141+ id: pending.id,
142+ stream_id: streamId,
143+ status,
144+ meta: {
145+ method: data.method || pending.method || null,
146+ platform: context.platform,
147+ url: data.url || pending.path || null
148+ }
149+ });
150+ }
151+
152+ if (typeof data.chunk === "string" && data.chunk.trim()) {
153+ const parsedChunk = parseStreamChunkPayload(data.chunk);
154+ const seq = Number.isFinite(data.seq) && data.seq > 0
155+ ? data.seq
156+ : (Number(pending.streamSeq) || 0) + 1;
157+ pending.streamSeq = seq;
158+ wsSend({
159+ type: "stream_event",
160+ id: pending.id,
161+ stream_id: streamId,
162+ seq,
163+ event: parsedChunk.event,
164+ raw: parsedChunk.raw,
165+ data: parsedChunk.data
166+ });
167+ }
168+
169+ if (data.done === true) {
170+ wsSend({
171+ type: "stream_end",
172+ id: pending.id,
173+ stream_id: streamId,
174+ status
175+ });
176+ pending.resolve({
177+ body: null,
178+ error: null,
179+ id: pending.id,
180+ method: data.method || pending.method || null,
181+ ok: true,
182+ status,
183+ url: data.url || pending.path || null
184+ });
185+ return;
186+ }
187+
188+ if (data.error) {
189+ const streamError = new Error(data.error);
190+ streamError.streamReported = true;
191+ wsSend({
192+ type: "stream_error",
193+ id: pending.id,
194+ stream_id: streamId,
195+ status,
196+ code: data.error === "browser_request_cancelled" ? "request_cancelled" : "stream_error",
197+ message: data.error
198+ });
199+ pending.reject(streamError);
200+ }
201 }
202
203 function handlePageProxyResponse(data, sender) {
204@@ -4243,6 +4402,22 @@ function handlePageProxyResponse(data, sender) {
205 }
206 }
207
208+ if (pending.responseMode === "sse" && (data.error || (Number.isFinite(data.status) && data.status >= 400))) {
209+ const message = data.error || `upstream_status_${data.status}`;
210+ const streamError = new Error(message);
211+ streamError.streamReported = true;
212+ wsSend({
213+ type: "stream_error",
214+ id: pending.id,
215+ stream_id: pending.streamId || pending.id,
216+ status: Number.isFinite(data.status) ? data.status : null,
217+ code: message === "browser_request_cancelled" ? "request_cancelled" : "stream_error",
218+ message
219+ });
220+ pending.reject(streamError);
221+ return;
222+ }
223+
224 pending.resolve({
225 id: data.id,
226 ok: data.ok !== false && !data.error,
227@@ -4389,6 +4564,8 @@ async function proxyApiRequest(message) {
228 if (!platform || !PLATFORMS[platform]) throw new Error(`未知平台:${platform || "-"}`);
229 if (!apiPath) throw new Error("缺少代理请求路径");
230
231+ const responseMode = String(message?.response_mode || message?.responseMode || "buffered").toLowerCase();
232+ const streamId = trimToNull(message?.stream_id) || trimToNull(message?.streamId) || id;
233 const prompt = platform === "gemini" ? extractPromptFromProxyBody(body) : null;
234 const geminiAutoRequest = platform === "gemini" && prompt && isGeminiStreamGenerateUrl(apiPath)
235 ? buildGeminiAutoRequest(prompt)
236@@ -4404,6 +4581,8 @@ async function proxyApiRequest(message) {
237 }, {
238 platform,
239 prompt,
240+ responseMode,
241+ streamId,
242 attempts: 0
243 });
244 }
+206,
-3
1@@ -7,6 +7,7 @@
2 const originalXhrOpen = XMLHttpRequest.prototype.open;
3 const originalXhrSend = XMLHttpRequest.prototype.send;
4 const originalXhrSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
5+ const activeProxyControllers = new Map();
6
7 function hostnameMatches(hostname, hosts) {
8 return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
9@@ -284,6 +285,140 @@
10 }
11 }
12
13+ async function streamProxyResponse(detail, response, startedAt, rule, requestBody) {
14+ const contentType = response.headers.get("content-type") || "";
15+ const shouldSplitChunks = contentType.includes("text/event-stream");
16+ const streamId = detail.stream_id || detail.streamId || detail.id;
17+ let seq = 0;
18+
19+ emitSse({
20+ id: detail.id,
21+ stream_id: streamId,
22+ method: detail.method,
23+ open: true,
24+ reqBody: requestBody,
25+ status: response.status,
26+ ts: Date.now(),
27+ url: detail.url
28+ }, rule);
29+
30+ try {
31+ if (!response.body) {
32+ emitSse(
33+ response.status >= 400
34+ ? {
35+ error: `upstream_status_${response.status}`,
36+ id: detail.id,
37+ method: detail.method,
38+ reqBody: requestBody,
39+ status: response.status,
40+ stream_id: streamId,
41+ ts: Date.now(),
42+ url: detail.url
43+ }
44+ : {
45+ id: detail.id,
46+ stream_id: streamId,
47+ done: true,
48+ duration: Date.now() - startedAt,
49+ method: detail.method,
50+ reqBody: requestBody,
51+ status: response.status,
52+ ts: Date.now(),
53+ url: detail.url
54+ },
55+ rule
56+ );
57+ return;
58+ }
59+
60+ const reader = response.body.getReader();
61+ const decoder = new TextDecoder();
62+ let buffer = "";
63+
64+ while (true) {
65+ const { done, value } = await reader.read();
66+ if (done) break;
67+
68+ buffer += decoder.decode(value, { stream: true });
69+ const chunks = shouldSplitChunks ? buffer.split("\n\n") : [buffer];
70+ buffer = shouldSplitChunks ? (chunks.pop() || "") : "";
71+
72+ for (const chunk of chunks) {
73+ if (!chunk.trim()) continue;
74+ seq += 1;
75+ emitSse({
76+ chunk,
77+ id: detail.id,
78+ method: detail.method,
79+ reqBody: requestBody,
80+ seq,
81+ status: response.status,
82+ stream_id: streamId,
83+ ts: Date.now(),
84+ url: detail.url
85+ }, rule);
86+ }
87+ }
88+
89+ buffer += decoder.decode();
90+ if (buffer.trim()) {
91+ seq += 1;
92+ emitSse({
93+ chunk: buffer,
94+ id: detail.id,
95+ method: detail.method,
96+ reqBody: requestBody,
97+ seq,
98+ status: response.status,
99+ stream_id: streamId,
100+ ts: Date.now(),
101+ url: detail.url
102+ }, rule);
103+ }
104+
105+ emitSse(
106+ response.status >= 400
107+ ? {
108+ error: `upstream_status_${response.status}`,
109+ id: detail.id,
110+ method: detail.method,
111+ reqBody: requestBody,
112+ seq,
113+ status: response.status,
114+ stream_id: streamId,
115+ ts: Date.now(),
116+ url: detail.url
117+ }
118+ : {
119+ done: true,
120+ duration: Date.now() - startedAt,
121+ id: detail.id,
122+ method: detail.method,
123+ reqBody: requestBody,
124+ seq,
125+ status: response.status,
126+ stream_id: streamId,
127+ ts: Date.now(),
128+ url: detail.url
129+ },
130+ rule
131+ );
132+ } catch (error) {
133+ emitSse({
134+ error: error.message,
135+ id: detail.id,
136+ method: detail.method,
137+ reqBody: requestBody,
138+ seq,
139+ status: response.status,
140+ stream_id: streamId,
141+ ts: Date.now(),
142+ url: detail.url
143+ }, rule);
144+ }
145+ }
146+
147 window.addEventListener("__baa_proxy_request__", async (event) => {
148 let detail = event.detail || {};
149 if (typeof detail === "string") {
150@@ -297,9 +432,13 @@
151 const id = detail.id;
152 const method = String(detail.method || "GET").toUpperCase();
153 const rawPath = detail.path || detail.url || location.href;
154+ const responseMode = String(detail.response_mode || detail.responseMode || "buffered").toLowerCase();
155
156 if (!id) return;
157
158+ const proxyAbortController = new AbortController();
159+ activeProxyControllers.set(id, proxyAbortController);
160+
161 try {
162 const url = new URL(rawPath, location.origin).href;
163 const context = getRequestContext(url);
164@@ -326,15 +465,41 @@
165 method,
166 headers,
167 body,
168- credentials: "include"
169+ credentials: "include",
170+ signal: proxyAbortController.signal
171 });
172-
173- const responseBody = await response.text();
174 const resHeaders = readHeaders(response.headers);
175 const contentType = response.headers.get("content-type") || "";
176 const isSse = context ? context.rule.isSse(context.parsed.pathname, contentType) : false;
177 const reqHeaders = readHeaders(headers);
178 const reqBody = typeof body === "string" ? trim(body) : null;
179+
180+ if (responseMode === "sse") {
181+ emitNet({
182+ url,
183+ method,
184+ reqHeaders,
185+ reqBody,
186+ status: response.status,
187+ resHeaders,
188+ resBody: null,
189+ duration: Date.now() - startedAt,
190+ sse: true,
191+ source: "proxy"
192+ }, pageRule);
193+
194+ const replayDetail = {
195+ ...detail,
196+ id,
197+ method,
198+ stream_id: detail.stream_id || detail.streamId || id,
199+ url
200+ };
201+ await streamProxyResponse(replayDetail, response, startedAt, pageRule, reqBody);
202+ return;
203+ }
204+
205+ const responseBody = await response.text();
206 const trimmedResponseBody = trim(responseBody);
207
208 emitNet({
209@@ -386,6 +551,21 @@
210 error: error.message,
211 source: "proxy"
212 }, pageRule);
213+
214+ if (responseMode === "sse") {
215+ emitSse({
216+ error: error.message,
217+ id,
218+ method,
219+ reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
220+ status: null,
221+ stream_id: detail.stream_id || detail.streamId || id,
222+ ts: Date.now(),
223+ url: rawPath
224+ }, pageRule);
225+ return;
226+ }
227+
228 emit("__baa_proxy_response__", {
229 id,
230 platform: pageRule.platform,
231@@ -394,7 +574,30 @@
232 ok: false,
233 error: error.message
234 }, pageRule);
235+ } finally {
236+ activeProxyControllers.delete(id);
237+ }
238+ });
239+
240+ window.addEventListener("__baa_proxy_cancel__", (event) => {
241+ let detail = event.detail || {};
242+
243+ if (typeof detail === "string") {
244+ try {
245+ detail = JSON.parse(detail);
246+ } catch (_) {
247+ detail = {};
248+ }
249 }
250+
251+ const id = detail?.id || detail?.requestId;
252+ if (!id) return;
253+
254+ const controller = activeProxyControllers.get(id);
255+ if (!controller) return;
256+
257+ activeProxyControllers.delete(id);
258+ controller.abort(detail?.reason || "browser_request_cancelled");
259 });
260
261 window.fetch = async function patchedFetch(input, init) {
+6,
-1
1@@ -22,7 +22,7 @@
2
3 ## 当前状态
4
5-- `TODO`
6+- `已完成(2026-03-26)`
7
8 ## 建议分支名
9
10@@ -158,3 +158,8 @@
11 - 兼容了哪些 Claude 旧路径
12 - 跑了哪些验证
13 - 还有哪些剩余风险
14+
15+## 当前残余风险
16+
17+- 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置。
18+- 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,还没有接入通用 request / SSE 合同。
+14,
-1
1@@ -25,7 +25,7 @@
2
3 ## 当前状态
4
5-- `TODO`
6+- `已完成(2026-03-26)`
7
8 ## 建议分支名
9
10@@ -140,3 +140,16 @@
11 - 哪些旧文案被删除、降级或标为 legacy
12 - 跑了哪些验证
13 - 还有哪些残余风险
14+
15+## 当前残余风险
16+
17+- 为满足现有 runtime smoke 前置条件,本机仓库根已补空目录:
18+ - `state/`
19+ - `runs/`
20+ - `worktrees/`
21+ - `logs/launchd`
22+ - `logs/codexd`
23+ - `tmp/`
24+- 这些目录不是 git 跟踪改动,而是当前脚本和本机运行环境前提。
25+- 真实 Firefox 手工 smoke 仍未执行,因此“手动关 tab -> `tab_restore` -> WS 重连后状态回报”的浏览器端闭环还没实测。
26+- `shell_runtime` 和 richer 的插件动作结果合同仍未接入 `conductor`,但它们已降为后续增量,不再算当前主线 blocker。
+18,
-12
1@@ -13,8 +13,8 @@
2
3 ## 状态分类
4
5-- `已完成`:`T-S001` 到 `T-S022`
6-- `当前 TODO`:`T-S023` 到 `T-S024`
7+- `已完成`:`T-S001` 到 `T-S024`
8+- `当前 TODO`:无高优先级主线任务
9 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
10
11 当前新的主需求文档:
12@@ -46,6 +46,10 @@
13 18. [`T-S018.md`](./T-S018.md):把 Firefox 插件收口到空壳标签页并上报账号/指纹/端点
14 19. [`T-S019.md`](./T-S019.md):让 conductor 持久化浏览器登录态并提供查询面
15 20. [`T-S020.md`](./T-S020.md):回写浏览器桥接文档、补持久化 smoke 并同步状态视图
16+21. [`T-S021.md`](./T-S021.md):收口 `conductor` describe 与通用 browser HTTP 合同
17+22. [`T-S022.md`](./T-S022.md):实现 Firefox 空壳页 runtime 与插件管理动作
18+23. [`T-S023.md`](./T-S023.md):打通通用 browser request / cancel / SSE 链路与 `conductor` 风控策略
19+24. [`T-S024.md`](./T-S024.md):回写正式文档、补 browser smoke 并同步主线状态
20
21 当前主线已经额外收口:
22
23@@ -58,12 +62,12 @@
24
25 ## 当前活动任务
26
27-- 当前主线已切到浏览器桥接第二阶段开发
28-- 当前活动任务:[`T-S023.md`](./T-S023.md)、[`T-S024.md`](./T-S024.md)
29+- 浏览器桥接第二阶段已完成主线收口
30+- 当前没有高优先级活动任务卡;如需继续推进,直接新开后续任务
31
32-## 当前 TODO
33+## 当前主线收口情况
34
35-当前浏览器桥接主线已进入正式开发阶段:
36+当前浏览器桥接主线第二阶段已经完成:
37
38 1. [`T-S017.md`](./T-S017.md):已完成,提供浏览器登录态持久化模型与仓储
39 2. [`T-S018.md`](./T-S018.md):已完成,Firefox 插件已收口到空壳标签页并开始上报账号/指纹/端点
40@@ -71,8 +75,8 @@
41 4. [`T-S020.md`](./T-S020.md):已完成,文档、browser smoke 和任务状态视图已同步到正式模型
42 5. [`T-S021.md`](./T-S021.md):已完成,收口 `conductor` describe 与通用 browser HTTP 合同
43 6. [`T-S022.md`](./T-S022.md):已完成,实现 Firefox 空壳页 runtime 与插件管理动作
44-7. [`T-S023.md`](./T-S023.md):打通通用 browser request/SSE 链路与 `conductor` 风控策略
45-8. [`T-S024.md`](./T-S024.md):回写文档、补 smoke 并同步主线状态
46+7. [`T-S023.md`](./T-S023.md):已完成,通用 browser request / cancel / SSE 链路和首版风控策略已接入主线
47+8. [`T-S024.md`](./T-S024.md):已完成,README / docs / smoke / 状态视图已经同步到正式口径
48
49 建议并行关系:
50
51@@ -80,16 +84,18 @@
52 - `T-S019` 已完成集成和验收收口
53 - `T-S020` 已在 `T-S019` 之后完成收尾
54 - `T-S021` 与 `T-S022` 已并行完成
55-- `T-S023` 现在负责服务端集成和通用 request/SSE 主链路
56-- `T-S024` 在 `T-S023` 之后做文档与 smoke 收尾
57+- `T-S023` 已完成服务端集成和通用 request/SSE 主链路收口
58+- `T-S024` 已在 `T-S023` 之后完成文档与 smoke 收尾
59
60 当前已知主线遗留:
61
62-- 当前主线开发任务已切到 `T-S023` 到 `T-S024`
63+- 当前高优先级浏览器桥接任务已收口;后续若继续扩展,需单独新开任务卡
64+- 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
65+- 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,没有接入通用 request / SSE 合同
66 - runtime smoke 仍依赖仓库根已有 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮功能回归
67 - 本地工作树里仍存在与本轮并行任务无关的 `plugins/baa-firefox/controller.js` 改动;后续开发继续避免覆盖它
68 - 这轮还没跑真实 Firefox 手工 smoke,因此“手动关 tab -> `tab_restore` -> WS 重连后状态回报”的浏览器端闭环仍未实测
69-- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有正式的插件管理动作回执合同;插件侧 runtime 和 payload 已准备好,后续由 `T-S023` / `T-S024` 接入
70+- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有 richer 的插件管理动作结果合同;这两项已降为后续增量,不再算当前主线 blocker
71
72 ## 低优先级 TODO
73
1@@ -183,6 +183,35 @@ async function fetchJson(url, init) {
2 };
3 }
4
5+async function fetchText(url, init) {
6+ const response = await fetch(url, init);
7+ const text = await response.text();
8+
9+ return {
10+ response,
11+ text
12+ };
13+}
14+
15+function parseSseFrames(text) {
16+ return String(text || "")
17+ .split(/\n\n+/u)
18+ .map((chunk) => chunk.trim())
19+ .filter(Boolean)
20+ .map((chunk) => {
21+ const lines = chunk.split("\n");
22+ const eventLine = lines.find((line) => line.startsWith("event:"));
23+ const dataLines = lines
24+ .filter((line) => line.startsWith("data:"))
25+ .map((line) => line.slice(5).trimStart());
26+
27+ return {
28+ data: JSON.parse(dataLines.join("\n")),
29+ event: eventLine ? eventLine.slice(6).trim() : null
30+ };
31+ });
32+}
33+
34 function assertNoSecretLeak(text, secrets) {
35 for (const secret of secrets) {
36 assert.doesNotMatch(text, new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u"));
37@@ -303,6 +332,178 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
38 const openMessage = await client.queue.next((message) => message.type === "open_tab");
39 assert.equal(openMessage.platform, "claude");
40
41+ const pluginStatusResult = await fetchJson(`${baseUrl}/v1/browser/actions`, {
42+ method: "POST",
43+ headers: {
44+ "content-type": "application/json"
45+ },
46+ body: JSON.stringify({
47+ action: "plugin_status",
48+ client_id: "firefox-browser-control-smoke"
49+ })
50+ });
51+ assert.equal(pluginStatusResult.response.status, 200);
52+ assert.equal(pluginStatusResult.payload.data.action, "plugin_status");
53+
54+ const pluginStatusMessage = await client.queue.next(
55+ (message) => message.type === "plugin_status"
56+ );
57+ assert.equal(pluginStatusMessage.type, "plugin_status");
58+
59+ const tabRestoreResult = await fetchJson(`${baseUrl}/v1/browser/actions`, {
60+ method: "POST",
61+ headers: {
62+ "content-type": "application/json"
63+ },
64+ body: JSON.stringify({
65+ action: "tab_restore",
66+ client_id: "firefox-browser-control-smoke",
67+ platform: "claude",
68+ reason: "smoke-test"
69+ })
70+ });
71+ assert.equal(tabRestoreResult.response.status, 200);
72+ assert.equal(tabRestoreResult.payload.data.action, "tab_restore");
73+
74+ const tabRestoreMessage = await client.queue.next(
75+ (message) => message.type === "tab_restore"
76+ );
77+ assert.equal(tabRestoreMessage.platform, "claude");
78+ assert.equal(tabRestoreMessage.reason, "smoke-test");
79+
80+ const browserStreamPromise = fetchText(`${baseUrl}/v1/browser/request`, {
81+ method: "POST",
82+ headers: {
83+ "content-type": "application/json"
84+ },
85+ body: JSON.stringify({
86+ platform: "claude",
87+ method: "POST",
88+ path: "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1/completion",
89+ requestBody: {
90+ prompt: "Stream the bridge state."
91+ },
92+ requestId: "browser-stream-smoke",
93+ responseMode: "sse"
94+ })
95+ });
96+
97+ const browserStreamRequest = await client.queue.next(
98+ (message) => message.type === "api_request" && message.id === "browser-stream-smoke"
99+ );
100+ assert.equal(browserStreamRequest.method, "POST");
101+ assert.equal(
102+ browserStreamRequest.path,
103+ "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1/completion"
104+ );
105+ assert.equal(browserStreamRequest.response_mode, "sse");
106+ assert.equal(browserStreamRequest.stream_id, "browser-stream-smoke");
107+ assert.equal(browserStreamRequest.body.prompt, "Stream the bridge state.");
108+
109+ client.socket.send(
110+ JSON.stringify({
111+ type: "stream_open",
112+ id: "browser-stream-smoke",
113+ stream_id: "browser-stream-smoke",
114+ status: 200,
115+ meta: {
116+ source: "smoke"
117+ }
118+ })
119+ );
120+ client.socket.send(
121+ JSON.stringify({
122+ type: "stream_event",
123+ id: "browser-stream-smoke",
124+ stream_id: "browser-stream-smoke",
125+ seq: 1,
126+ event: "message",
127+ data: {
128+ delta: "Bridge is streaming."
129+ },
130+ raw: 'data: {"delta":"Bridge is streaming."}'
131+ })
132+ );
133+ client.socket.send(
134+ JSON.stringify({
135+ type: "stream_end",
136+ id: "browser-stream-smoke",
137+ stream_id: "browser-stream-smoke",
138+ status: 200
139+ })
140+ );
141+
142+ const browserStreamResult = await browserStreamPromise;
143+ assert.equal(browserStreamResult.response.status, 200);
144+ assert.equal(
145+ browserStreamResult.response.headers.get("content-type"),
146+ "text/event-stream; charset=utf-8"
147+ );
148+ const browserStreamFrames = parseSseFrames(browserStreamResult.text);
149+ assert.deepEqual(
150+ browserStreamFrames.map((frame) => frame.event),
151+ ["stream_open", "stream_event", "stream_end"]
152+ );
153+ assert.equal(browserStreamFrames[0].data.request_id, "browser-stream-smoke");
154+ assert.equal(browserStreamFrames[0].data.response_mode, "sse");
155+ assert.equal(browserStreamFrames[1].data.seq, 1);
156+ assert.equal(browserStreamFrames[1].data.data.delta, "Bridge is streaming.");
157+ assert.equal(browserStreamFrames[2].data.stream_id, "browser-stream-smoke");
158+
159+ const cancelableRequestPromise = fetchJson(`${baseUrl}/v1/browser/request`, {
160+ method: "POST",
161+ headers: {
162+ "content-type": "application/json"
163+ },
164+ body: JSON.stringify({
165+ platform: "claude",
166+ method: "GET",
167+ path: "/api/organizations",
168+ requestId: "browser-cancel-smoke"
169+ })
170+ });
171+
172+ const cancelableRequest = await client.queue.next(
173+ (message) => message.type === "api_request" && message.id === "browser-cancel-smoke"
174+ );
175+ assert.equal(cancelableRequest.method, "GET");
176+ assert.equal(cancelableRequest.path, "/api/organizations");
177+
178+ const cancelResult = await fetchJson(`${baseUrl}/v1/browser/request/cancel`, {
179+ method: "POST",
180+ headers: {
181+ "content-type": "application/json"
182+ },
183+ body: JSON.stringify({
184+ platform: "claude",
185+ requestId: "browser-cancel-smoke",
186+ reason: "smoke-test"
187+ })
188+ });
189+ assert.equal(cancelResult.response.status, 200);
190+ assert.equal(cancelResult.payload.data.status, "cancel_requested");
191+ assert.equal(cancelResult.payload.data.type, "request_cancel");
192+
193+ const cancelMessage = await client.queue.next(
194+ (message) => message.type === "request_cancel" && message.id === "browser-cancel-smoke"
195+ );
196+ assert.equal(cancelMessage.platform, "claude");
197+ assert.equal(cancelMessage.reason, "smoke-test");
198+
199+ client.socket.send(
200+ JSON.stringify({
201+ type: "api_response",
202+ id: "browser-cancel-smoke",
203+ ok: false,
204+ status: 499,
205+ error: "browser_request_cancelled"
206+ })
207+ );
208+
209+ const cancelledRequestResult = await cancelableRequestPromise;
210+ assert.equal(cancelledRequestResult.response.status, 499);
211+ assert.equal(cancelledRequestResult.payload.error, "browser_upstream_error");
212+
213 const sendPromise = fetchJson(`${baseUrl}/v1/browser/claude/send`, {
214 method: "POST",
215 headers: {