- commit
- 3769b05
- parent
- b3f2e2f
- author
- codex@macbookpro
- date
- 2026-03-27 15:51:57 +0800 CST
feat: finalize stale lease sweeping and chatgpt relay
13 files changed,
+1036,
-130
+55,
-22
1@@ -3,11 +3,11 @@
2 ## 结论摘要
3
4 - 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复。
5-- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude 代发 + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
6+- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
7 - 代码和自动化测试都表明:`/describe/business`、`/describe/control`、`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel` 已经形成正式主链路。
8-- 目前不应再把系统描述成“只有 Claude 专用页面路径”;当前是“通用 browser surface 已落地,但正式 relay 仍只有 Claude 接通,其它平台主要停留在空壳页和元数据链路”。
9-- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、`BUG-012` 还没有 stale `inFlight` 自动清扫机制,以及正式 browser relay 仍只有 Claude 接通。
10-- 针对这些残余项,当前已经拆出三张后续任务卡:`T-S026`、`T-S027`、`T-S028`。
11+- 目前不应再把系统描述成“只有 Claude 专用页面路径”;当前应表述为“通用 browser surface 已落地,正式 relay 已覆盖 Claude 和 ChatGPT,Gemini 仍停留在空壳页和元数据链路”。
12+- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut,以及 Gemini relay 仍留在下一波。
13+- 此前拆出的后续任务卡里,`T-S027`、`T-S028` 已完成;当前主要剩余 `T-S026`。
14
15 ## 本次核对依据
16
17@@ -26,7 +26,7 @@
18 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
19 - 结果:通过
20 - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
21- - 结果:`32/32` 通过
22+ - 结果:`35/35` 通过
23 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
24 - 结果:`3/3` 通过
25
26@@ -57,6 +57,14 @@
27 - `records`
28 - `policy`
29 - `summary`
30+- `policy.targets[]` 现已额外带:
31+ - `last_activity_at`
32+ - `last_activity_reason`
33+ - `stale_sweep_count`
34+ - `last_stale_sweep_at`
35+ - `last_stale_sweep_idle_ms`
36+ - `last_stale_sweep_reason`
37+ - `last_stale_sweep_request_id`
38 - `current_client` 已带:
39 - `shell_runtime`
40 - `last_action_result`
41@@ -69,13 +77,15 @@
42
43 ### 3. 通用 browser request/cancel/SSE 主链路已落地
44
45-- `POST /v1/browser/request` 已支持 buffered JSON 和正式 SSE。
46+- `POST /v1/browser/request` 已正式支持:
47+ - Claude:prompt shortcut + raw path 的 buffered / SSE
48+ - ChatGPT:显式 `path` 的 raw buffered / SSE
49 - SSE 事件类型已经明确实现为:
50 - `stream_open`
51 - `stream_event`
52 - `stream_end`
53 - `stream_error`
54-- `POST /v1/browser/request/cancel` 已接通取消链路。
55+- `POST /v1/browser/request/cancel` 已接通 Claude / ChatGPT 的取消链路。
56 - 证据:
57 - `../apps/conductor-daemon/src/local-api.ts:4717-4789`
58 - `../apps/conductor-daemon/src/local-api.ts:4792-4875`
59@@ -84,6 +94,21 @@
60 - `../tests/browser/browser-control-e2e-smoke.test.mjs:513-645`
61 - `../apps/conductor-daemon/src/index.test.js:1894-1955`
62
63+### 11. ChatGPT browser relay 已收口到正式合同
64+
65+- `platform=chatgpt` 的 `/v1/browser/request` 已补上正式合同说明和自动化验证。
66+- 当前正式支持面是:
67+ - buffered
68+ - SSE
69+ - cancel
70+- 当前不支持的是 Claude 风格的 prompt shortcut;ChatGPT 仍要求显式 `path`,并依赖浏览器里已捕获的有效登录态 / header。
71+- 证据:
72+ - `../apps/conductor-daemon/src/local-api.ts`
73+ - `../apps/conductor-daemon/src/index.test.js`
74+ - `../tests/browser/browser-control-e2e-smoke.test.mjs`
75+ - `../docs/api/README.md`
76+ - `../docs/api/business-interfaces.md`
77+
78 ### 4. 浏览器风控策略已在 `conductor-daemon` 内实现
79
80 - 默认策略已实装,不只是文档约定:
81@@ -92,8 +117,10 @@
82 - 单 `client/platform` 并发
83 - 失败退避
84 - 熔断
85+ - stale `inFlight` 后台清扫
86 - stream open/idle timeout
87 - stream buffer 上限
88+- 策略运行态现在按 lease 级别追踪最近活跃时间;SSE 事件和关键代理阶段会 `touch` 活跃时间,后台按 idle 阈值定期 sweep 明显异常卡死的 slot。
89 - 证据:
90 - `../apps/conductor-daemon/src/browser-request-policy.ts:11-145`
91 - `../apps/conductor-daemon/src/local-api.ts:2810-2825`
92@@ -179,8 +206,9 @@
93 - `../apps/conductor-daemon/src/index.ts` 已新增 `awaitWritableDrainOrClose(...)`
94 - `../apps/conductor-daemon/src/index.test.js` 已覆盖 body close、stream close、drain 后继续写入
95 - `BUG-012`
96- - `../apps/conductor-daemon/src/browser-request-policy.ts` 已增加 waiter timeout、队列清理和 `waiter_timeout` 错误细节
97- - `../apps/conductor-daemon/src/index.test.js` 已覆盖 target slot timeout、platform admission timeout 和 HTTP 503 返回路径
98+ - `../apps/conductor-daemon/src/browser-request-policy.ts` 已增加 waiter timeout、队列清理、lease 活跃时间追踪和 stale `inFlight` 后台清扫
99+ - `../apps/conductor-daemon/src/local-api.ts` 已把关键 browser request 阶段与 SSE 事件接到 `lease.touch(...)`,并把 stale sweep 诊断字段暴露到 `GET /v1/browser`
100+ - `../apps/conductor-daemon/src/index.test.js` 已覆盖 target slot timeout、platform admission timeout、stale sweep 恢复 waiter、健康请求不误回收和 `/v1/browser` 读面诊断字段
101 - `BUG-014`
102 - `../plugins/baa-firefox/controller.js` 的 `ws_reconnect` 已改为 deferred 结果
103 - `../tests/browser/browser-control-e2e-smoke.test.mjs` 已覆盖 `ws_reconnect.completed === false`
104@@ -203,24 +231,25 @@
105 - 当前不能把“已修复 bug”误写成“所有残余风险都已消失”。
106 - 当前没有 open bug 卡,但仍保留若干非 bug 型残余风险和后续增强项。
107
108-### 3. 正式 browser relay 仍然只有 Claude 接通
109+### 3. Gemini 仍未进入正式 browser relay 合同
110
111-- `chatgpt` 和 `gemini` 在插件侧已有平台定义、空壳页和元数据路径。
112-- 但从 `local-api.ts` 的正式说明和测试覆盖看,真正走通 request / SSE / legacy wrapper 的还是 Claude。
113+- `gemini` 在插件侧已有平台定义、空壳页和部分模板逻辑。
114+- 但本轮正式验收和文档转正只覆盖 Claude 与 ChatGPT。
115 - 因此当前应表述为:
116- - Claude:正式 relay 已可用
117- - ChatGPT / Gemini:元数据和 shell runtime 已有,通用 relay 未见同等级落地验证
118+ - Claude:正式 relay 已可用,且保留 prompt shortcut / legacy helper
119+ - ChatGPT:正式 relay 已可用,但仅限显式 `path` 的 raw request / SSE / cancel
120+ - Gemini:仍停留在空壳页和元数据路径,留待下一波
121
122 ### 4. 风控状态仍是进程内内存态
123
124 - 默认策略本身已实现。
125 - 但策略运行态计数并未持久化,进程重启后限流 / 退避 / 熔断状态会重置。
126
127-### 5. `BUG-012` 当前没有 stale `inFlight` 自动清扫机制
128+### 5. stale `inFlight` 自愈已落地,但仍保留极长静默请求误判风险
129
130-- 这轮修复已经把“永久挂起”收口成“明确超时失败”。
131-- 但如果未来真的出现长期不恢复的 lease 泄漏,同一 `target` 的后续请求会稳定超时失败,而不是自动自愈。
132-- 如果要进一步增强,应单开任务补 stale `inFlight` 清扫机制。
133+- 当前代码已经能在后台定期 sweep 明显 stale 的 lease,并让同一 `target` 的 waiter 继续推进。
134+- 这轮为避免误杀,默认只在 `5min` 无活跃更新后才回收 slot,并依赖 `lease.touch(...)` 标记关键活跃点。
135+- 如果未来出现“健康但长时间完全静默”的超长 buffered 请求,理论上仍存在被保守阈值误判的风险;当前文档里应把它写成残余边界,而不是“完全没有自愈”。
136
137 ### 6. `ws_reconnect` 的自动化验证还不是 Firefox 真实 reconnect 生命周期验收
138
139@@ -228,18 +257,22 @@
140 - 真实 Firefox 扩展运行环境里的 reconnect 生命周期本身,仍依赖后续 `hello` / 状态同步来体现“真正重连完成”。
141 - 这一层现有设计未扩改,因此仍建议在真实 Firefox 环境里补手工 smoke。
142
143+### 7. ChatGPT 已转正,但仍保留平台前提边界
144+
145+- ChatGPT 现在已正式接入 `/v1/browser/request` 的显式 `path` buffered / SSE / cancel。
146+- 但它仍依赖浏览器里真实捕获到的有效登录态 / header,不是“无前提可用”的平台。
147+- 另外,ChatGPT 也没有 Claude 风格的 prompt shortcut;当前正式支持面仍是 raw relay,不是 prompt helper。
148+
149 ## 已拆出的后续任务
150
151 - `T-S026`:真实 Firefox 手工 smoke 与验收记录
152-- `T-S027`:补 `browser-request-policy` stale `inFlight` 自愈清扫
153-- `T-S028`:收口 ChatGPT browser relay 到正式合同
154
155 ## 建议给 Claude 重点复核的点
156
157 1. 复核 `../apps/conductor-daemon/src/index.ts` 与 `../apps/conductor-daemon/src/index.test.js`
158 - 目标:确认 `BUG-011` 的 body / stream 背压断连修复和测试范围准确
159 2. 复核 `../apps/conductor-daemon/src/browser-request-policy.ts` 与 `../apps/conductor-daemon/src/index.test.js`
160- - 目标:确认 `BUG-012` 的 timeout / cleanup 已落地,以及“没有 stale `inFlight` 自动清扫”这一残余风险判断准确
161+ - 目标:确认 `BUG-012` 的 timeout / cleanup / stale sweep 都已落地,且健康长请求的误判风险描述准确
162 3. 复核 `../plugins/baa-firefox/controller.js` 与 `../tests/browser/browser-control-e2e-smoke.test.mjs`
163 - 目标:确认 `BUG-014` 的 deferred 结果语义已落地,且当前自动化验证只覆盖 conductor 侧语义透传
164 4. 复核 `../apps/conductor-daemon/src/local-api.ts`
165@@ -251,4 +284,4 @@
166
167 如果只写一段给外部协作者看,可以用下面这版:
168
169-> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude browser relay 主链路。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(32/32)和 browser-control e2e smoke(3/3)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、正式 relay 仍只有 Claude 接通、风控运行态仍是进程内内存态,以及 `BUG-012` 还没有 stale `inFlight` 自动清扫机制。
170+> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT browser relay 主链路。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(35/35)和 browser-control e2e smoke(3/3)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、Gemini relay 尚未正式化,以及风控运行态仍是进程内内存态。
1@@ -15,6 +15,11 @@ export interface BrowserRequestStreamPolicyConfig {
2 openTimeoutMs: number;
3 }
4
5+export interface BrowserRequestStaleLeaseConfig {
6+ idleMs: number;
7+ sweepIntervalMs: number;
8+}
9+
10 export interface BrowserRequestPolicyConfig {
11 backoff: {
12 baseMs: number;
13@@ -37,6 +42,7 @@ export interface BrowserRequestPolicyConfig {
14 requestsPerMinutePerPlatform: number;
15 windowMs: number;
16 };
17+ staleLease: BrowserRequestStaleLeaseConfig;
18 stream: BrowserRequestStreamPolicyConfig;
19 }
20
21@@ -74,10 +80,17 @@ export interface BrowserRequestPolicySnapshot {
22 clientId: string;
23 consecutiveFailures: number;
24 inFlight: number;
25+ lastActivityAt: number | null;
26+ lastActivityReason: string | null;
27 lastError: string | null;
28 lastFailureAt: number | null;
29+ lastStaleSweepAt: number | null;
30+ lastStaleSweepIdleMs: number | null;
31+ lastStaleSweepReason: string | null;
32+ lastStaleSweepRequestId: string | null;
33 lastSuccessAt: number | null;
34 platform: string;
35+ staleSweepCount: number;
36 waiting: number;
37 }>;
38 }
39@@ -86,6 +99,7 @@ export interface BrowserRequestPolicyLease {
40 readonly admission: BrowserRequestAdmission;
41 readonly target: BrowserRequestTarget;
42 complete(outcome: BrowserRequestLeaseOutcome): void;
43+ touch(reason?: string): void;
44 }
45
46 export interface BrowserRequestPolicyControllerOptions {
47@@ -103,15 +117,31 @@ interface BrowserRequestPlatformState {
48 waiters: Array<() => void>;
49 }
50
51+interface BrowserRequestLeaseRuntimeState {
52+ admittedAt: number | null;
53+ createdAt: number;
54+ lastActivityAt: number;
55+ lastActivityReason: string;
56+ requestId: string;
57+}
58+
59 interface BrowserRequestTargetState {
60 backoffUntil: number | null;
61 circuitRetryAt: number | null;
62 circuitState: BrowserRequestCircuitState;
63 consecutiveFailures: number;
64 inFlight: number;
65+ lastActivityAt: number | null;
66+ lastActivityReason: string | null;
67 lastError: string | null;
68 lastFailureAt: number | null;
69+ lastStaleSweepAt: number | null;
70+ lastStaleSweepIdleMs: number | null;
71+ lastStaleSweepReason: string | null;
72+ lastStaleSweepRequestId: string | null;
73 lastSuccessAt: number | null;
74+ leases: Map<string, BrowserRequestLeaseRuntimeState>;
75+ staleSweepCount: number;
76 waiters: Array<() => void>;
77 }
78
79@@ -137,6 +167,10 @@ const DEFAULT_BROWSER_REQUEST_POLICY: BrowserRequestPolicyConfig = {
80 requestsPerMinutePerPlatform: 10,
81 windowMs: 60_000
82 },
83+ staleLease: {
84+ idleMs: 300_000,
85+ sweepIntervalMs: 30_000
86+ },
87 stream: {
88 idleTimeoutMs: 30_000,
89 maxBufferedBytes: 512 * 1024,
90@@ -144,6 +178,7 @@ const DEFAULT_BROWSER_REQUEST_POLICY: BrowserRequestPolicyConfig = {
91 openTimeoutMs: 10_000
92 }
93 };
94+
95 const BROWSER_REQUEST_WAITER_TIMEOUT_MS = 120_000;
96
97 function clonePolicyConfig(input: BrowserRequestPolicyConfig): BrowserRequestPolicyConfig {
98@@ -169,6 +204,10 @@ function clonePolicyConfig(input: BrowserRequestPolicyConfig): BrowserRequestPol
99 requestsPerMinutePerPlatform: input.rateLimit.requestsPerMinutePerPlatform,
100 windowMs: input.rateLimit.windowMs
101 },
102+ staleLease: {
103+ idleMs: input.staleLease.idleMs,
104+ sweepIntervalMs: input.staleLease.sweepIntervalMs
105+ },
106 stream: {
107 idleTimeoutMs: input.stream.idleTimeoutMs,
108 maxBufferedBytes: input.stream.maxBufferedBytes,
109@@ -203,6 +242,10 @@ function mergePolicyConfig(
110 ...defaults.rateLimit,
111 ...(overrides.rateLimit ?? {})
112 },
113+ staleLease: {
114+ ...defaults.staleLease,
115+ ...(overrides.staleLease ?? {})
116+ },
117 stream: {
118 ...defaults.stream,
119 ...(overrides.stream ?? {})
120@@ -245,15 +288,34 @@ function buildTimeoutPromise(
121 });
122 }
123
124+function maybeUnrefTimeout(handle: TimeoutHandle): void {
125+ const maybeHandle = handle as {
126+ unref?: () => void;
127+ } | null;
128+
129+ if (typeof maybeHandle?.unref === "function") {
130+ maybeHandle.unref();
131+ }
132+}
133+
134 class BrowserRequestPolicyLeaseImpl implements BrowserRequestPolicyLease {
135 private completed = false;
136
137 constructor(
138 readonly admission: BrowserRequestAdmission,
139 readonly target: BrowserRequestTarget,
140+ private readonly onTouch: (reason?: string) => void,
141 private readonly onComplete: (outcome: BrowserRequestLeaseOutcome) => void
142 ) {}
143
144+ touch(reason?: string): void {
145+ if (this.completed) {
146+ return;
147+ }
148+
149+ this.onTouch(reason);
150+ }
151+
152 complete(outcome: BrowserRequestLeaseOutcome): void {
153 if (this.completed) {
154 return;
155@@ -278,10 +340,12 @@ export class BrowserRequestPolicyError extends Error {
156 export class BrowserRequestPolicyController {
157 private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
158 private readonly config: BrowserRequestPolicyConfig;
159+ private nextLeaseSequence = 1;
160 private readonly now: () => number;
161 private readonly platforms = new Map<string, BrowserRequestPlatformState>();
162 private readonly random: () => number;
163 private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
164+ private staleLeaseSweepTimer: TimeoutHandle | null = null;
165 private readonly targets = new Map<string, BrowserRequestTargetState>();
166
167 constructor(options: BrowserRequestPolicyControllerOptions = {}) {
168@@ -291,7 +355,9 @@ export class BrowserRequestPolicyController {
169 this.clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => globalThis.clearTimeout(handle));
170 this.now = options.now ?? (() => Date.now());
171 this.random = options.random ?? (() => Math.random());
172- this.setTimeoutImpl = options.setTimeoutImpl ?? ((handler, timeoutMs) => globalThis.setTimeout(handler, timeoutMs));
173+ this.setTimeoutImpl =
174+ options.setTimeoutImpl ?? ((handler, timeoutMs) => globalThis.setTimeout(handler, timeoutMs));
175+ this.scheduleNextStaleLeaseSweep();
176 }
177
178 getConfig(): BrowserRequestPolicyConfig {
179@@ -321,10 +387,17 @@ export class BrowserRequestPolicyController {
180 clientId: clientId ?? "",
181 consecutiveFailures: state.consecutiveFailures,
182 inFlight: state.inFlight,
183+ lastActivityAt: state.lastActivityAt,
184+ lastActivityReason: state.lastActivityReason,
185 lastError: state.lastError,
186 lastFailureAt: state.lastFailureAt,
187+ lastStaleSweepAt: state.lastStaleSweepAt,
188+ lastStaleSweepIdleMs: state.lastStaleSweepIdleMs,
189+ lastStaleSweepReason: state.lastStaleSweepReason,
190+ lastStaleSweepRequestId: state.lastStaleSweepRequestId,
191 lastSuccessAt: state.lastSuccessAt,
192 platform: platform ?? "",
193+ staleSweepCount: state.staleSweepCount,
194 waiting: state.waiters.length
195 };
196 })
197@@ -347,19 +420,27 @@ export class BrowserRequestPolicyController {
198 const normalizedTarget = this.normalizeTarget(target);
199 const requestedAt = this.now();
200 const targetState = this.getTargetState(normalizedTarget);
201- await this.acquireTargetSlot(normalizedTarget, targetState);
202+ this.sweepStaleTargetLeases(targetState, requestedAt, "request_begin");
203+ const leaseId = await this.acquireTargetSlot(normalizedTarget, targetState, requestId);
204 let admission: BrowserRequestAdmission | null = null;
205
206 try {
207- admission = await this.admitRequest(normalizedTarget, targetState, requestId, requestedAt);
208+ admission = await this.admitRequest(normalizedTarget, targetState, leaseId, requestId, requestedAt);
209 } catch (error) {
210- this.releaseTargetSlot(targetState);
211+ this.releaseTargetLease(targetState, leaseId);
212 throw error;
213 }
214
215- return new BrowserRequestPolicyLeaseImpl(admission, normalizedTarget, (outcome) => {
216- this.completeRequest(normalizedTarget, targetState, outcome);
217- });
218+ return new BrowserRequestPolicyLeaseImpl(
219+ admission,
220+ normalizedTarget,
221+ (reason) => {
222+ this.touchLease(targetState, leaseId, reason);
223+ },
224+ (outcome) => {
225+ this.completeRequest(targetState, leaseId, outcome);
226+ }
227+ );
228 }
229
230 private normalizeTarget(target: BrowserRequestTarget): BrowserRequestTarget {
231@@ -407,9 +488,17 @@ export class BrowserRequestPolicyController {
232 circuitState: "closed",
233 consecutiveFailures: 0,
234 inFlight: 0,
235+ lastActivityAt: null,
236+ lastActivityReason: null,
237 lastError: null,
238 lastFailureAt: null,
239+ lastStaleSweepAt: null,
240+ lastStaleSweepIdleMs: null,
241+ lastStaleSweepReason: null,
242+ lastStaleSweepRequestId: null,
243 lastSuccessAt: null,
244+ leases: new Map(),
245+ staleSweepCount: 0,
246 waiters: []
247 };
248 this.targets.set(key, created);
249@@ -454,14 +543,15 @@ export class BrowserRequestPolicyController {
250
251 private async acquireTargetSlot(
252 target: BrowserRequestTarget,
253- state: BrowserRequestTargetState
254- ): Promise<void> {
255+ state: BrowserRequestTargetState,
256+ requestId: string
257+ ): Promise<string> {
258 if (
259 state.inFlight < this.config.concurrency.maxInFlightPerClientPlatform
260 && state.waiters.length === 0
261 ) {
262 state.inFlight += 1;
263- return;
264+ return this.createLeaseRuntime(state, requestId, "slot_acquired");
265 }
266
267 await this.waitForWaiter(
268@@ -478,6 +568,8 @@ export class BrowserRequestPolicyController {
269 }
270 )
271 );
272+
273+ return this.createLeaseRuntime(state, requestId, "slot_acquired");
274 }
275
276 private releaseTargetSlot(state: BrowserRequestTargetState): void {
277@@ -491,6 +583,76 @@ export class BrowserRequestPolicyController {
278 state.inFlight = Math.max(0, state.inFlight - 1);
279 }
280
281+ private createLeaseRuntime(
282+ state: BrowserRequestTargetState,
283+ requestId: string,
284+ reason: string
285+ ): string {
286+ const now = this.now();
287+ const leaseId = `${requestId}\u0000${this.nextLeaseSequence++}`;
288+ state.leases.set(leaseId, {
289+ admittedAt: null,
290+ createdAt: now,
291+ lastActivityAt: now,
292+ lastActivityReason: reason,
293+ requestId
294+ });
295+ this.recordTargetActivity(state, now, reason);
296+ return leaseId;
297+ }
298+
299+ private recordTargetActivity(
300+ state: BrowserRequestTargetState,
301+ at: number,
302+ reason: string
303+ ): void {
304+ state.lastActivityAt = at;
305+ state.lastActivityReason = normalizeOptionalString(reason);
306+ }
307+
308+ private touchLease(
309+ state: BrowserRequestTargetState,
310+ leaseId: string,
311+ reason?: string
312+ ): void {
313+ const lease = state.leases.get(leaseId);
314+ if (lease == null) {
315+ return;
316+ }
317+
318+ const now = this.now();
319+ lease.lastActivityAt = now;
320+ lease.lastActivityReason = normalizeOptionalString(reason) ?? "lease_touch";
321+ this.recordTargetActivity(state, now, lease.lastActivityReason);
322+ }
323+
324+ private markLeaseAdmitted(
325+ state: BrowserRequestTargetState,
326+ leaseId: string,
327+ admittedAt: number
328+ ): void {
329+ const lease = state.leases.get(leaseId);
330+ if (lease == null) {
331+ return;
332+ }
333+
334+ lease.admittedAt = admittedAt;
335+ lease.lastActivityAt = admittedAt;
336+ lease.lastActivityReason = "admitted";
337+ this.recordTargetActivity(state, admittedAt, "admitted");
338+ }
339+
340+ private releaseTargetLease(
341+ state: BrowserRequestTargetState,
342+ leaseId: string
343+ ): void {
344+ if (!state.leases.delete(leaseId)) {
345+ return;
346+ }
347+
348+ this.releaseTargetSlot(state);
349+ }
350+
351 private async acquirePlatformAdmission(
352 target: BrowserRequestTarget,
353 state: BrowserRequestPlatformState
354@@ -530,6 +692,7 @@ export class BrowserRequestPolicyController {
355 private async admitRequest(
356 target: BrowserRequestTarget,
357 state: BrowserRequestTargetState,
358+ leaseId: string,
359 requestId: string,
360 requestedAt: number
361 ): Promise<BrowserRequestAdmission> {
362@@ -546,7 +709,9 @@ export class BrowserRequestPolicyController {
363 const now = this.now();
364 if (backoffUntil > now) {
365 backoffDelayMs = backoffUntil - now;
366+ this.touchLease(state, leaseId, "backoff_wait");
367 await buildTimeoutPromise(this.setTimeoutImpl, backoffDelayMs);
368+ this.touchLease(state, leaseId, "backoff_complete");
369 }
370
371 const nowAfterBackoff = this.now();
372@@ -562,16 +727,21 @@ export class BrowserRequestPolicyController {
373 );
374
375 if (rateLimitDelayMs > 0) {
376+ this.touchLease(state, leaseId, "rate_limit_wait");
377 await buildTimeoutPromise(this.setTimeoutImpl, rateLimitDelayMs);
378+ this.touchLease(state, leaseId, "rate_limit_complete");
379 }
380 }
381
382 jitterDelayMs = this.sampleJitterDelayMs();
383 if (jitterDelayMs > 0) {
384+ this.touchLease(state, leaseId, "jitter_wait");
385 await buildTimeoutPromise(this.setTimeoutImpl, jitterDelayMs);
386+ this.touchLease(state, leaseId, "jitter_complete");
387 }
388
389 const admittedAt = this.now();
390+ this.markLeaseAdmitted(state, leaseId, admittedAt);
391 prunePlatformDispatches(platformState, admittedAt, this.config.rateLimit.windowMs);
392 platformState.dispatches.push(admittedAt);
393 platformState.lastDispatchedAt = admittedAt;
394@@ -582,7 +752,10 @@ export class BrowserRequestPolicyController {
395 circuitState: state.circuitState,
396 jitterDelayMs,
397 platform: target.platform,
398- queueDelayMs: Math.max(0, admittedAt - requestedAt - backoffDelayMs - rateLimitDelayMs - jitterDelayMs),
399+ queueDelayMs: Math.max(
400+ 0,
401+ admittedAt - requestedAt - backoffDelayMs - rateLimitDelayMs - jitterDelayMs
402+ ),
403 rateLimitDelayMs,
404 requestId,
405 requestedAt,
406@@ -628,10 +801,14 @@ export class BrowserRequestPolicyController {
407 }
408
409 private completeRequest(
410- target: BrowserRequestTarget,
411 state: BrowserRequestTargetState,
412+ leaseId: string,
413 outcome: BrowserRequestLeaseOutcome
414 ): void {
415+ if (!state.leases.has(leaseId)) {
416+ return;
417+ }
418+
419 const now = this.now();
420
421 if (outcome.status === "success") {
422@@ -641,12 +818,14 @@ export class BrowserRequestPolicyController {
423 state.consecutiveFailures = 0;
424 state.lastError = null;
425 state.lastSuccessAt = now;
426- this.releaseTargetSlot(state);
427+ this.recordTargetActivity(state, now, "complete_success");
428+ this.releaseTargetLease(state, leaseId);
429 return;
430 }
431
432 if (outcome.status === "cancelled") {
433- this.releaseTargetSlot(state);
434+ this.recordTargetActivity(state, now, "complete_cancelled");
435+ this.releaseTargetLease(state, leaseId);
436 return;
437 }
438
439@@ -667,7 +846,75 @@ export class BrowserRequestPolicyController {
440 state.circuitRetryAt = now + this.config.circuitBreaker.openMs;
441 }
442
443- this.releaseTargetSlot(state);
444+ this.recordTargetActivity(state, now, "complete_failure");
445+ this.releaseTargetLease(state, leaseId);
446+ }
447+
448+ private scheduleNextStaleLeaseSweep(): void {
449+ if (
450+ this.config.staleLease.idleMs <= 0
451+ || this.config.staleLease.sweepIntervalMs <= 0
452+ || this.staleLeaseSweepTimer != null
453+ ) {
454+ return;
455+ }
456+
457+ this.staleLeaseSweepTimer = this.setTimeoutImpl(() => {
458+ this.staleLeaseSweepTimer = null;
459+
460+ try {
461+ this.sweepStaleLeases();
462+ } finally {
463+ this.scheduleNextStaleLeaseSweep();
464+ }
465+ }, this.config.staleLease.sweepIntervalMs);
466+ maybeUnrefTimeout(this.staleLeaseSweepTimer);
467+ }
468+
469+ private sweepStaleLeases(): void {
470+ const now = this.now();
471+
472+ for (const state of this.targets.values()) {
473+ this.sweepStaleTargetLeases(state, now, "background_interval");
474+ }
475+ }
476+
477+ private sweepStaleTargetLeases(
478+ state: BrowserRequestTargetState,
479+ now: number,
480+ reason: string
481+ ): void {
482+ if (state.inFlight === 0 || state.leases.size === 0) {
483+ return;
484+ }
485+
486+ const staleLeaseIds = [...state.leases.entries()]
487+ .filter(([, lease]) => now - lease.lastActivityAt >= this.config.staleLease.idleMs)
488+ .map(([leaseId]) => leaseId);
489+
490+ for (const leaseId of staleLeaseIds) {
491+ this.sweepStaleTargetLease(state, leaseId, now, reason);
492+ }
493+ }
494+
495+ private sweepStaleTargetLease(
496+ state: BrowserRequestTargetState,
497+ leaseId: string,
498+ now: number,
499+ reason: string
500+ ): void {
501+ const lease = state.leases.get(leaseId);
502+ if (lease == null) {
503+ return;
504+ }
505+
506+ state.staleSweepCount += 1;
507+ state.lastStaleSweepAt = now;
508+ state.lastStaleSweepIdleMs = Math.max(0, now - lease.lastActivityAt);
509+ state.lastStaleSweepReason = `${reason}:lease_idle_timeout`;
510+ state.lastStaleSweepRequestId = lease.requestId;
511+ this.recordTargetActivity(state, now, "stale_sweep");
512+ this.releaseTargetLease(state, leaseId);
513 }
514 }
515
+287,
-41
1@@ -579,12 +579,38 @@ function parseSseFrames(text) {
2 });
3 }
4
5+function getShellRuntimeDefaults(platform) {
6+ switch (platform) {
7+ case "chatgpt":
8+ return {
9+ actualUrl: "https://chatgpt.com/c/http",
10+ shellUrl: "https://chatgpt.com/",
11+ title: "ChatGPT HTTP"
12+ };
13+ case "gemini":
14+ return {
15+ actualUrl: "https://gemini.google.com/app",
16+ shellUrl: "https://gemini.google.com/",
17+ title: "Gemini HTTP"
18+ };
19+ case "claude":
20+ default:
21+ return {
22+ actualUrl: "https://claude.ai/chats/http",
23+ shellUrl: "https://claude.ai/",
24+ title: "Claude HTTP"
25+ };
26+ }
27+}
28+
29 function buildShellRuntime(platform, overrides = {}) {
30+ const defaults = getShellRuntimeDefaults(platform);
31+
32 return {
33 platform,
34 desired: {
35 exists: true,
36- shell_url: "https://claude.ai/",
37+ shell_url: defaults.shellUrl,
38 source: "integration",
39 reason: "test",
40 updated_at: 1710000002000,
41@@ -594,8 +620,8 @@ function buildShellRuntime(platform, overrides = {}) {
42 actual: {
43 exists: true,
44 tab_id: 321,
45- url: "https://claude.ai/chats/http",
46- title: "Claude HTTP",
47+ url: defaults.actualUrl,
48+ title: defaults.title,
49 window_id: 91,
50 active: true,
51 status: "complete",
52@@ -663,42 +689,6 @@ function sendPluginActionResult(socket, input) {
53
54 function createBrowserBridgeStub() {
55 const calls = [];
56- const buildShellRuntime = (platform = "claude", overrides = {}) => ({
57- platform,
58- desired: {
59- exists: true,
60- shell_url: "https://claude.ai/",
61- source: "stub",
62- reason: "test",
63- updated_at: 1710000001200,
64- last_action: "tab_open",
65- last_action_at: 1710000001300
66- },
67- actual: {
68- exists: true,
69- tab_id: 88,
70- url: "https://claude.ai/chats/stub",
71- title: "Claude",
72- window_id: 12,
73- active: true,
74- status: "complete",
75- discarded: false,
76- hidden: false,
77- healthy: true,
78- issue: null,
79- last_seen_at: 1710000001400,
80- last_ready_at: 1710000001500,
81- candidate_tab_id: null,
82- candidate_url: null
83- },
84- drift: {
85- aligned: true,
86- needs_restore: false,
87- unexpected_actual: false,
88- reason: "aligned"
89- },
90- ...overrides
91- });
92 const buildActionDispatch = ({
93 action,
94 clientId,
95@@ -849,12 +839,12 @@ function createBrowserBridgeStub() {
96 ws_url: "ws://127.0.0.1:4317/ws/firefox"
97 };
98
99- const buildApiResponse = ({ body, clientId = "firefox-claude", error = null, status = 200 }) => ({
100+ const buildApiResponse = ({ body, clientId = "firefox-claude", error = null, id = null, status = 200 }) => ({
101 body,
102 clientId,
103 connectionId: "conn-firefox-claude",
104 error,
105- id: `browser-${calls.length + 1}`,
106+ id: id ?? `browser-${calls.length + 1}`,
107 ok: error == null && status < 400,
108 respondedAt: 1710000004000 + calls.length,
109 status
110@@ -891,6 +881,7 @@ function createBrowserBridgeStub() {
111
112 if (input.path === "/api/organizations") {
113 return buildApiResponse({
114+ id: input.id,
115 body: {
116 organizations: [
117 {
118@@ -905,6 +896,7 @@ function createBrowserBridgeStub() {
119
120 if (input.path === "/api/organizations/org-1/chat_conversations") {
121 return buildApiResponse({
122+ id: input.id,
123 body: {
124 chat_conversations: [
125 {
126@@ -920,6 +912,7 @@ function createBrowserBridgeStub() {
127
128 if (input.path === "/api/organizations/org-1/chat_conversations/conv-1") {
129 return buildApiResponse({
130+ id: input.id,
131 body: {
132 conversation: {
133 uuid: "conv-1",
134@@ -948,6 +941,7 @@ function createBrowserBridgeStub() {
135
136 if (input.path === "/api/organizations/org-1/chat_conversations/conv-1/completion") {
137 return buildApiResponse({
138+ id: input.id,
139 body: {
140 accepted: true,
141 conversation_uuid: "conv-1"
142@@ -958,6 +952,7 @@ function createBrowserBridgeStub() {
143
144 if (input.path === "/api/stream-buffered-smoke") {
145 return buildApiResponse({
146+ id: input.id,
147 body: [
148 "event: completion",
149 'data: {"type":"completion","completion":"Hello "}',
150@@ -969,6 +964,19 @@ function createBrowserBridgeStub() {
151 });
152 }
153
154+ if (input.path === "/backend-api/models") {
155+ return buildApiResponse({
156+ id: input.id,
157+ body: {
158+ models: [
159+ {
160+ slug: "gpt-5.4"
161+ }
162+ ]
163+ }
164+ });
165+ }
166+
167 throw new Error(`unexpected browser proxy path: ${input.path}`);
168 },
169 cancelApiRequest(input = {}) {
170@@ -1994,6 +2002,14 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
171 "/v1/browser/request/cancel"
172 ]
173 );
174+ assert.deepEqual(
175+ describePayload.data.browser.request_contract.supported_platforms,
176+ ["claude", "chatgpt"]
177+ );
178+ assert.deepEqual(
179+ describePayload.data.browser.action_contract.supported_platforms,
180+ ["claude", "chatgpt"]
181+ );
182 const legacyClaudeOpen = describePayload.data.browser.legacy_routes.find(
183 (route) => route.path === "/v1/browser/claude/open"
184 );
185@@ -2024,6 +2040,10 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
186 assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/runs/u);
187 assert.equal(businessDescribePayload.data.codex.backend, "independent_codexd");
188 assert.equal(businessDescribePayload.data.browser.request_contract.route.path, "/v1/browser/request");
189+ assert.deepEqual(
190+ businessDescribePayload.data.browser.request_contract.supported_platforms,
191+ ["claude", "chatgpt"]
192+ );
193 assert.match(
194 JSON.stringify(businessDescribePayload.data.browser.request_contract.supported_response_modes),
195 /"sse"/u
196@@ -2048,6 +2068,10 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
197 assert.equal(controlDescribePayload.data.codex.target_base_url, codexd.baseUrl);
198 assert.equal(controlDescribePayload.data.browser.action_contract.route.path, "/v1/browser/actions");
199 assert.equal(controlDescribePayload.data.browser.action_contract.response_body.accepted, "布尔值;插件是否接受了该动作。");
200+ assert.deepEqual(
201+ controlDescribePayload.data.browser.action_contract.supported_platforms,
202+ ["claude", "chatgpt"]
203+ );
204 assert.equal(controlDescribePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
205
206 const healthResponse = await handleConductorHttpRequest(
207@@ -2178,6 +2202,27 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
208 assert.equal(bufferedSsePayload.data.response.events[1].data.completion, "world");
209 assert.equal(bufferedSsePayload.data.response.full_text, "Hello world");
210
211+ const chatgptBufferedResponse = await handleConductorHttpRequest(
212+ {
213+ body: JSON.stringify({
214+ platform: "chatgpt",
215+ method: "GET",
216+ path: "/backend-api/models",
217+ requestId: "browser-chatgpt-buffered-123"
218+ }),
219+ method: "POST",
220+ path: "/v1/browser/request"
221+ },
222+ localApiContext
223+ );
224+ assert.equal(chatgptBufferedResponse.status, 200);
225+ const chatgptBufferedPayload = parseJsonBody(chatgptBufferedResponse);
226+ assert.equal(chatgptBufferedPayload.data.request_mode, "api_request");
227+ assert.equal(chatgptBufferedPayload.data.proxy.path, "/backend-api/models");
228+ assert.equal(chatgptBufferedPayload.data.proxy.request_id, "browser-chatgpt-buffered-123");
229+ assert.equal(chatgptBufferedPayload.data.response.models[0].slug, "gpt-5.4");
230+ assert.equal(chatgptBufferedPayload.data.policy.platform, "chatgpt");
231+
232 const browserStreamResponse = await handleConductorHttpRequest(
233 {
234 body: JSON.stringify({
235@@ -2617,6 +2662,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
236 "apiRequest:GET:/api/organizations/org-1/chat_conversations",
237 "apiRequest:POST:/api/organizations/org-1/chat_conversations/conv-1/completion",
238 "apiRequest:GET:/api/stream-buffered-smoke",
239+ "apiRequest:GET:/backend-api/models",
240 "apiRequest:GET:/api/organizations",
241 "apiRequest:GET:/api/organizations/org-1/chat_conversations",
242 "streamRequest:claude",
243@@ -2735,6 +2781,132 @@ test("BrowserRequestPolicyController times out target slot waiters and removes t
244 assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude", "claude")?.inFlight, 0);
245 });
246
247+test("BrowserRequestPolicyController sweeps stale in-flight leases and lets the next waiter proceed", async () => {
248+ const scheduler = createManualTimerScheduler();
249+ const policy = new BrowserRequestPolicyController({
250+ clearTimeoutImpl: scheduler.clearTimeout,
251+ config: {
252+ jitter: {
253+ maxMs: 0,
254+ minMs: 0,
255+ muMs: 0,
256+ sigmaMs: 0
257+ },
258+ staleLease: {
259+ idleMs: 60_000,
260+ sweepIntervalMs: 10_000
261+ }
262+ },
263+ now: scheduler.now,
264+ setTimeoutImpl: scheduler.setTimeout
265+ });
266+
267+ const leakedLease = await policy.beginRequest({
268+ clientId: "firefox-claude",
269+ platform: "claude"
270+ }, "request-leaked");
271+ const recoveredLeasePromise = policy.beginRequest({
272+ clientId: "firefox-claude",
273+ platform: "claude"
274+ }, "request-recovered");
275+
276+ await flushAsyncWork();
277+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude", "claude")?.waiting, 1);
278+
279+ scheduler.advanceBy(60_000);
280+ await flushAsyncWork();
281+
282+ const recoveredLease = await recoveredLeasePromise;
283+ const targetSnapshot = getPolicyTargetSnapshot(policy, "firefox-claude", "claude");
284+ assert.equal(recoveredLease.admission.requestId, "request-recovered");
285+ assert.equal(targetSnapshot?.inFlight, 1);
286+ assert.equal(targetSnapshot?.waiting, 0);
287+ assert.equal(targetSnapshot?.staleSweepCount, 1);
288+ assert.equal(targetSnapshot?.lastStaleSweepAt, 60_000);
289+ assert.equal(targetSnapshot?.lastStaleSweepRequestId, "request-leaked");
290+ assert.equal(targetSnapshot?.lastStaleSweepReason, "background_interval:lease_idle_timeout");
291+
292+ leakedLease.complete({
293+ status: "cancelled"
294+ });
295+
296+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude", "claude")?.inFlight, 1);
297+
298+ recoveredLease.complete({
299+ status: "success"
300+ });
301+
302+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude", "claude")?.inFlight, 0);
303+});
304+
305+test("BrowserRequestPolicyController does not sweep healthy leases that keep reporting activity", async () => {
306+ const scheduler = createManualTimerScheduler();
307+ const policy = new BrowserRequestPolicyController({
308+ clearTimeoutImpl: scheduler.clearTimeout,
309+ config: {
310+ jitter: {
311+ maxMs: 0,
312+ minMs: 0,
313+ muMs: 0,
314+ sigmaMs: 0
315+ },
316+ staleLease: {
317+ idleMs: 60_000,
318+ sweepIntervalMs: 10_000
319+ }
320+ },
321+ now: scheduler.now,
322+ setTimeoutImpl: scheduler.setTimeout
323+ });
324+
325+ const healthyLease = await policy.beginRequest({
326+ clientId: "firefox-claude",
327+ platform: "claude"
328+ }, "request-healthy");
329+ const waitingLeasePromise = policy.beginRequest({
330+ clientId: "firefox-claude",
331+ platform: "claude"
332+ }, "request-waiting");
333+ let waitingSettled = false;
334+
335+ void waitingLeasePromise.then(
336+ () => {
337+ waitingSettled = true;
338+ },
339+ () => {
340+ waitingSettled = true;
341+ }
342+ );
343+
344+ await flushAsyncWork();
345+
346+ for (let index = 0; index < 4; index += 1) {
347+ scheduler.advanceBy(20_000);
348+ await flushAsyncWork();
349+ healthyLease.touch("stream_event");
350+ assert.equal(waitingSettled, false);
351+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude", "claude")?.staleSweepCount, 0);
352+ }
353+
354+ const snapshotBeforeComplete = getPolicyTargetSnapshot(policy, "firefox-claude", "claude");
355+ assert.equal(snapshotBeforeComplete?.inFlight, 1);
356+ assert.equal(snapshotBeforeComplete?.lastActivityReason, "stream_event");
357+ assert.equal(waitingSettled, false);
358+
359+ healthyLease.complete({
360+ status: "success"
361+ });
362+ await flushAsyncWork();
363+
364+ const waitingLease = await waitingLeasePromise;
365+ assert.equal(waitingLease.admission.requestId, "request-waiting");
366+ waitingLease.complete({
367+ status: "cancelled"
368+ });
369+
370+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude", "claude")?.inFlight, 0);
371+});
372+
373 test("BrowserRequestPolicyController times out platform admission waiters and releases their target slot", async () => {
374 const scheduler = createManualTimerScheduler();
375 const policy = new BrowserRequestPolicyController({
376@@ -2920,6 +3092,80 @@ test("handleConductorHttpRequest returns a clear 503 when a leaked browser reque
377 });
378 });
379
380+test("handleConductorHttpRequest exposes stale lease sweep diagnostics in /v1/browser", async () => {
381+ const { repository, snapshot } = await createLocalApiFixture();
382+ const browser = createBrowserBridgeStub();
383+ const scheduler = createManualTimerScheduler();
384+ const policy = new BrowserRequestPolicyController({
385+ clearTimeoutImpl: scheduler.clearTimeout,
386+ config: {
387+ jitter: {
388+ maxMs: 0,
389+ minMs: 0,
390+ muMs: 0,
391+ sigmaMs: 0
392+ },
393+ staleLease: {
394+ idleMs: 60_000,
395+ sweepIntervalMs: 10_000
396+ }
397+ },
398+ now: scheduler.now,
399+ setTimeoutImpl: scheduler.setTimeout
400+ });
401+
402+ await policy.beginRequest({
403+ clientId: "firefox-claude",
404+ platform: "claude"
405+ }, "request-leaked");
406+ const recoveredLeasePromise = policy.beginRequest({
407+ clientId: "firefox-claude",
408+ platform: "claude"
409+ }, "request-recovered");
410+
411+ await flushAsyncWork();
412+ scheduler.advanceBy(60_000);
413+ await flushAsyncWork();
414+
415+ const recoveredLease = await recoveredLeasePromise;
416+ recoveredLease.touch("stream_event");
417+
418+ const response = await handleConductorHttpRequest(
419+ {
420+ method: "GET",
421+ path: "/v1/browser"
422+ },
423+ {
424+ ...browser.context,
425+ browserRequestPolicy: policy,
426+ repository,
427+ snapshotLoader: () => snapshot
428+ }
429+ );
430+
431+ assert.equal(response.status, 200);
432+ const payload = parseJsonBody(response);
433+ assert.equal(payload.data.policy.defaults.stale_lease.idle_ms, 60_000);
434+ assert.equal(payload.data.policy.defaults.stale_lease.sweep_interval_ms, 10_000);
435+ assert.equal(payload.data.policy.targets[0].client_id, "firefox-claude");
436+ assert.equal(payload.data.policy.targets[0].platform, "claude");
437+ assert.equal(payload.data.policy.targets[0].in_flight, 1);
438+ assert.equal(payload.data.policy.targets[0].last_activity_at, 60_000);
439+ assert.equal(payload.data.policy.targets[0].last_activity_reason, "stream_event");
440+ assert.equal(payload.data.policy.targets[0].stale_sweep_count, 1);
441+ assert.equal(payload.data.policy.targets[0].last_stale_sweep_at, 60_000);
442+ assert.equal(payload.data.policy.targets[0].last_stale_sweep_idle_ms, 60_000);
443+ assert.equal(
444+ payload.data.policy.targets[0].last_stale_sweep_reason,
445+ "background_interval:lease_idle_timeout"
446+ );
447+ assert.equal(payload.data.policy.targets[0].last_stale_sweep_request_id, "request-leaked");
448+
449+ recoveredLease.complete({
450+ status: "cancelled"
451+ });
452+});
453+
454 test(
455 "handleConductorHttpRequest normalizes exec failures that are blocked by macOS TCC preflight",
456 { concurrency: false },
+52,
-6
1@@ -105,6 +105,8 @@ const SUPPORTED_BROWSER_ACTIONS = [
2 "ws_reconnect"
3 ] as const;
4 const RESERVED_BROWSER_ACTIONS = [] as const;
5+const FORMAL_BROWSER_SHELL_PLATFORMS = ["claude", "chatgpt"] as const;
6+const FORMAL_BROWSER_REQUEST_PLATFORMS = ["claude", "chatgpt"] as const;
7 const SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES = ["buffered", "sse"] as const;
8 const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = [] as const;
9
10@@ -2900,6 +2902,10 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
11 concurrency: policySnapshot.defaults.concurrency,
12 jitter: policySnapshot.defaults.jitter,
13 rate_limit: policySnapshot.defaults.rateLimit,
14+ stale_lease: {
15+ idle_ms: policySnapshot.defaults.staleLease.idleMs,
16+ sweep_interval_ms: policySnapshot.defaults.staleLease.sweepIntervalMs
17+ },
18 stream: {
19 idle_timeout_ms: policySnapshot.defaults.stream.idleTimeoutMs,
20 max_buffered_bytes: policySnapshot.defaults.stream.maxBufferedBytes,
21@@ -2920,10 +2926,17 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
22 client_id: entry.clientId,
23 consecutive_failures: entry.consecutiveFailures,
24 in_flight: entry.inFlight,
25+ last_activity_at: entry.lastActivityAt ?? undefined,
26+ last_activity_reason: entry.lastActivityReason ?? undefined,
27 last_error: entry.lastError ?? undefined,
28 last_failure_at: entry.lastFailureAt ?? undefined,
29+ last_stale_sweep_at: entry.lastStaleSweepAt ?? undefined,
30+ last_stale_sweep_idle_ms: entry.lastStaleSweepIdleMs ?? undefined,
31+ last_stale_sweep_reason: entry.lastStaleSweepReason ?? undefined,
32+ last_stale_sweep_request_id: entry.lastStaleSweepRequestId ?? undefined,
33 last_success_at: entry.lastSuccessAt ?? undefined,
34 platform: entry.platform,
35+ stale_sweep_count: entry.staleSweepCount,
36 waiting: entry.waiting
37 }))
38 },
39@@ -3191,7 +3204,8 @@ function buildBrowserActionContract(origin: string): JsonObject {
40 request_body: {
41 action:
42 "必填字符串。当前正式支持 plugin_status、request_credentials、tab_open、tab_focus、tab_reload、tab_restore、ws_reconnect、controller_reload。",
43- platform: "tab_open、tab_focus、tab_reload、request_credentials、tab_restore 建议带非空平台字符串;当前正式平台仍是 claude。",
44+ platform:
45+ "tab_open、tab_focus、tab_reload、request_credentials、tab_restore 建议带非空平台字符串;当前正式 shell / credential 管理平台已覆盖 claude 和 chatgpt,Gemini 仍留在下一波。",
46 clientId: "可选字符串;指定目标 Firefox bridge client。",
47 reason: "可选字符串;request_credentials、tab_reload、tab_restore、ws_reconnect、controller_reload 会原样透传给浏览器侧。"
48 },
49@@ -3206,6 +3220,7 @@ function buildBrowserActionContract(origin: string): JsonObject {
50 shell_runtime: "本次动作返回的最新 runtime 快照列表。"
51 },
52 supported_actions: [...SUPPORTED_BROWSER_ACTIONS],
53+ supported_platforms: [...FORMAL_BROWSER_SHELL_PLATFORMS],
54 reserved_actions: [...RESERVED_BROWSER_ACTIONS],
55 examples: [
56 {
57@@ -3219,7 +3234,7 @@ function buildBrowserActionContract(origin: string): JsonObject {
58 title: "Ask the browser plugin to refresh credentials",
59 curl: buildCurlExample(origin, requireRouteDefinition("browser.actions"), {
60 action: "request_credentials",
61- platform: "claude",
62+ platform: "chatgpt",
63 reason: "describe_refresh"
64 })
65 }
66@@ -3236,7 +3251,8 @@ function buildBrowserRequestContract(origin: string): JsonObject {
67 return {
68 route: describeRoute(requireRouteDefinition("browser.request")),
69 request_body: {
70- platform: "必填字符串;当前正式支持 claude。",
71+ platform:
72+ "必填字符串;当前正式支持 claude 和 chatgpt。claude 额外支持省略 path + prompt 的兼容模式;chatgpt 当前只支持显式 path 的 raw proxy 请求。",
73 clientId: "可选字符串;指定目标 Firefox bridge client。",
74 requestId: "可选字符串;用于 trace 和未来 cancel 对齐。缺省时由 conductor 生成。",
75 method: "可选字符串;默认 GET。若携带 requestBody 或 prompt 且未显式指定,则默认 POST。",
76@@ -3250,6 +3266,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
77 '可选字符串 buffered 或 sse;buffered 返回 JSON,sse 返回 text/event-stream,并按 stream_open / stream_event / stream_end / stream_error 编码。',
78 timeoutMs: `可选整数 > 0;默认 ${DEFAULT_BROWSER_PROXY_TIMEOUT_MS}。`
79 },
80+ supported_platforms: [...FORMAL_BROWSER_REQUEST_PLATFORMS],
81 supported_response_modes: [...SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES],
82 reserved_response_modes: [...RESERVED_BROWSER_REQUEST_RESPONSE_MODES],
83 examples: [
84@@ -3267,10 +3284,18 @@ function buildBrowserRequestContract(origin: string): JsonObject {
85 method: "GET",
86 path: "/api/organizations"
87 })
88+ },
89+ {
90+ title: "Issue a raw ChatGPT browser proxy read against a captured endpoint",
91+ curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
92+ platform: "chatgpt",
93+ method: "GET",
94+ path: "/backend-api/models"
95+ })
96 }
97 ],
98 error_semantics: [
99- "400 invalid_request: 缺字段或组合不合法;例如既没有 path 也没有 Claude prompt。",
100+ "400 invalid_request: 缺字段或组合不合法;例如既没有 path,也不是 platform=claude + prompt 的兼容模式。",
101 "409 claude_credentials_unavailable: Claude prompt 模式还没有捕获到可用凭证。",
102 "503 browser_bridge_unavailable: 当前没有活跃 Firefox bridge client。",
103 "4xx/5xx browser_upstream_error: 浏览器本地代理已返回上游 HTTP 错误;responseMode=sse 时会在事件流里交付 stream_error。"
104@@ -3310,7 +3335,10 @@ function buildBrowserLegacyRouteData(): JsonObject[] {
105 function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: string): JsonObject {
106 return {
107 enabled: snapshot.controlApi.firefoxWsUrl != null,
108+ legacy_helper_platform: BROWSER_CLAUDE_PLATFORM,
109 platform: BROWSER_CLAUDE_PLATFORM,
110+ supported_action_platforms: [...FORMAL_BROWSER_SHELL_PLATFORMS],
111+ supported_request_platforms: [...FORMAL_BROWSER_REQUEST_PLATFORMS],
112 route_prefix: "/v1/browser",
113 routes: [
114 describeRoute(requireRouteDefinition("browser.status")),
115@@ -3329,7 +3357,8 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
116 notes: [
117 "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
118 "GET /v1/browser remains the shared read model for login-state metadata, plugin connectivity, shell_runtime, and the latest structured action_result per client.",
119- "The generic browser HTTP surface currently supports Claude only and expects a local Firefox bridge client.",
120+ "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT raw relay, and expects a local Firefox bridge client.",
121+ "Claude keeps the prompt shortcut when path is omitted; ChatGPT currently requires an explicit path and a real browser login context captured on the selected client.",
122 "POST /v1/browser/actions now waits for the plugin to return a structured action_result instead of returning only a dispatch ack.",
123 "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.",
124 "The /v1/browser/claude/* routes remain available as legacy wrappers during the migration window."
125@@ -4070,7 +4099,7 @@ async function handleCapabilitiesRead(
126 notes: [
127 "Read routes are safe for discovery and inspection.",
128 "The browser HTTP contract is now split into GET /v1/browser, POST /v1/browser/request, and POST /v1/browser/actions.",
129- "The generic browser surface currently supports Claude only; /v1/browser/claude/* remains available as legacy compatibility wrappers.",
130+ "The generic browser request surface now formally supports Claude and ChatGPT; /v1/browser/claude/* remains available as legacy compatibility wrappers.",
131 "All /v1/codex routes proxy the independent codexd daemon over local HTTP.",
132 "POST /v1/system/* writes the local automation mode immediately.",
133 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN> and return 401 JSON on missing or wrong tokens.",
134@@ -4278,6 +4307,7 @@ function buildBrowserSseSuccessResponse(
135
136 if (next == null) {
137 stream.cancel("downstream_disconnected");
138+ execution.lease?.touch("sse_downstream_disconnected");
139 completeLease({
140 code: "downstream_disconnected",
141 message: "The HTTP SSE client disconnected before the browser stream completed.",
142@@ -4287,6 +4317,7 @@ function buildBrowserSseSuccessResponse(
143 }
144
145 if (next.done) {
146+ execution.lease?.touch("sse_iterator_done");
147 completeLease({
148 status: "success"
149 });
150@@ -4297,6 +4328,7 @@ function buildBrowserSseSuccessResponse(
151
152 switch (event.type) {
153 case "stream_open":
154+ execution.lease?.touch("stream_open");
155 yield serializeSseFrame(
156 "stream_open",
157 compactJsonObject({
158@@ -4317,6 +4349,7 @@ function buildBrowserSseSuccessResponse(
159 );
160 break;
161 case "stream_event":
162+ execution.lease?.touch("stream_event");
163 yield serializeSseFrame(
164 "stream_event",
165 compactJsonObject({
166@@ -4330,6 +4363,7 @@ function buildBrowserSseSuccessResponse(
167 );
168 break;
169 case "stream_end":
170+ execution.lease?.touch("stream_end");
171 yield serializeSseFrame(
172 "stream_end",
173 compactJsonObject({
174@@ -4344,6 +4378,7 @@ function buildBrowserSseSuccessResponse(
175 });
176 return;
177 case "stream_error":
178+ execution.lease?.touch("stream_error");
179 yield serializeSseFrame(
180 "stream_error",
181 compactJsonObject({
182@@ -4374,6 +4409,7 @@ function buildBrowserSseSuccessResponse(
183 } finally {
184 if (!leaseCompleted) {
185 stream.cancel("stream_closed");
186+ execution.lease?.touch("stream_closed");
187 completeLease({
188 code: "stream_closed",
189 message: "Browser stream was closed before the conductor completed the SSE relay.",
190@@ -4573,6 +4609,7 @@ async function executeBrowserRequest(
191 },
192 requestId
193 );
194+ lease.touch("claude_request_begin");
195
196 try {
197 const organization = await resolveClaudeOrganization(
198@@ -4581,6 +4618,7 @@ async function executeBrowserRequest(
199 input.organizationId,
200 input.timeoutMs
201 );
202+ lease.touch("claude_organization_resolved");
203 const conversation = await resolveClaudeConversation(
204 context,
205 selection,
206@@ -4591,6 +4629,7 @@ async function executeBrowserRequest(
207 timeoutMs: input.timeoutMs
208 }
209 );
210+ lease.touch("claude_conversation_resolved");
211 const requestPath = buildClaudeRequestPath(
212 BROWSER_CLAUDE_COMPLETION_PATH,
213 organization.id,
214@@ -4598,6 +4637,7 @@ async function executeBrowserRequest(
215 );
216
217 if (responseMode === "sse") {
218+ lease.touch("stream_created");
219 const stream = requireBrowserBridge(context).streamRequest({
220 body: requestBody ?? { prompt: "" },
221 clientId: selection.client.client_id,
222@@ -4633,6 +4673,7 @@ async function executeBrowserRequest(
223 };
224 }
225
226+ lease.touch("buffered_proxy_dispatch");
227 const result = await requestBrowserProxy(context, {
228 action: "browser request",
229 body: requestBody ?? { prompt: "" },
230@@ -4644,6 +4685,7 @@ async function executeBrowserRequest(
231 platform: input.platform,
232 timeoutMs: input.timeoutMs
233 });
234+ lease.touch("buffered_response_received");
235 lease.complete({
236 status: "success"
237 });
238@@ -4690,9 +4732,11 @@ async function executeBrowserRequest(
239 },
240 requestId
241 );
242+ lease.touch("browser_request_begin");
243
244 try {
245 if (responseMode === "sse") {
246+ lease.touch("stream_created");
247 const stream = requireBrowserBridge(context).streamRequest({
248 body: requestBody,
249 clientId: targetClient.client_id,
250@@ -4728,6 +4772,7 @@ async function executeBrowserRequest(
251 };
252 }
253
254+ lease.touch("buffered_proxy_dispatch");
255 const result = await requestBrowserProxy(context, {
256 action: "browser request",
257 body: requestBody,
258@@ -4739,6 +4784,7 @@ async function executeBrowserRequest(
259 platform: input.platform,
260 timeoutMs: input.timeoutMs
261 });
262+ lease.touch("buffered_response_received");
263 lease.complete({
264 status: "success"
265 });
+14,
-4
1@@ -136,7 +136,7 @@
2 | --- | --- | --- |
3 | `GET` | `/v1/browser` | 读取活跃 Firefox bridge、插件在线状态、最新 `shell_runtime` / `last_action_result`、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
4 | `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` |
5-| `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 buffered 与 SSE 请求 |
6+| `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
7 | `POST` | `/v1/browser/request/cancel` | 取消请求或流;会向对应 Firefox client 派发 `request_cancel` |
8 | `POST` | `/v1/browser/claude/open` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
9 | `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
10@@ -153,14 +153,15 @@ Browser 面约定:
11 - `conductor` 只保存并回显 `account`、`credential_fingerprint`、`endpoints`、`endpoint_metadata`、时间戳和 `fresh/stale/lost`
12 - 原始 `cookie`、`token`、header 值不会入库,也不会出现在 `/v1/browser` 读接口里
13 - 连接断开或流量老化后,持久化记录仍可读,但状态会从 `fresh` 变成 `stale` / `lost`
14-- 当前浏览器本地代发面只支持 `claude`;ChatGPT / Gemini 目前只有壳页和元数据上报,不在正式 HTTP relay 合同里
15-- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式
16+- 当前浏览器本地代发面正式支持 `claude` 和 `chatgpt`;Gemini 目前仍只保留壳页和元数据上报,不在正式 HTTP relay 合同里
17+- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` 当前必须显式提供 `path`
18 - `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
19 - SSE 响应固定用 `stream_open`、`stream_event`、`stream_end`、`stream_error` 作为 event name;`stream_event` 带递增 `seq`
20 - `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`,并返回结构化 `action_result`
21 - `GET /v1/browser` 会回显当前风控默认值、最新 `shell_runtime` / `last_action_result` 和运行时 target/platform 状态,便于观察抖动、限流、退避和熔断
22 - `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
23 - `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
24+- ChatGPT raw relay 同样依赖真实浏览器里已捕获到的有效登录态 / header;建议先用 `GET /v1/browser?platform=chatgpt&status=fresh` 确认再发请求
25 - 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
26 - 如果已连接 client 还没拿到 Claude 凭证,会返回 `409` JSON 错误并提示先在浏览器里完成一轮真实请求
27
28@@ -205,6 +206,15 @@ curl -X POST "${LOCAL_API_BASE}/v1/browser/request" \
29 -d '{"platform":"claude","prompt":"Summarize the current bridge state."}'
30 ```
31
32+通过通用 request 合同读取 ChatGPT 已捕获 endpoint:
33+
34+```bash
35+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
36+curl -X POST "${LOCAL_API_BASE}/v1/browser/request" \
37+ -H 'Content-Type: application/json' \
38+ -d '{"platform":"chatgpt","method":"GET","path":"/backend-api/models"}'
39+```
40+
41 通过 SSE 模式直连浏览器流:
42
43 ```bash
44@@ -323,7 +333,7 @@ host-ops 约定:
45 - 浏览器发来的 `credentials` / `api_endpoints` 会被转换成 `account`、凭证指纹、端点元数据和 `fresh/stale/lost` 持久化记录
46 - `headers` 只保留名称与数量;原始 `cookie` / `token` / header 值既不会入库,也不会在 snapshot 或 `/v1/browser` 中回显
47 - `GET /v1/browser` 会合并当前活跃连接和持久化记录;即使 client 断开或 daemon 重启,最近一次记录仍可读取
48-- `/v1/browser/request`、`/v1/browser/request/cancel` 和 legacy 的 `/v1/browser/claude/current` 会复用这条 WS bridge 的 `api_request` / `api_response` / `stream_*` / `request_cancel` 做 Claude 页面内 HTTP 代理
49+- `/v1/browser/request`、`/v1/browser/request/cancel` 和 legacy 的 `/v1/browser/claude/current` 会复用这条 WS bridge 的 `api_request` / `api_response` / `stream_*` / `request_cancel` 做 Claude / ChatGPT 页面内 HTTP 代理
50
51 详细消息模型和 smoke 示例见:
52
+13,
-4
1@@ -64,7 +64,7 @@
2
3 | 方法 | 路径 | 作用 |
4 | --- | --- | --- |
5-| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude buffered 与 SSE 请求 |
6+| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
7 | `POST` | `/v1/browser/request/cancel` | 取消 request 或流;会向对应 Firefox client 下发正式 `request_cancel` |
8 | `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
9
10@@ -73,13 +73,15 @@
11 - `GET /v1/browser` 是当前正式浏览器桥接读面;支持按 `platform`、`account`、`client_id`、`host`、`status` 过滤
12 - 这个读面只返回 `account`、凭证指纹、端点元数据和时间戳状态;不会暴露原始 `cookie`、`token` 或 header 值
13 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
14-- 当前浏览器代发面只支持 `claude`
15-- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式
16+- 当前浏览器代发面正式支持 `claude` 和 `chatgpt`;Gemini 继续留在下一波
17+- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` 当前必须显式带 `path`
18 - `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
19 - `responseMode=sse` 会返回 `text/event-stream`,事件名固定为 `stream_open`、`stream_event`、`stream_end`、`stream_error`
20 - `stream_event` 都带递增 `seq`;失败、超时或取消时会带着已收到的 partial 状态落到 `stream_error`
21 - `GET /v1/browser` 会返回当前浏览器风控默认值和运行中 target/platform 状态摘要,便于观察限流、退避和熔断
22+- `GET /v1/browser` 的 `policy.defaults.stale_lease` 会暴露后台清扫阈值;`policy.targets[]` 会补 `last_activity_*`、`stale_sweep_count`、`last_stale_sweep_*`,便于判断 slot 是否曾被后台自愈回收
23 - `send` / `current` 不是 DOM 自动化,而是通过插件已有的页面内 HTTP 代理完成
24+- ChatGPT raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`
25 - 如果没有活跃 Firefox bridge client,会返回 `503`
26 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
27 - 打开、聚焦、重载标签页等 browser/plugin 管理动作已经移到 [`control-interfaces.md`](./control-interfaces.md) 和 `GET /describe/control`
28@@ -152,6 +154,13 @@ curl -X POST "${BASE_URL}/v1/browser/request" \
29 -d '{"platform":"claude","prompt":"Summarize the current repository status."}'
30 ```
31
32+```bash
33+BASE_URL="http://100.71.210.78:4317"
34+curl -X POST "${BASE_URL}/v1/browser/request" \
35+ -H 'Content-Type: application/json' \
36+ -d '{"platform":"chatgpt","method":"GET","path":"/backend-api/models"}'
37+```
38+
39 ```bash
40 BASE_URL="http://100.71.210.78:4317"
41 curl -X POST "${BASE_URL}/v1/browser/request/cancel" \
42@@ -210,7 +219,7 @@ curl "${BASE_URL}/v1/tasks/${TASK_ID}/logs?limit=50"
43
44 - 业务类接口当前以“只读查询”为主
45 - 浏览器业务面当前以“登录态元数据读面 + 通用 browser request 合同”收口,不把页面对话 UI 当成正式能力
46-- 浏览器 relay 当前只正式支持 Claude,且依赖本地 Firefox bridge 已连接
47+- 浏览器 relay 当前正式支持 Claude 和 ChatGPT;其中 ChatGPT 仅支持显式 `path` 的 raw relay,且依赖本地 Firefox bridge 已连接和有效登录态
48 - `/v1/browser/request/cancel` 已正式接到 Firefox bridge;`responseMode=sse` 会返回真实 `text/event-stream`
49 - `/v1/codex/*` 是少数已经正式开放的业务写接口,但后端固定代理到独立 `codexd`
50 - 控制动作例如 `pause` / `resume` / `drain` 不在本文件讨论范围内
+2,
-1
1@@ -92,11 +92,12 @@ browser/plugin 管理约定:
2 - `ws_reconnect`
3 - `controller_reload`
4 - `tab_restore`
5-- 当前正式平台仍是 `claude`
6+- 当前正式 shell / credential 管理平台已覆盖 `claude` 和 `chatgpt`;Gemini 仍以空壳页和元数据上报为主
7 - 如果没有活跃 Firefox bridge client,会返回 `503`
8 - 如果指定了不存在的 `clientId`,会返回 `409`
9 - `POST /v1/browser/actions` 会等待插件回传结构化 `action_result`,返回 `accepted` / `completed` / `failed` / `reason` / `target` / `result` / `shell_runtime`
10 - `GET /v1/browser` 会同步暴露当前 `shell_runtime` 和每个 client 最近一次结构化 `action_result`
11+- `GET /v1/browser` 的 `policy` 视图也会带 `stale_lease` 默认阈值,以及 target 级别的 `last_activity_*`、`stale_sweep_count`、`last_stale_sweep_*` 诊断字段
12 - browser 业务请求不在本节;请改读 [`business-interfaces.md`](./business-interfaces.md) 和 `POST /v1/browser/request`
13
14 ### 控制动作
+7,
-7
1@@ -156,14 +156,14 @@
2
3 这条链路的关键边界:
4
5-- 当前正式 relay 平台仍只支持 Claude,但合同本身已经是通用 browser request / cancel / SSE
6+- 当前正式 relay 平台已支持 Claude 和 ChatGPT;其中 Claude 保留 prompt shortcut,ChatGPT 当前只支持显式 path 的 raw relay
7 - `request` / `current` 通过插件已有的页面内 HTTP 代理完成,不是 DOM 自动化
8-- `conductor` 不直接持有原始 Claude 凭证
9+- `conductor` 不直接持有原始平台凭证
10 - `responseMode=sse` 时,浏览器会通过 `stream_open` / `stream_event` / `stream_end` / `stream_error` 回传
11 - `POST /v1/browser/request/cancel` 会向插件下发正式 `request_cancel`
12 - 如果没有活跃 Firefox bridge client,会返回 `503`
13-- 如果 client 还没有 Claude 凭证和 endpoint,会返回 `409`
14-- ChatGPT / Gemini 当前不在正式 HTTP relay 合同里
15+- 如果 client 还没有 Claude 凭证和 endpoint,Claude prompt helper 会返回 `409`
16+- ChatGPT raw relay 同样依赖真实浏览器里已捕获到的有效 header / 登录态;Gemini 仍不在正式 HTTP relay 合同里
17
18 ## 启动和管理页
19
20@@ -195,7 +195,7 @@
21 - 持久化记录在断连和重启后的可读性
22 - `fresh` / `stale` / `lost` 状态变化
23 - 读接口不泄露原始凭证值
24-- 通用 browser actions / request / cancel、正式 SSE 和 Claude legacy wrapper 的最小浏览器本地代发闭环
25+- Claude / ChatGPT 通用 browser request / cancel、正式 SSE 和 Claude legacy wrapper 的最小浏览器本地代发闭环
26
27 随后插件会继续上送:
28
29@@ -292,9 +292,9 @@
30
31 ## 已知限制
32
33-- 当前正式浏览器本地代发 HTTP relay 平台只有 Claude
34-- ChatGPT / Gemini 当前只保留空壳页和元数据上报,不在正式 `/v1/browser/*` relay 合同里
35+- Gemini 当前只保留空壳页和元数据上报,不在正式 `/v1/browser/*` relay 合同里
36 - 必须先在真实 Claude 页面里产生过请求,插件才能学到可用凭证和 `org-id`
37+- ChatGPT 当前没有 Claude 那种 prompt shortcut / conversation helper;正式支持面只覆盖显式 `path` 的 raw request / SSE / cancel
38 - `browser/request`、`claude_send` / `claude_current` 走浏览器本地 HTTP 代理,不会驱动 Claude 页面 DOM,也不会把页面会话历史持久化到 `conductor`
39 - 当前 `/v1/browser/claude/current` 只是辅助回读最近一段 Claude 状态,不提供长期历史合同
40
+10,
-13
1@@ -11,15 +11,13 @@
2 - `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复
3 - 任务文档已统一收口到 `tasks/`
4 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
5-- `T-S001` 到 `T-S025` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
6+- `T-S001` 到 `T-S025`、`T-S027`、`T-S028` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
7
8 ## 当前状态分类
9
10-- `已完成`:`T-S001` 到 `T-S025`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
11+- `已完成`:`T-S001` 到 `T-S025`、`T-S027`、`T-S028`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
12 - `当前 TODO`:
13 - `T-S026` 真实 Firefox 手工 smoke 与验收记录
14- - `T-S027` browser-request-policy stale `inFlight` 自愈清扫
15- - `T-S028` ChatGPT browser relay 正式化
16 - `待处理缺陷`:当前无 open bug backlog(见 `bugs/README.md`)
17 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
18
19@@ -75,13 +73,13 @@
20 7. `2026-03-27` 跟进修复:`BUG-014` 已修复,`ws_reconnect` 的 `action_result.completed` 不再提前为 `true`
21 8. `2026-03-27` 跟进修复:`BUG-013` 已完成回归确认,stream session 结束后会清理 timer,不会再触发多余 timeout / cancel
22 9. `2026-03-27` 跟进修复:`BUG-017` 已修复,buffered 模式的 SSE 响应现在会返回结构化 `events` / `full_text`
23+10. `2026-03-27` 跟进任务:`T-S027` 已完成,browser request policy 已补 stale `inFlight` 自愈清扫、lease 活跃时间追踪和 `GET /v1/browser` 读面诊断字段
24+11. `2026-03-27` 跟进任务:`T-S028` 已完成,`platform=chatgpt` 的 `/v1/browser/request` 现已正式支持 path-based buffered / SSE / cancel,并已补到 automated smoke 与文档
25
26 当前策略:
27
28 - 当前最高优先级剩余任务按顺序是:
29 - `T-S026`:真实 Firefox 手工 smoke 与验收记录
30- - `T-S027`:补 `browser-request-policy` stale `inFlight` 自愈清扫
31- - `T-S028`:收口 ChatGPT browser relay 到正式合同
32 - 当前 bug backlog 单独留在 `bugs/` 目录,不把它和主线需求任务混写
33 - 当前不把大文件拆分当作主线 blocker
34 - 以下重构工作顺延到下一轮专门重构任务:
35@@ -94,7 +92,7 @@
36 ## 当前缺陷 backlog
37
38 - 当前 open bug backlog:无
39-- 当前没有 bug fix 正在主线开发中;当前下一波主线任务顺序是 `T-S026 -> T-S027 -> T-S028`
40+- 当前没有 bug fix 正在主线开发中;当前下一波主线任务顺序是 `T-S026`
41
42 ## 低优先级 TODO
43
44@@ -162,13 +160,12 @@
45 - `pnpm verify:mini` 只收口 on-node 静态检查和运行态探针,不替代会话级 smoke
46 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
47 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
48-- 正式 browser HTTP relay 当前正式验收仍只有 Claude;ChatGPT 已有 bridge / plugin 代码路径,但还没完成正式 `/v1/browser/request` 验收与文档转正;Gemini 继续留在下一波
49+- 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;其中 ChatGPT 收口为显式 `path` 的 raw relay,Gemini 继续留在下一波
50 - 当前 open bug backlog 已清空
51-- 当前主线下一波任务已拆成:
52+- 当前主线下一波任务是:
53 - `T-S026`:真实 Firefox 手工 smoke 与验收记录
54- - `T-S027`:补 `browser-request-policy` stale `inFlight` 自愈清扫
55- - `T-S028`:收口 ChatGPT browser relay 到正式合同
56-- `BUG-012` 这轮修复解决的是“永久挂起”,不是“自动回收泄漏 slot”;如果未来出现长期不恢复的 lease 泄漏,同一 `target` 的请求会超时失败,而不是自动自愈
57+- `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
58+- ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界
59 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
60-- 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> tab_restore -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前唯一环境型残余风险
61+- 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> tab_restore -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前最高优先级环境型残余风险
62 - 当前多个源码文件已超过常规体量,但拆分重构已明确延后到浏览器桥接主线收口之后再做
+32,
-0
1@@ -20,6 +20,10 @@
2 - 提交:`a2b1055`
3 - 开工要求:不要从其他任务分支切出;如需新分支,从当前 `main` 新切
4
5+## 当前状态
6+
7+- `已完成(2026-03-27)`
8+
9 ## 建议分支名
10
11 - `feat/policy-stale-sweeper`
12@@ -134,3 +138,31 @@
13 - 新增了哪些诊断字段
14 - 跑了哪些测试
15 - 还有哪些剩余风险
16+
17+## 实际完成记录(2026-03-27)
18+
19+- 状态:已完成
20+- 实际实现:
21+ - `browser-request-policy` 已改为按 lease 级别记录 `requestId`、`lastActivityAt`、`lastActivityReason`
22+ - 默认增加 `staleLease.idleMs = 300000`、`staleLease.sweepIntervalMs = 30000`
23+ - controller 构造后会启动后台 sweep;`beginRequest()` 入场前也会 opportunistic sweep 一次
24+ - stale 判定为“某个 in-flight lease 距离最近一次 activity 超过 `staleLease.idleMs`”
25+ - stale 回收会删除对应 lease、释放 target slot,并在有 waiter 时直接推进下一个 waiter
26+ - `local-api.ts` 已在 Claude 组织/会话解析、buffered proxy dispatch/response、SSE `stream_open` / `stream_event` / `stream_end` / `stream_error` 等关键阶段调用 `lease.touch(...)`
27+- 新增诊断字段:
28+ - `policy.defaults.stale_lease.idle_ms`
29+ - `policy.defaults.stale_lease.sweep_interval_ms`
30+ - `policy.targets[].last_activity_at`
31+ - `policy.targets[].last_activity_reason`
32+ - `policy.targets[].stale_sweep_count`
33+ - `policy.targets[].last_stale_sweep_at`
34+ - `policy.targets[].last_stale_sweep_idle_ms`
35+ - `policy.targets[].last_stale_sweep_reason`
36+ - `policy.targets[].last_stale_sweep_request_id`
37+- 自动化验证:
38+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
39+ - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
40+- 剩余风险:
41+ - 当前实现是“保守回收明显异常卡死的 slot”,不是通用 tracing 系统
42+ - 如果未来出现“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能因 `5min` idle 阈值被误判;这轮通过高阈值 + `lease.touch(...)` 降低了该风险,但没有把风险降到零
43+ - policy 运行态当前仍是进程内内存态;如果 `conductor` 重启,stale / 限流 / 退避等运行态会一起重置
+31,
-0
1@@ -22,6 +22,10 @@
2 - 提交:`a2b1055`
3 - 开工要求:不要从其他任务分支切出;如需新分支,从当前 `main` 新切
4
5+## 当前状态
6+
7+- `已完成(2026-03-27)`
8+
9 ## 建议分支名
10
11 - `feat/chatgpt-browser-relay`
12@@ -126,6 +130,33 @@
13 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
14 - `git -C /Users/george/code/baa-conductor diff --check`
15
16+## 实施结果
17+
18+- `platform=chatgpt` 的 `/v1/browser/request` 已正式收口到文档和 describe 合同,当前正式支持:
19+ - 显式 `path` 的 `buffered`
20+ - 显式 `path` 的 `sse`
21+ - `POST /v1/browser/request/cancel`
22+- Claude 仍保留原有 prompt shortcut、legacy `/v1/browser/claude/*` 包装和现有 smoke;本轮没有把 ChatGPT 扩成 Claude 风格 prompt helper。
23+- 自动化验证已补到两层:
24+ - `apps/conductor-daemon/src/index.test.js`:补本地 API 合同级 ChatGPT buffered 请求断言,并校验 describe 合同里的正式支持平台
25+ - `tests/browser/browser-control-e2e-smoke.test.mjs`:补 ChatGPT metadata + buffered / SSE / cancel smoke
26+- Firefox 插件侧本轮没有新增行为改动;现有通用 proxy / stream / cancel 路径已经足以支撑 ChatGPT,主要补的是正式合同、测试和文档口径。
27+- Gemini 继续留在下一波,因为这张卡的目标是先把已具备链路基础、且已能被自动化验证的 ChatGPT 收口成正式支持面,避免扩成 `chatgpt + gemini` 大任务。
28+
29+## 自动化验证
30+
31+- `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
32+- `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
33+ - 结果:`35/35` 通过
34+- `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
35+ - 结果:`3/3` 通过
36+
37+## 剩余风险
38+
39+- ChatGPT 仍依赖浏览器里真实捕获到的有效登录态 / header;这轮没有把它做成“无前提可用”的平台
40+- ChatGPT 仍没有 Claude 风格的 prompt shortcut;当前正式支持面仍是显式 `path` 的 raw request / SSE / cancel
41+- 真实 Firefox 手工 smoke 仍未完成;当前自动化覆盖的是 conductor / bridge / plugin stub 层面的合同与语义透传,不等于真机桌面验收
42+
43 ## 交付要求
44
45 完成后请说明:
+10,
-13
1@@ -15,11 +15,9 @@
2
3 ## 状态分类
4
5-- `已完成`:`T-S001` 到 `T-S025`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
6+- `已完成`:`T-S001` 到 `T-S025`、`T-S027`、`T-S028`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
7 - `当前 TODO`:
8 - `T-S026` 真实 Firefox 手工 smoke 与验收记录
9- - `T-S027` browser-request-policy stale `inFlight` 自愈清扫
10- - `T-S028` ChatGPT browser relay 正式化
11 - `待处理缺陷`:当前无 open bug backlog(见 `../bugs/README.md`)
12 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
13
14@@ -62,12 +60,12 @@
15 26. [`T-BUG-011.md`](./T-BUG-011.md):修复 `writeHttpResponse()` 在背压断连下的永久挂起,并补专项测试
16 27. [`T-BUG-012.md`](./T-BUG-012.md):修复 `browser-request-policy` waiter 永久挂起,并补专项测试
17 28. [`T-BUG-014.md`](./T-BUG-014.md):修正 `ws_reconnect` 的 `completed` 语义,并补 smoke 断言
18+29. [`T-S027.md`](./T-S027.md):补 `browser-request-policy` stale `inFlight` 自愈清扫
19+30. [`T-S028.md`](./T-S028.md):收口 ChatGPT browser relay 到正式合同
20
21 ## 已准备的后续任务
22
23 - [`T-S026.md`](./T-S026.md):真实 Firefox 手工 smoke 与验收记录
24-- [`T-S027.md`](./T-S027.md):补 `browser-request-policy` stale `inFlight` 自愈清扫
25-- [`T-S028.md`](./T-S028.md):收口 ChatGPT browser relay 到正式合同
26
27 当前主线已经额外收口:
28
29@@ -83,8 +81,6 @@
30
31 - 当前高优先级剩余任务按顺序是:
32 - [`T-S026.md`](./T-S026.md):真实 Firefox 手工 smoke 与验收记录
33- - [`T-S027.md`](./T-S027.md):补 `browser-request-policy` stale `inFlight` 自愈清扫
34- - [`T-S028.md`](./T-S028.md):收口 ChatGPT browser relay 到正式合同
35 - 当前没有正在执行中的缺陷修复卡;如需继续推进工程改动,优先从残余风险或后续增强项开新卡
36
37 ## 当前主线收口情况
38@@ -111,6 +107,8 @@
39 - `2026-03-27`:`BUG-014` 已修复,`ws_reconnect` 的 `action_result.completed` 现在会正确返回 `false`
40 - `2026-03-27`:`BUG-013` 已完成回归确认,stream session 结束后会清理 timer,不会再触发多余 timeout / cancel
41 - `2026-03-27`:`BUG-017` 已修复,buffered 模式收到 SSE 原始文本时现在会返回结构化 `events` / `full_text`
42+- `2026-03-27`:`T-S027` 已完成,browser request policy 已补 stale `inFlight` 自愈清扫与读面诊断字段
43+- `2026-03-27`:`T-S028` 已完成,`platform=chatgpt` 的 `/v1/browser/request` 现已正式支持 path-based buffered / SSE / cancel,并已补到 automated smoke 与文档
44
45 建议并行关系:
46
47@@ -124,16 +122,15 @@
48 当前已知主线遗留:
49
50 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
51-- 正式 browser HTTP relay 当前正式验收仍只有 Claude;ChatGPT 已有 bridge / plugin 代码路径,但还没有完成正式 `/v1/browser/request` 验收与文档转正;Gemini 继续留在下一波
52+- 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;其中 ChatGPT 收口为显式 `path` 的 raw relay,Gemini 继续留在下一波
53 - 当前 open bug backlog 已清空
54-- 当前主线剩余任务已拆成:
55+- 当前主线剩余任务是:
56 - [`T-S026.md`](./T-S026.md):补真实 Firefox 手工 smoke 与验收记录
57- - [`T-S027.md`](./T-S027.md):补 `browser-request-policy` stale `inFlight` 自愈清扫
58- - [`T-S028.md`](./T-S028.md):收口 ChatGPT browser relay 到正式合同
59-- `BUG-012` 这轮修复解决的是“永久挂起”,不是“自动回收泄漏 slot”;如果未来出现长期不恢复的 lease 泄漏,同一 `target` 的请求会超时失败,而不是自动自愈
60+- `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
61+- ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界,不是 regression
62 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
63 - runtime smoke 仍依赖仓库根已有 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮功能回归
64-- 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> `tab_restore` -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前唯一残余风险
65+- 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> `tab_restore` -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前最高优先级残余风险
66
67 ## 低优先级 TODO
68
1@@ -218,12 +218,38 @@ function assertNoSecretLeak(text, secrets) {
2 }
3 }
4
5+function getShellRuntimeDefaults(platform) {
6+ switch (platform) {
7+ case "chatgpt":
8+ return {
9+ actualUrl: "https://chatgpt.com/c/smoke",
10+ shellUrl: "https://chatgpt.com/",
11+ title: "Smoke ChatGPT"
12+ };
13+ case "gemini":
14+ return {
15+ actualUrl: "https://gemini.google.com/app",
16+ shellUrl: "https://gemini.google.com/",
17+ title: "Smoke Gemini"
18+ };
19+ case "claude":
20+ default:
21+ return {
22+ actualUrl: "https://claude.ai/chats/smoke",
23+ shellUrl: "https://claude.ai/",
24+ title: "Smoke Claude"
25+ };
26+ }
27+}
28+
29 function buildShellRuntime(platform, overrides = {}) {
30+ const defaults = getShellRuntimeDefaults(platform);
31+
32 return {
33 platform,
34 desired: {
35 exists: true,
36- shell_url: "https://claude.ai/",
37+ shell_url: defaults.shellUrl,
38 source: "smoke",
39 reason: "smoke_test",
40 updated_at: 1710000002000,
41@@ -233,8 +259,8 @@ function buildShellRuntime(platform, overrides = {}) {
42 actual: {
43 exists: true,
44 tab_id: 321,
45- url: "https://claude.ai/chats/smoke",
46- title: "Smoke Claude",
47+ url: defaults.actualUrl,
48+ title: defaults.title,
49 window_id: 91,
50 active: true,
51 status: "complete",
52@@ -300,7 +326,7 @@ function sendPluginActionResult(socket, input) {
53 );
54 }
55
56-test("browser control e2e smoke covers metadata read surface plus Claude relay", async () => {
57+test("browser control e2e smoke covers metadata read surface plus Claude and ChatGPT relay", async () => {
58 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
59 const runtime = new ConductorRuntime(
60 {
61@@ -842,6 +868,237 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
62 assert.equal(currentResult.payload.data.messages[1].role, "assistant");
63 assert.equal(currentResult.payload.data.messages[1].content, "Bridge is connected and Claude proxy is ready.");
64 assert.equal(currentResult.payload.data.proxy.status, 200);
65+
66+ client.socket.send(
67+ JSON.stringify({
68+ type: "credentials",
69+ platform: "chatgpt",
70+ account: "smoke@example.com",
71+ credential_fingerprint: "fp-smoke-chatgpt",
72+ freshness: "fresh",
73+ captured_at: 1710000003000,
74+ last_seen_at: 1710000003500,
75+ headers: {
76+ authorization: "Bearer chatgpt-auth-secret",
77+ cookie: "__Secure-next-auth.session-token=chatgpt-session-secret",
78+ "openai-sentinel-chat-requirements-token": "chatgpt-sentinel-secret"
79+ },
80+ shell_runtime: buildShellRuntime("chatgpt"),
81+ timestamp: 1710000003000
82+ })
83+ );
84+ await client.queue.next(
85+ (message) => message.type === "state_snapshot" && message.reason === "credentials"
86+ );
87+
88+ client.socket.send(
89+ JSON.stringify({
90+ type: "api_endpoints",
91+ platform: "chatgpt",
92+ account: "smoke@example.com",
93+ credential_fingerprint: "fp-smoke-chatgpt",
94+ updated_at: 1710000003600,
95+ endpoints: [
96+ "GET /backend-api/models",
97+ "POST /backend-api/conversation"
98+ ],
99+ endpoint_metadata: [
100+ {
101+ method: "GET",
102+ path: "/backend-api/models",
103+ first_seen_at: 1710000003200,
104+ last_seen_at: 1710000003600
105+ }
106+ ],
107+ shell_runtime: buildShellRuntime("chatgpt")
108+ })
109+ );
110+ await client.queue.next(
111+ (message) => message.type === "state_snapshot" && message.reason === "api_endpoints"
112+ );
113+
114+ const chatgptStatus = await fetchJson(`${baseUrl}/v1/browser?platform=chatgpt`);
115+ assert.equal(chatgptStatus.response.status, 200);
116+ assert.equal(chatgptStatus.payload.data.records.length, 1);
117+ assert.equal(chatgptStatus.payload.data.records[0].platform, "chatgpt");
118+ assert.equal(chatgptStatus.payload.data.records[0].live.request_hooks.endpoint_count, 2);
119+ assert.equal(chatgptStatus.payload.data.records[0].live.shell_runtime.platform, "chatgpt");
120+ assert.equal(chatgptStatus.payload.data.records[0].persisted.credential_fingerprint, "fp-smoke-chatgpt");
121+ assert.equal(chatgptStatus.payload.data.summary.status_counts.fresh, 1);
122+ assertNoSecretLeak(chatgptStatus.text, [
123+ "chatgpt-auth-secret",
124+ "chatgpt-session-secret",
125+ "chatgpt-sentinel-secret"
126+ ]);
127+
128+ const chatgptBufferedResultPromise = fetchJson(`${baseUrl}/v1/browser/request`, {
129+ method: "POST",
130+ headers: {
131+ "content-type": "application/json"
132+ },
133+ body: JSON.stringify({
134+ platform: "chatgpt",
135+ method: "GET",
136+ path: "/backend-api/models",
137+ requestId: "chatgpt-buffered-smoke"
138+ })
139+ });
140+
141+ const chatgptBufferedRequest = await client.queue.next(
142+ (message) => message.type === "api_request" && message.id === "chatgpt-buffered-smoke"
143+ );
144+ assert.equal(chatgptBufferedRequest.platform, "chatgpt");
145+ assert.equal(chatgptBufferedRequest.method, "GET");
146+ assert.equal(chatgptBufferedRequest.path, "/backend-api/models");
147+
148+ client.socket.send(
149+ JSON.stringify({
150+ type: "api_response",
151+ id: "chatgpt-buffered-smoke",
152+ ok: true,
153+ status: 200,
154+ body: {
155+ models: [
156+ {
157+ slug: "gpt-5.4"
158+ }
159+ ]
160+ }
161+ })
162+ );
163+
164+ const chatgptBufferedResult = await chatgptBufferedResultPromise;
165+ assert.equal(chatgptBufferedResult.response.status, 200);
166+ assert.equal(chatgptBufferedResult.payload.data.request_mode, "api_request");
167+ assert.equal(chatgptBufferedResult.payload.data.proxy.path, "/backend-api/models");
168+ assert.equal(chatgptBufferedResult.payload.data.response.models[0].slug, "gpt-5.4");
169+
170+ const chatgptStreamPromise = fetchText(`${baseUrl}/v1/browser/request`, {
171+ method: "POST",
172+ headers: {
173+ "content-type": "application/json"
174+ },
175+ body: JSON.stringify({
176+ platform: "chatgpt",
177+ method: "POST",
178+ path: "/backend-api/conversation",
179+ requestBody: {
180+ prompt: "Stream ChatGPT bridge state."
181+ },
182+ requestId: "chatgpt-stream-smoke",
183+ responseMode: "sse"
184+ })
185+ });
186+
187+ const chatgptStreamRequest = await client.queue.next(
188+ (message) => message.type === "api_request" && message.id === "chatgpt-stream-smoke"
189+ );
190+ assert.equal(chatgptStreamRequest.platform, "chatgpt");
191+ assert.equal(chatgptStreamRequest.method, "POST");
192+ assert.equal(chatgptStreamRequest.path, "/backend-api/conversation");
193+ assert.equal(chatgptStreamRequest.response_mode, "sse");
194+
195+ client.socket.send(
196+ JSON.stringify({
197+ type: "stream_open",
198+ id: "chatgpt-stream-smoke",
199+ stream_id: "chatgpt-stream-smoke",
200+ status: 200,
201+ meta: {
202+ source: "smoke-chatgpt"
203+ }
204+ })
205+ );
206+ client.socket.send(
207+ JSON.stringify({
208+ type: "stream_event",
209+ id: "chatgpt-stream-smoke",
210+ stream_id: "chatgpt-stream-smoke",
211+ seq: 1,
212+ event: "message",
213+ data: {
214+ delta: "ChatGPT is streaming."
215+ },
216+ raw: 'data: {"delta":"ChatGPT is streaming."}'
217+ })
218+ );
219+ client.socket.send(
220+ JSON.stringify({
221+ type: "stream_end",
222+ id: "chatgpt-stream-smoke",
223+ stream_id: "chatgpt-stream-smoke",
224+ status: 200
225+ })
226+ );
227+
228+ const chatgptStreamResult = await chatgptStreamPromise;
229+ assert.equal(chatgptStreamResult.response.status, 200);
230+ assert.equal(
231+ chatgptStreamResult.response.headers.get("content-type"),
232+ "text/event-stream; charset=utf-8"
233+ );
234+ const chatgptStreamFrames = parseSseFrames(chatgptStreamResult.text);
235+ assert.deepEqual(
236+ chatgptStreamFrames.map((frame) => frame.event),
237+ ["stream_open", "stream_event", "stream_end"]
238+ );
239+ assert.equal(chatgptStreamFrames[0].data.request_id, "chatgpt-stream-smoke");
240+ assert.equal(chatgptStreamFrames[1].data.seq, 1);
241+ assert.equal(chatgptStreamFrames[1].data.data.delta, "ChatGPT is streaming.");
242+
243+ const chatgptCancelableRequestPromise = fetchJson(`${baseUrl}/v1/browser/request`, {
244+ method: "POST",
245+ headers: {
246+ "content-type": "application/json"
247+ },
248+ body: JSON.stringify({
249+ platform: "chatgpt",
250+ method: "GET",
251+ path: "/backend-api/models",
252+ requestId: "chatgpt-cancel-smoke"
253+ })
254+ });
255+
256+ const chatgptCancelableRequest = await client.queue.next(
257+ (message) => message.type === "api_request" && message.id === "chatgpt-cancel-smoke"
258+ );
259+ assert.equal(chatgptCancelableRequest.platform, "chatgpt");
260+ assert.equal(chatgptCancelableRequest.path, "/backend-api/models");
261+
262+ const chatgptCancelResult = await fetchJson(`${baseUrl}/v1/browser/request/cancel`, {
263+ method: "POST",
264+ headers: {
265+ "content-type": "application/json"
266+ },
267+ body: JSON.stringify({
268+ platform: "chatgpt",
269+ requestId: "chatgpt-cancel-smoke",
270+ reason: "smoke-test"
271+ })
272+ });
273+ assert.equal(chatgptCancelResult.response.status, 200);
274+ assert.equal(chatgptCancelResult.payload.data.status, "cancel_requested");
275+ assert.equal(chatgptCancelResult.payload.data.type, "request_cancel");
276+
277+ const chatgptCancelMessage = await client.queue.next(
278+ (message) => message.type === "request_cancel" && message.id === "chatgpt-cancel-smoke"
279+ );
280+ assert.equal(chatgptCancelMessage.platform, "chatgpt");
281+ assert.equal(chatgptCancelMessage.reason, "smoke-test");
282+
283+ client.socket.send(
284+ JSON.stringify({
285+ type: "api_response",
286+ id: "chatgpt-cancel-smoke",
287+ ok: false,
288+ status: 499,
289+ error: "browser_request_cancelled"
290+ })
291+ );
292+
293+ const chatgptCancelledRequestResult = await chatgptCancelableRequestPromise;
294+ assert.equal(chatgptCancelledRequestResult.response.status, 499);
295+ assert.equal(chatgptCancelledRequestResult.payload.error, "browser_upstream_error");
296 } finally {
297 client?.queue.stop();
298