baa-conductor

git clone 

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
M PROGRESS/2026-03-27-current-code-progress.md
+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 尚未正式化,以及风控运行态仍是进程内内存态。
M apps/conductor-daemon/src/browser-request-policy.ts
+262, -15
  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 
M apps/conductor-daemon/src/index.test.js
+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 },
M apps/conductor-daemon/src/local-api.ts
+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     });
M docs/api/README.md
+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 
M docs/api/business-interfaces.md
+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` 不在本文件讨论范围内
M docs/api/control-interfaces.md
+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 ### 控制动作
M docs/firefox/README.md
+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 
M plans/STATUS_SUMMARY.md
+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 - 当前多个源码文件已超过常规体量,但拆分重构已明确延后到浏览器桥接主线收口之后再做
M tasks/T-S027.md
+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 / 限流 / 退避等运行态会一起重置
M tasks/T-S028.md
+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 完成后请说明:
M tasks/TASK_OVERVIEW.md
+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 
M tests/browser/browser-control-e2e-smoke.test.mjs
+261, -4
  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