codex@macbookpro
·
2026-04-03
local-api.ts
1import * as nodeFs from "node:fs";
2import { randomUUID } from "node:crypto";
3import * as nodePath from "node:path";
4import { fileURLToPath } from "node:url";
5import {
6 isBrowserSessionRole,
7 type AuthPrincipal,
8 type BrowserSessionRole
9} from "../../../packages/auth/dist/index.js";
10import {
11 buildArtifactRelativePath,
12 buildArtifactPublicUrl,
13 getArtifactContentType,
14 type ArtifactStore,
15 type ConversationAutomationStatus,
16 type ConversationPauseReason,
17 type RenewalJobRecord,
18 type RenewalJobStatus
19} from "../../../packages/artifact-db/dist/index.js";
20import {
21 AUTOMATION_STATE_KEY,
22 DEFAULT_AUTOMATION_MODE,
23 TASK_STATUS_VALUES,
24 parseJsonText,
25 type AutomationMode,
26 type BrowserEndpointMetadataRecord,
27 type BrowserLoginStateRecord,
28 type BrowserLoginStateStatus,
29 type ControlPlaneRepository,
30 type ControllerRecord,
31 type JsonObject,
32 type JsonValue,
33 type TaskLogRecord,
34 type TaskRecord,
35 type TaskRunRecord,
36 type TaskStatus
37} from "../../../packages/db/dist/index.js";
38import {
39 DEFAULT_EXEC_MAX_BUFFER_BYTES,
40 DEFAULT_EXEC_TIMEOUT_MS,
41 executeCommand,
42 readTextFile,
43 writeTextFile,
44 type ExecOperationResponse,
45 type ExecOperationResult,
46 type ExecOperationRequest,
47 type FileReadOperationRequest,
48 type FileWriteOperationRequest
49} from "../../../packages/host-ops/dist/index.js";
50// @ts-ignore conductor reuses the built status-api snapshot normalizer directly.
51import { createStatusSnapshotFromControlApiPayload } from "../../status-api/dist/apps/status-api/src/data-source.js";
52// @ts-ignore conductor reuses the built status-api HTML renderer directly.
53import { renderStatusPage } from "../../status-api/dist/apps/status-api/src/render.js";
54
55import {
56 binaryResponse,
57 jsonResponse,
58 textResponse,
59 type ConductorHttpRequest,
60 type ConductorHttpResponse
61} from "./http-types.js";
62import type {
63 BrowserBridgeActionResultItemSnapshot,
64 BrowserBridgeActionResultSnapshot,
65 BrowserBridgeApiStream,
66 BrowserBridgeApiResponse,
67 BrowserBridgeClientSnapshot,
68 BrowserBridgeController,
69 BrowserBridgeCredentialSnapshot,
70 BrowserBridgeFinalMessageSnapshot,
71 BrowserBridgeRequestHookSnapshot,
72 BrowserBridgeShellRuntimeSnapshot,
73 BrowserBridgeStreamEvent,
74 BrowserBridgeStateSnapshot
75} from "./browser-types.js";
76import {
77 BrowserRequestPolicyController,
78 BrowserRequestPolicyError,
79 createDefaultBrowserRequestPolicyConfig,
80 type BrowserRequestAdmission,
81 type BrowserRequestPolicyLease
82} from "./browser-request-policy.js";
83import type { BaaBrowserDeliveryBridge } from "./artifacts/upload-session.js";
84import { DEFAULT_BROWSER_PROXY_TIMEOUT_MS } from "./execution-timeouts.js";
85import {
86 RenewalConversationNotFoundError,
87 getRenewalConversationDetail,
88 listRenewalConversationDetails,
89 setRenewalConversationAutomationStatus
90} from "./renewal/conversations.js";
91import type { BaaInstructionPolicyConfig } from "./instructions/policy.js";
92import {
93 UI_SESSION_COOKIE_NAME,
94 UiSessionManager,
95 type UiSessionRecord
96} from "./ui-session.js";
97
98interface FileStatsLike {
99 isDirectory(): boolean;
100}
101
102const { readFileSync } = nodeFs;
103const { join, resolve } = nodePath;
104const readdirSync = (nodeFs as unknown as { readdirSync(path: string): string[] }).readdirSync;
105const realpathSync = (nodeFs as unknown as { realpathSync(path: string): string }).realpathSync;
106const statSync = (nodeFs as unknown as { statSync(path: string): FileStatsLike }).statSync;
107const extname = (nodePath as unknown as { extname(path: string): string }).extname;
108const relative = (nodePath as unknown as { relative(from: string, to: string): string }).relative;
109
110const DEFAULT_LIST_LIMIT = 20;
111const DEFAULT_LOG_LIMIT = 200;
112const MAX_LIST_LIMIT = 100;
113const MAX_LOG_LIMIT = 500;
114const DEFAULT_BROWSER_REQUEST_POLICY_CONFIG = createDefaultBrowserRequestPolicyConfig();
115const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
116const BROWSER_LOGIN_STATUS_SET = new Set<BrowserLoginStateStatus>(["fresh", "stale", "lost"]);
117const CODEXD_LOCAL_API_ENV = "BAA_CODEXD_LOCAL_API_BASE";
118const CLAUDE_CODED_LOCAL_API_ENV = "BAA_CLAUDE_CODED_LOCAL_API_BASE";
119const CODEX_ROUTE_IDS = new Set([
120 "codex.status",
121 "codex.sessions.list",
122 "codex.sessions.read",
123 "codex.sessions.create",
124 "codex.turn.create"
125]);
126const CLAUDE_CODED_ROUTE_IDS = new Set([
127 "claude-coded.status",
128 "claude-coded.ask"
129]);
130const HOST_OPERATIONS_ROUTE_IDS = new Set(["host.exec", "host.files.read", "host.files.write"]);
131const HOST_OPERATIONS_AUTH_HEADER = "Authorization: Bearer <BAA_SHARED_TOKEN>";
132const HOST_OPERATIONS_WWW_AUTHENTICATE = 'Bearer realm="baa-conductor-host-ops"';
133const DEFAULT_CODE_ROOT_DIR = "/Users/george/code/";
134const DEFAULT_CONDUCTOR_UI_DIST_DIR = resolve(fileURLToPath(new URL("../../conductor-ui/dist/", import.meta.url)));
135const CODE_ROUTE_CONTENT_TYPE = "text/plain; charset=utf-8";
136const STATUS_VIEW_HTML_HEADERS = {
137 "cache-control": "no-store",
138 "content-type": "text/html; charset=utf-8"
139} as const;
140const CONDUCTOR_UI_ENTRY_FILE = "index.html";
141const CONDUCTOR_UI_HTML_HEADERS = {
142 "cache-control": "no-store",
143 "content-type": "text/html; charset=utf-8"
144} as const;
145const CONDUCTOR_UI_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
146const CONDUCTOR_UI_ASSET_CONTENT_TYPES: Record<string, string> = {
147 ".css": "text/css; charset=utf-8",
148 ".ico": "image/x-icon",
149 ".jpeg": "image/jpeg",
150 ".jpg": "image/jpeg",
151 ".js": "text/javascript; charset=utf-8",
152 ".json": "application/json; charset=utf-8",
153 ".map": "application/json; charset=utf-8",
154 ".png": "image/png",
155 ".svg": "image/svg+xml",
156 ".ttf": "font/ttf",
157 ".txt": "text/plain; charset=utf-8",
158 ".webp": "image/webp",
159 ".woff": "font/woff",
160 ".woff2": "font/woff2"
161};
162const ROBOTS_TXT_BODY = "User-agent: *\nAllow: /artifact/";
163const SSE_RESPONSE_HEADERS = {
164 "cache-control": "no-store",
165 "content-type": "text/event-stream; charset=utf-8"
166} as const;
167const ALLOWED_ARTIFACT_SCOPES = new Set(["exec", "msg", "session"]);
168const BLOCKED_CODE_FILE_NAMES = new Set([".credentials", ".env"]);
169const BLOCKED_CODE_PATH_SEGMENTS = new Set([".git"]);
170const BLOCKED_CODE_BINARY_EXTENSIONS = new Set([
171 ".7z",
172 ".a",
173 ".avi",
174 ".class",
175 ".db",
176 ".dll",
177 ".dylib",
178 ".eot",
179 ".exe",
180 ".gif",
181 ".gz",
182 ".ico",
183 ".jar",
184 ".jpeg",
185 ".jpg",
186 ".mkv",
187 ".mov",
188 ".mp3",
189 ".mp4",
190 ".o",
191 ".otf",
192 ".pdf",
193 ".png",
194 ".pyc",
195 ".rar",
196 ".so",
197 ".sqlite",
198 ".tar",
199 ".tgz",
200 ".ttf",
201 ".war",
202 ".webp",
203 ".woff",
204 ".woff2",
205 ".zip"
206]);
207const ARTIFACT_FILE_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
208const RECENT_SESSIONS_TXT_ARTIFACT_PATH = buildArtifactRelativePath("session", "latest.txt");
209const RECENT_SESSIONS_JSON_ARTIFACT_PATH = buildArtifactRelativePath("session", "latest.json");
210const BROWSER_CLAUDE_PLATFORM = "claude";
211const BROWSER_CLAUDE_ROOT_URL = "https://claude.ai/";
212const BROWSER_CLAUDE_ORGANIZATIONS_PATH = "/api/organizations";
213const BROWSER_CLAUDE_CONVERSATIONS_PATH = "/api/organizations/{id}/chat_conversations";
214const BROWSER_CLAUDE_CONVERSATION_PATH = "/api/organizations/{id}/chat_conversations/{id}";
215const BROWSER_CLAUDE_COMPLETION_PATH = "/api/organizations/{id}/chat_conversations/{id}/completion";
216const BROWSER_CHATGPT_PLATFORM = "chatgpt";
217const BROWSER_CHATGPT_ROOT_URL = "https://chatgpt.com/";
218const BROWSER_CHATGPT_CONVERSATION_PATH = "/backend-api/conversation";
219const BROWSER_GEMINI_PLATFORM = "gemini";
220const BROWSER_GEMINI_ROOT_URL = "https://gemini.google.com/";
221const BROWSER_GEMINI_STREAM_GENERATE_PATH =
222 "/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
223const SUPPORTED_BROWSER_ACTIONS = [
224 "controller_reload",
225 "plugin_status",
226 "request_credentials",
227 "tab_focus",
228 "tab_open",
229 "tab_reload",
230 "tab_restore",
231 "ws_reconnect"
232] as const;
233const RESERVED_BROWSER_ACTIONS = [] as const;
234const FORMAL_BROWSER_SHELL_PLATFORMS = ["claude", "chatgpt"] as const;
235const FORMAL_BROWSER_REQUEST_PLATFORMS = ["claude", "chatgpt", "gemini"] as const;
236const SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES = ["buffered", "sse"] as const;
237const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = [] as const;
238const MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS = 60_000;
239const MAX_BROWSER_WS_RECONNECT_REPEAT_COUNT = 20;
240const MAX_BROWSER_WS_RECONNECT_REPEAT_INTERVAL_MS = 60_000;
241const RENEWAL_AUTOMATION_STATUS_SET = new Set<ConversationAutomationStatus>(["manual", "auto", "paused"]);
242const RENEWAL_PAUSE_REASON_SET = new Set<ConversationPauseReason>([
243 "ai_pause",
244 "error_loop",
245 "execution_failure",
246 "repeated_message",
247 "repeated_renewal",
248 "rescue_wait",
249 "system_pause",
250 "user_pause"
251]);
252const RENEWAL_JOB_STATUS_SET = new Set<RenewalJobStatus>(["pending", "running", "done", "failed"]);
253
254type LocalApiRouteMethod = "GET" | "POST";
255type LocalApiRouteKind = "probe" | "read" | "write";
256type LocalApiDescribeSurface = "business" | "control";
257type LocalApiRouteLifecycle = "legacy" | "stable";
258type BrowserActionName =
259 | "controller_reload"
260 | "plugin_status"
261 | "request_credentials"
262 | "tab_focus"
263 | "tab_open"
264 | "tab_reload"
265 | "tab_restore"
266 | "ws_reconnect";
267type BrowserRequestResponseMode = "buffered" | "sse";
268type BrowserRecordView = "active_and_persisted" | "active_only" | "persisted_only";
269type SharedTokenAuthFailureReason =
270 | "empty_bearer_token"
271 | "invalid_authorization_scheme"
272 | "invalid_token"
273 | "missing_authorization_header";
274
275interface LocalApiRouteDefinition {
276 id: string;
277 exposeInDescribe?: boolean;
278 kind: LocalApiRouteKind;
279 legacyReplacementPath?: string;
280 lifecycle?: LocalApiRouteLifecycle;
281 method: LocalApiRouteMethod;
282 pathPattern: string;
283 summary: string;
284}
285
286interface LocalApiRouteMatch {
287 params: Record<string, string>;
288 route: LocalApiRouteDefinition;
289}
290
291interface BrowserStatusFilters {
292 account?: string;
293 browser?: string;
294 clientId?: string;
295 host?: string;
296 limit: number;
297 platform?: string;
298 status?: BrowserLoginStateStatus;
299}
300
301interface BrowserMergedRecord {
302 account: string | null;
303 activeConnection: BrowserBridgeClientSnapshot | null;
304 browser: string | null;
305 clientId: string;
306 credential: BrowserBridgeCredentialSnapshot | null;
307 endpointMetadata: BrowserEndpointMetadataRecord | null;
308 host: string | null;
309 persistedLoginState: BrowserLoginStateRecord | null;
310 platform: string;
311 requestHook: BrowserBridgeRequestHookSnapshot | null;
312 shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
313 view: BrowserRecordView;
314}
315
316type UpstreamSuccessEnvelope = JsonObject & {
317 data: JsonValue;
318 ok: true;
319};
320
321type UpstreamErrorEnvelope = JsonObject & {
322 details?: JsonValue;
323 error: string;
324 message: string;
325 ok: false;
326};
327
328interface LocalApiRequestContext {
329 artifactStore: ArtifactStore | null;
330 claudeCodedLocalApiBase: string | null;
331 codeRootDir: string;
332 deliveryBridge: BaaBrowserDeliveryBridge | null;
333 browserBridge: BrowserBridgeController | null;
334 browserRequestPolicy: BrowserRequestPolicyController | null;
335 browserStateLoader: () => BrowserBridgeStateSnapshot | null;
336 codexdLocalApiBase: string | null;
337 fetchImpl: typeof fetch;
338 now: () => number;
339 params: Record<string, string>;
340 repository: ControlPlaneRepository | null;
341 request: ConductorHttpRequest;
342 requestId: string;
343 sharedToken: string | null;
344 snapshotLoader: () => ConductorRuntimeApiSnapshot;
345 uiDistDir: string;
346 uiSessionManager: UiSessionManager;
347 url: URL;
348}
349
350export interface ConductorRuntimeApiSnapshot {
351 claudeCoded: {
352 localApiBase: string | null;
353 };
354 codexd: {
355 localApiBase: string | null;
356 };
357 controlApi: {
358 baseUrl: string;
359 browserWsUrl?: string | null;
360 firefoxWsUrl?: string | null;
361 hasSharedToken: boolean;
362 localApiBase: string | null;
363 usesPlaceholderToken: boolean;
364 };
365 daemon: {
366 currentLeaderId: string | null;
367 currentTerm: number | null;
368 host: string;
369 lastError: string | null;
370 leaseExpiresAt: number | null;
371 leaseState: string;
372 nodeId: string;
373 role: string;
374 schedulerEnabled: boolean;
375 };
376 identity: string;
377 runtime: {
378 pid: number | null;
379 started: boolean;
380 startedAt: number;
381 };
382 warnings: string[];
383}
384
385export interface ConductorLocalApiContext {
386 artifactStore?: ArtifactStore | null;
387 claudeCodedLocalApiBase?: string | null;
388 codeRootDir?: string | null;
389 deliveryBridge?: BaaBrowserDeliveryBridge | null;
390 browserBridge?: BrowserBridgeController | null;
391 browserRequestPolicy?: BrowserRequestPolicyController | null;
392 browserStateLoader?: (() => BrowserBridgeStateSnapshot | null) | null;
393 codexdLocalApiBase?: string | null;
394 fetchImpl?: typeof fetch;
395 instructionPolicy?: BaaInstructionPolicyConfig | null;
396 now?: () => number;
397 repository: ControlPlaneRepository | null;
398 sharedToken?: string | null;
399 snapshotLoader: () => ConductorRuntimeApiSnapshot;
400 uiDistDir?: string | null;
401 uiSessionManager?: UiSessionManager | null;
402 version?: string | null;
403}
404
405class LocalApiHttpError extends Error {
406 constructor(
407 readonly status: number,
408 readonly error: string,
409 message: string,
410 readonly details?: JsonValue,
411 readonly headers?: Record<string, string>
412 ) {
413 super(message);
414 }
415}
416
417const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
418 {
419 id: "probe.healthz",
420 exposeInDescribe: false,
421 kind: "probe",
422 method: "GET",
423 pathPattern: "/healthz",
424 summary: "最小 TCP/HTTP 健康探针"
425 },
426 {
427 id: "probe.readyz",
428 exposeInDescribe: false,
429 kind: "probe",
430 method: "GET",
431 pathPattern: "/readyz",
432 summary: "本地 runtime readiness 探针"
433 },
434 {
435 id: "probe.rolez",
436 exposeInDescribe: false,
437 kind: "probe",
438 method: "GET",
439 pathPattern: "/rolez",
440 summary: "当前 leader/standby 视图"
441 },
442 {
443 id: "probe.runtime",
444 exposeInDescribe: false,
445 kind: "probe",
446 method: "GET",
447 pathPattern: "/v1/runtime",
448 summary: "当前 runtime 快照"
449 },
450 {
451 id: "service.describe",
452 kind: "read",
453 method: "GET",
454 pathPattern: "/describe",
455 summary: "读取 describe 入口索引"
456 },
457 {
458 id: "service.describe.business",
459 kind: "read",
460 method: "GET",
461 pathPattern: "/describe/business",
462 summary: "读取业务类自描述 JSON"
463 },
464 {
465 id: "service.describe.control",
466 kind: "read",
467 method: "GET",
468 pathPattern: "/describe/control",
469 summary: "读取控制类自描述 JSON"
470 },
471 {
472 id: "service.robots",
473 exposeInDescribe: false,
474 kind: "read",
475 method: "GET",
476 pathPattern: "/robots.txt",
477 summary: "返回允许 AI 访问 /artifact/ 的 robots.txt"
478 },
479 {
480 id: "service.artifact.read",
481 exposeInDescribe: false,
482 kind: "read",
483 method: "GET",
484 pathPattern: "/artifact/:artifact_scope/:artifact_file",
485 summary: "读取 artifact 静态文件"
486 },
487 {
488 id: "service.app.shell",
489 exposeInDescribe: false,
490 kind: "read",
491 method: "GET",
492 pathPattern: "/app",
493 summary: "读取 conductor-ui SPA 入口"
494 },
495 {
496 id: "service.app.assets",
497 exposeInDescribe: false,
498 kind: "read",
499 method: "GET",
500 pathPattern: "/app/assets/:app_asset_path*",
501 summary: "读取 conductor-ui 构建后的静态资源"
502 },
503 {
504 id: "service.app.history",
505 exposeInDescribe: false,
506 kind: "read",
507 method: "GET",
508 pathPattern: "/app/:app_path*",
509 summary: "为 conductor-ui 提供 SPA history fallback"
510 },
511 {
512 id: "ui.session.me",
513 exposeInDescribe: false,
514 kind: "read",
515 method: "GET",
516 pathPattern: "/v1/ui/session/me",
517 summary: "读取当前 Web UI 会话与可登录角色"
518 },
519 {
520 id: "ui.session.login",
521 exposeInDescribe: false,
522 kind: "write",
523 method: "POST",
524 pathPattern: "/v1/ui/session/login",
525 summary: "创建当前 Web UI session cookie"
526 },
527 {
528 id: "ui.session.logout",
529 exposeInDescribe: false,
530 kind: "write",
531 method: "POST",
532 pathPattern: "/v1/ui/session/logout",
533 summary: "销毁当前 Web UI session cookie"
534 },
535 {
536 id: "service.code.read",
537 kind: "read",
538 method: "GET",
539 pathPattern: "/code/:code_path*",
540 summary: "读取代码根目录下的文本源码文件,目录请求返回纯文本列表"
541 },
542 {
543 id: "service.health",
544 kind: "read",
545 method: "GET",
546 pathPattern: "/health",
547 summary: "读取服务健康摘要"
548 },
549 {
550 id: "service.version",
551 kind: "read",
552 method: "GET",
553 pathPattern: "/version",
554 summary: "读取服务版本"
555 },
556 {
557 id: "system.capabilities",
558 kind: "read",
559 method: "GET",
560 pathPattern: "/v1/capabilities",
561 summary: "读取能力发现摘要"
562 },
563 {
564 id: "codex.status",
565 kind: "read",
566 method: "GET",
567 pathPattern: "/v1/codex",
568 summary: "读取独立 codexd 代理状态与会话能力摘要"
569 },
570 {
571 id: "codex.sessions.list",
572 kind: "read",
573 method: "GET",
574 pathPattern: "/v1/codex/sessions",
575 summary: "列出独立 codexd 当前会话"
576 },
577 {
578 id: "codex.sessions.read",
579 kind: "read",
580 method: "GET",
581 pathPattern: "/v1/codex/sessions/:session_id",
582 summary: "读取独立 codexd 的单个会话"
583 },
584 {
585 id: "codex.sessions.create",
586 kind: "write",
587 method: "POST",
588 pathPattern: "/v1/codex/sessions",
589 summary: "通过 conductor 代理创建独立 codexd 会话"
590 },
591 {
592 id: "codex.turn.create",
593 kind: "write",
594 method: "POST",
595 pathPattern: "/v1/codex/turn",
596 summary: "向独立 codexd 会话提交 turn"
597 },
598 {
599 id: "claude-coded.status",
600 kind: "read",
601 method: "GET",
602 pathPattern: "/v1/claude-coded",
603 summary: "读取独立 claude-coded 代理状态摘要"
604 },
605 {
606 id: "claude-coded.ask",
607 kind: "write",
608 method: "POST",
609 pathPattern: "/v1/claude-coded/ask",
610 summary: "通过 conductor 代理向 claude-coded 提交 prompt"
611 },
612 {
613 id: "browser.status",
614 kind: "read",
615 method: "GET",
616 pathPattern: "/v1/browser",
617 summary: "读取本地浏览器 bridge、插件在线状态与登录态摘要"
618 },
619 {
620 id: "browser.actions",
621 kind: "write",
622 method: "POST",
623 pathPattern: "/v1/browser/actions",
624 summary: "派发通用 browser/plugin 管理动作"
625 },
626 {
627 id: "browser.request",
628 kind: "write",
629 method: "POST",
630 pathPattern: "/v1/browser/request",
631 summary: "发起通用 browser HTTP 代发请求"
632 },
633 {
634 id: "browser.request.cancel",
635 kind: "write",
636 method: "POST",
637 pathPattern: "/v1/browser/request/cancel",
638 summary: "取消通用 browser 请求或流"
639 },
640 {
641 id: "browser.claude.open",
642 kind: "write",
643 legacyReplacementPath: "/v1/browser/actions",
644 lifecycle: "legacy",
645 method: "POST",
646 pathPattern: "/v1/browser/claude/open",
647 summary: "legacy 包装:打开或聚焦 Claude 标签页"
648 },
649 {
650 id: "browser.claude.send",
651 kind: "write",
652 legacyReplacementPath: "/v1/browser/request",
653 lifecycle: "legacy",
654 method: "POST",
655 pathPattern: "/v1/browser/claude/send",
656 summary: "legacy 包装:通过本地 browser bridge 发起一轮 Claude 对话"
657 },
658 {
659 id: "browser.claude.current",
660 kind: "read",
661 lifecycle: "legacy",
662 method: "GET",
663 pathPattern: "/v1/browser/claude/current",
664 summary: "legacy 辅助读:读取当前 Claude 对话内容与页面代理状态"
665 },
666 {
667 id: "browser.chatgpt.send",
668 kind: "write",
669 legacyReplacementPath: "/v1/browser/request",
670 lifecycle: "legacy",
671 method: "POST",
672 pathPattern: "/v1/browser/chatgpt/send",
673 summary: "legacy 包装:通过本地 browser bridge 发起一轮 ChatGPT 对话"
674 },
675 {
676 id: "browser.chatgpt.current",
677 kind: "read",
678 lifecycle: "legacy",
679 method: "GET",
680 pathPattern: "/v1/browser/chatgpt/current",
681 summary: "legacy 辅助读:读取当前 ChatGPT 对话状态与页面代理信息"
682 },
683 {
684 id: "browser.gemini.send",
685 kind: "write",
686 legacyReplacementPath: "/v1/browser/request",
687 lifecycle: "legacy",
688 method: "POST",
689 pathPattern: "/v1/browser/gemini/send",
690 summary: "legacy 包装:通过本地 browser bridge 发起一轮 Gemini 对话"
691 },
692 {
693 id: "browser.gemini.current",
694 kind: "read",
695 lifecycle: "legacy",
696 method: "GET",
697 pathPattern: "/v1/browser/gemini/current",
698 summary: "legacy 辅助读:读取当前 Gemini 对话状态与页面代理信息"
699 },
700 {
701 id: "browser.claude.reload",
702 kind: "write",
703 legacyReplacementPath: "/v1/browser/actions",
704 lifecycle: "legacy",
705 method: "POST",
706 pathPattern: "/v1/browser/claude/reload",
707 summary: "legacy 包装:请求当前 Claude 浏览器 bridge 页面重载"
708 },
709 {
710 id: "system.state",
711 kind: "read",
712 method: "GET",
713 pathPattern: "/v1/system/state",
714 summary: "读取本地系统状态"
715 },
716 {
717 id: "status.view.json",
718 kind: "read",
719 method: "GET",
720 pathPattern: "/v1/status",
721 summary: "读取兼容 status-api 的只读 JSON 状态视图"
722 },
723 {
724 id: "status.view.ui",
725 kind: "read",
726 method: "GET",
727 pathPattern: "/v1/status/ui",
728 summary: "读取兼容 status-api 的只读 HTML 状态面板"
729 },
730 {
731 id: "system.pause",
732 kind: "write",
733 method: "POST",
734 pathPattern: "/v1/system/pause",
735 summary: "把 automation 切到 paused"
736 },
737 {
738 id: "system.resume",
739 kind: "write",
740 method: "POST",
741 pathPattern: "/v1/system/resume",
742 summary: "把 automation 切到 running"
743 },
744 {
745 id: "system.drain",
746 kind: "write",
747 method: "POST",
748 pathPattern: "/v1/system/drain",
749 summary: "把 automation 切到 draining"
750 },
751 {
752 id: "host.exec",
753 kind: "write",
754 method: "POST",
755 pathPattern: "/v1/exec",
756 summary: "执行本机 shell 命令并返回结构化 stdout/stderr"
757 },
758 {
759 id: "host.files.read",
760 kind: "read",
761 method: "POST",
762 pathPattern: "/v1/files/read",
763 summary: "读取本机文本文件并返回结构化内容与元数据"
764 },
765 {
766 id: "host.files.write",
767 kind: "write",
768 method: "POST",
769 pathPattern: "/v1/files/write",
770 summary: "写入本机文本文件并返回结构化结果"
771 },
772 {
773 id: "controllers.list",
774 kind: "read",
775 method: "GET",
776 pathPattern: "/v1/controllers",
777 summary: "列出本地 controller 摘要"
778 },
779 {
780 id: "tasks.list",
781 kind: "read",
782 method: "GET",
783 pathPattern: "/v1/tasks",
784 summary: "列出本地 task 摘要"
785 },
786 {
787 id: "tasks.read",
788 kind: "read",
789 method: "GET",
790 pathPattern: "/v1/tasks/:task_id",
791 summary: "读取单个 task"
792 },
793 {
794 id: "tasks.logs.read",
795 kind: "read",
796 method: "GET",
797 pathPattern: "/v1/tasks/:task_id/logs",
798 summary: "读取 task 关联日志"
799 },
800 {
801 id: "runs.list",
802 exposeInDescribe: false,
803 kind: "read",
804 method: "GET",
805 pathPattern: "/v1/runs",
806 summary: "列出本地 run 摘要"
807 },
808 {
809 id: "runs.read",
810 exposeInDescribe: false,
811 kind: "read",
812 method: "GET",
813 pathPattern: "/v1/runs/:run_id",
814 summary: "读取单个 run"
815 },
816 {
817 id: "artifact.messages.list",
818 kind: "read",
819 method: "GET",
820 pathPattern: "/v1/messages",
821 summary: "查询消息列表(分页、按 platform/conversation 过滤)"
822 },
823 {
824 id: "artifact.messages.read",
825 kind: "read",
826 method: "GET",
827 pathPattern: "/v1/messages/:message_id",
828 summary: "读取单条消息详情"
829 },
830 {
831 id: "artifact.executions.list",
832 kind: "read",
833 method: "GET",
834 pathPattern: "/v1/executions",
835 summary: "查询执行记录列表(分页、按 message/target/tool 过滤)"
836 },
837 {
838 id: "artifact.executions.read",
839 kind: "read",
840 method: "GET",
841 pathPattern: "/v1/executions/:instruction_id",
842 summary: "读取单条执行记录详情"
843 },
844 {
845 id: "artifact.sessions.list",
846 kind: "read",
847 method: "GET",
848 pathPattern: "/v1/sessions",
849 summary: "查询会话索引(分页、按 platform 过滤)"
850 },
851 {
852 id: "artifact.sessions.latest",
853 kind: "read",
854 method: "GET",
855 pathPattern: "/v1/sessions/latest",
856 summary: "最近活跃会话及关联消息和执行 URL"
857 },
858 {
859 id: "renewal.conversations.list",
860 kind: "read",
861 method: "GET",
862 pathPattern: "/v1/renewal/conversations",
863 summary: "查询本地对话自动化状态(分页、按 platform/status 过滤)"
864 },
865 {
866 id: "renewal.conversations.read",
867 kind: "read",
868 method: "GET",
869 pathPattern: "/v1/renewal/conversations/:local_conversation_id",
870 summary: "读取单个本地对话状态及关联目标"
871 },
872 {
873 id: "renewal.links.list",
874 kind: "read",
875 method: "GET",
876 pathPattern: "/v1/renewal/links",
877 summary: "查询本地对话关联表(分页、按 platform/conversation/client 过滤)"
878 },
879 {
880 id: "renewal.jobs.list",
881 kind: "read",
882 method: "GET",
883 pathPattern: "/v1/renewal/jobs",
884 summary: "查询续命任务列表(分页、按状态/对话/message 过滤)"
885 },
886 {
887 id: "renewal.jobs.read",
888 kind: "read",
889 method: "GET",
890 pathPattern: "/v1/renewal/jobs/:job_id",
891 summary: "读取单个续命任务详情"
892 },
893 {
894 id: "renewal.conversations.manual",
895 kind: "write",
896 method: "POST",
897 pathPattern: "/v1/renewal/conversations/:local_conversation_id/manual",
898 summary: "将本地对话自动化状态切到 manual"
899 },
900 {
901 id: "renewal.conversations.auto",
902 kind: "write",
903 method: "POST",
904 pathPattern: "/v1/renewal/conversations/:local_conversation_id/auto",
905 summary: "将本地对话自动化状态切到 auto"
906 },
907 {
908 id: "renewal.conversations.paused",
909 kind: "write",
910 method: "POST",
911 pathPattern: "/v1/renewal/conversations/:local_conversation_id/paused",
912 summary: "将本地对话自动化状态切到 paused"
913 },
914 {
915 id: "automation.conversations.control",
916 exposeInDescribe: false,
917 kind: "write",
918 method: "POST",
919 pathPattern: "/v1/internal/automation/conversations/control",
920 summary: "内部当前对话自动化控制入口"
921 }
922];
923
924function resolveServiceName(): string {
925 return "baa-conductor-daemon";
926}
927
928function normalizePathname(value: string): string {
929 const normalized = value.replace(/\/+$/u, "");
930 return normalized === "" ? "/" : normalized;
931}
932
933function toUnixMilliseconds(value: number | null | undefined): number | null {
934 if (value == null) {
935 return null;
936 }
937
938 return value >= 1_000_000_000_000 ? Math.trunc(value) : Math.trunc(value * 1000);
939}
940
941function resolveLeadershipRole(snapshot: ConductorRuntimeApiSnapshot): "leader" | "standby" {
942 return snapshot.daemon.leaseState === "leader" ? "leader" : "standby";
943}
944
945function isRuntimeReady(snapshot: ConductorRuntimeApiSnapshot): boolean {
946 return snapshot.runtime.started && snapshot.daemon.leaseState !== "degraded";
947}
948
949function isJsonObject(value: JsonValue | null): value is JsonObject {
950 return value !== null && typeof value === "object" && !Array.isArray(value);
951}
952
953function normalizeOptionalString(value: string | null | undefined): string | null {
954 if (value == null) {
955 return null;
956 }
957
958 const normalized = value.trim();
959 return normalized === "" ? null : normalized;
960}
961
962function getSnapshotClaudeCodedLocalApiBase(snapshot: ConductorRuntimeApiSnapshot): string | null {
963 return normalizeOptionalString(snapshot.claudeCoded?.localApiBase) ?? null;
964}
965
966function getSnapshotCodexdLocalApiBase(snapshot: ConductorRuntimeApiSnapshot): string | null {
967 return normalizeOptionalString(snapshot.codexd?.localApiBase) ?? null;
968}
969
970function buildSuccessEnvelope(
971 requestId: string,
972 status: number,
973 data: JsonValue,
974 headers: Record<string, string> = {}
975): ConductorHttpResponse {
976 return jsonResponse(
977 status,
978 {
979 ok: true,
980 request_id: requestId,
981 data
982 },
983 headers
984 );
985}
986
987function buildErrorEnvelope(requestId: string, error: LocalApiHttpError): ConductorHttpResponse {
988 const payload: JsonObject = {
989 ok: false,
990 request_id: requestId,
991 error: error.error,
992 message: error.message
993 };
994
995 if (error.details !== undefined) {
996 payload.details = error.details;
997 }
998
999 return jsonResponse(error.status, payload, error.headers ?? {});
1000}
1001
1002function readBodyJson(request: ConductorHttpRequest): JsonValue | null {
1003 const rawBody = request.body?.trim() ?? "";
1004
1005 if (rawBody === "") {
1006 return null;
1007 }
1008
1009 try {
1010 return JSON.parse(rawBody) as JsonValue;
1011 } catch {
1012 throw new LocalApiHttpError(400, "invalid_json", "Request body must be valid JSON.");
1013 }
1014}
1015
1016function readBodyObject(request: ConductorHttpRequest, allowNull: boolean = false): JsonObject {
1017 const body = readBodyJson(request);
1018
1019 if (body === null) {
1020 if (allowNull) {
1021 return {};
1022 }
1023
1024 throw new LocalApiHttpError(400, "invalid_request", "Request body must be a JSON object.");
1025 }
1026
1027 if (!isJsonObject(body)) {
1028 throw new LocalApiHttpError(400, "invalid_request", "Request body must be a JSON object.");
1029 }
1030
1031 return body;
1032}
1033
1034function readOptionalStringField(body: JsonObject, fieldName: string): string | undefined {
1035 const value = body[fieldName];
1036
1037 if (value == null) {
1038 return undefined;
1039 }
1040
1041 if (typeof value !== "string") {
1042 throw new LocalApiHttpError(400, "invalid_request", `Field "${fieldName}" must be a string.`, {
1043 field: fieldName
1044 });
1045 }
1046
1047 const normalized = value.trim();
1048 return normalized === "" ? undefined : normalized;
1049}
1050
1051function readOptionalStringBodyField(body: JsonObject, ...fieldNames: string[]): string | undefined {
1052 for (const fieldName of fieldNames) {
1053 const value = readOptionalStringField(body, fieldName);
1054
1055 if (value !== undefined) {
1056 return value;
1057 }
1058 }
1059
1060 return undefined;
1061}
1062
1063function readBodyField(body: JsonObject, ...fieldNames: string[]): JsonValue | undefined {
1064 for (const fieldName of fieldNames) {
1065 if (Object.prototype.hasOwnProperty.call(body, fieldName)) {
1066 return body[fieldName];
1067 }
1068 }
1069
1070 return undefined;
1071}
1072
1073function buildExecOperationRequest(body: JsonObject): ExecOperationRequest {
1074 return {
1075 command: readBodyField(body, "command") as ExecOperationRequest["command"],
1076 cwd: readBodyField(body, "cwd") as ExecOperationRequest["cwd"],
1077 maxBufferBytes: readBodyField(
1078 body,
1079 "maxBufferBytes",
1080 "max_buffer_bytes"
1081 ) as ExecOperationRequest["maxBufferBytes"],
1082 timeoutMs: readBodyField(body, "timeoutMs", "timeout_ms") as ExecOperationRequest["timeoutMs"]
1083 };
1084}
1085
1086function buildDefaultExecFailureResult(timestamp: string): ExecOperationResult {
1087 return {
1088 durationMs: 0,
1089 exitCode: null,
1090 finishedAt: timestamp,
1091 signal: null,
1092 startedAt: timestamp,
1093 stderr: "",
1094 stdout: "",
1095 timedOut: false
1096 };
1097}
1098
1099function normalizeExecOperationResponse(response: ExecOperationResponse): ExecOperationResponse {
1100 if (response.ok) {
1101 return response;
1102 }
1103
1104 const fallbackResult = buildDefaultExecFailureResult(new Date().toISOString());
1105 const result = response.result;
1106
1107 return {
1108 ...response,
1109 result: {
1110 durationMs: result?.durationMs ?? fallbackResult.durationMs,
1111 exitCode: result?.exitCode ?? fallbackResult.exitCode,
1112 finishedAt: result?.finishedAt ?? result?.startedAt ?? fallbackResult.finishedAt,
1113 signal: result?.signal ?? fallbackResult.signal,
1114 startedAt: result?.startedAt ?? fallbackResult.startedAt,
1115 stderr: result?.stderr ?? fallbackResult.stderr,
1116 stdout: result?.stdout ?? fallbackResult.stdout,
1117 timedOut: result?.timedOut ?? fallbackResult.timedOut
1118 }
1119 };
1120}
1121
1122function buildFileReadOperationRequest(body: JsonObject): FileReadOperationRequest {
1123 return {
1124 cwd: readBodyField(body, "cwd") as FileReadOperationRequest["cwd"],
1125 encoding: readBodyField(body, "encoding") as FileReadOperationRequest["encoding"],
1126 path: readBodyField(body, "path") as FileReadOperationRequest["path"]
1127 };
1128}
1129
1130function buildFileWriteOperationRequest(body: JsonObject): FileWriteOperationRequest {
1131 return {
1132 content: readBodyField(body, "content") as FileWriteOperationRequest["content"],
1133 createParents: readBodyField(
1134 body,
1135 "createParents",
1136 "create_parents"
1137 ) as FileWriteOperationRequest["createParents"],
1138 cwd: readBodyField(body, "cwd") as FileWriteOperationRequest["cwd"],
1139 encoding: readBodyField(body, "encoding") as FileWriteOperationRequest["encoding"],
1140 overwrite: readBodyField(body, "overwrite") as FileWriteOperationRequest["overwrite"],
1141 path: readBodyField(body, "path") as FileWriteOperationRequest["path"]
1142 };
1143}
1144
1145function buildCodexSessionCreateRequest(body: JsonObject): JsonObject {
1146 return compactJsonObject({
1147 approvalPolicy: readBodyField(body, "approvalPolicy", "approval_policy"),
1148 baseInstructions: readBodyField(body, "baseInstructions", "base_instructions"),
1149 config: readBodyField(body, "config"),
1150 cwd: readBodyField(body, "cwd"),
1151 developerInstructions: readBodyField(body, "developerInstructions", "developer_instructions"),
1152 ephemeral: readBodyField(body, "ephemeral"),
1153 metadata: readBodyField(body, "metadata"),
1154 model: readBodyField(body, "model"),
1155 modelProvider: readBodyField(body, "modelProvider", "model_provider"),
1156 personality: readBodyField(body, "personality"),
1157 purpose: readBodyField(body, "purpose"),
1158 sandbox: readBodyField(body, "sandbox"),
1159 serviceTier: readBodyField(body, "serviceTier", "service_tier"),
1160 threadId: readBodyField(body, "threadId", "thread_id")
1161 });
1162}
1163
1164function buildCodexTurnCreateRequest(body: JsonObject): JsonObject {
1165 return compactJsonObject({
1166 approvalPolicy: readBodyField(body, "approvalPolicy", "approval_policy"),
1167 collaborationMode: readBodyField(body, "collaborationMode", "collaboration_mode"),
1168 cwd: readBodyField(body, "cwd"),
1169 effort: readBodyField(body, "effort"),
1170 expectedTurnId: readBodyField(body, "expectedTurnId", "expected_turn_id"),
1171 input: readBodyField(body, "input", "prompt"),
1172 model: readBodyField(body, "model"),
1173 outputSchema: readBodyField(body, "outputSchema", "output_schema"),
1174 personality: readBodyField(body, "personality"),
1175 sandboxPolicy: readBodyField(body, "sandboxPolicy", "sandbox_policy"),
1176 serviceTier: readBodyField(body, "serviceTier", "service_tier"),
1177 sessionId: readBodyField(body, "sessionId", "session_id"),
1178 summary: readBodyField(body, "summary")
1179 });
1180}
1181
1182function compactJsonObject(record: Record<string, JsonValue | undefined>): JsonObject {
1183 const result: JsonObject = {};
1184
1185 for (const [key, value] of Object.entries(record)) {
1186 if (value !== undefined) {
1187 result[key] = value;
1188 }
1189 }
1190
1191 return result;
1192}
1193
1194function isUpstreamSuccessEnvelope(value: JsonValue | null): value is UpstreamSuccessEnvelope {
1195 return isJsonObject(value) && value.ok === true && "data" in value;
1196}
1197
1198function isUpstreamErrorEnvelope(value: JsonValue | null): value is UpstreamErrorEnvelope {
1199 return (
1200 isJsonObject(value)
1201 && value.ok === false
1202 && typeof value.error === "string"
1203 && typeof value.message === "string"
1204 );
1205}
1206
1207function isCodexRoute(route: LocalApiRouteDefinition): boolean {
1208 return CODEX_ROUTE_IDS.has(route.id);
1209}
1210
1211function requireRouteDefinition(id: string): LocalApiRouteDefinition {
1212 const route = LOCAL_API_ROUTES.find((entry) => entry.id === id);
1213
1214 if (!route) {
1215 throw new Error(`Unknown local route definition "${id}".`);
1216 }
1217
1218 return route;
1219}
1220
1221function readPositiveIntegerQuery(
1222 url: URL,
1223 fieldName: string,
1224 defaultValue: number,
1225 maximum: number
1226): number {
1227 const rawValue = url.searchParams.get(fieldName);
1228
1229 if (rawValue == null || rawValue.trim() === "") {
1230 return defaultValue;
1231 }
1232
1233 const numeric = Number(rawValue);
1234
1235 if (!Number.isInteger(numeric) || numeric <= 0) {
1236 throw new LocalApiHttpError(
1237 400,
1238 "invalid_request",
1239 `Query parameter "${fieldName}" must be a positive integer.`,
1240 {
1241 field: fieldName
1242 }
1243 );
1244 }
1245
1246 if (numeric > maximum) {
1247 throw new LocalApiHttpError(
1248 400,
1249 "invalid_request",
1250 `Query parameter "${fieldName}" must be less than or equal to ${maximum}.`,
1251 {
1252 field: fieldName,
1253 maximum
1254 }
1255 );
1256 }
1257
1258 return numeric;
1259}
1260
1261function readTaskStatusFilter(url: URL): TaskStatus | undefined {
1262 const rawValue = url.searchParams.get("status");
1263
1264 if (rawValue == null || rawValue.trim() === "") {
1265 return undefined;
1266 }
1267
1268 if (!TASK_STATUS_SET.has(rawValue as TaskStatus)) {
1269 throw new LocalApiHttpError(
1270 400,
1271 "invalid_request",
1272 `Query parameter "status" must be one of ${TASK_STATUS_VALUES.join(", ")}.`,
1273 {
1274 field: "status",
1275 allowed_values: [...TASK_STATUS_VALUES]
1276 }
1277 );
1278 }
1279
1280 return rawValue as TaskStatus;
1281}
1282
1283function readBrowserLoginStatusFilter(url: URL): BrowserLoginStateStatus | undefined {
1284 const rawValue = url.searchParams.get("status");
1285
1286 if (rawValue == null || rawValue.trim() === "") {
1287 return undefined;
1288 }
1289
1290 if (!BROWSER_LOGIN_STATUS_SET.has(rawValue as BrowserLoginStateStatus)) {
1291 throw new LocalApiHttpError(
1292 400,
1293 "invalid_request",
1294 'Query parameter "status" must be one of fresh, stale, lost.',
1295 {
1296 field: "status",
1297 allowed_values: ["fresh", "stale", "lost"]
1298 }
1299 );
1300 }
1301
1302 return rawValue as BrowserLoginStateStatus;
1303}
1304
1305function readBrowserStatusFilters(url: URL): BrowserStatusFilters {
1306 return {
1307 account: readOptionalQueryString(url, "account"),
1308 browser: readOptionalQueryString(url, "browser"),
1309 clientId: readOptionalQueryString(url, "client_id", "clientId"),
1310 host: readOptionalQueryString(url, "host", "machine"),
1311 limit: readPositiveIntegerQuery(url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT),
1312 platform: readOptionalQueryString(url, "platform"),
1313 status: readBrowserLoginStatusFilter(url)
1314 };
1315}
1316
1317function requireRepository(repository: ControlPlaneRepository | null): ControlPlaneRepository {
1318 if (repository == null) {
1319 throw new LocalApiHttpError(
1320 503,
1321 "repository_not_configured",
1322 "Conductor local API repository is not configured."
1323 );
1324 }
1325
1326 return repository;
1327}
1328
1329function requireArtifactStore(artifactStore: ArtifactStore | null): ArtifactStore {
1330 if (artifactStore == null) {
1331 throw new LocalApiHttpError(
1332 503,
1333 "artifact_store_not_configured",
1334 "Conductor artifact store is not configured."
1335 );
1336 }
1337
1338 return artifactStore;
1339}
1340
1341function buildRecentSessionsArtifactUrls(artifactStore: ArtifactStore | null): {
1342 recent_sessions_json_url: string | null;
1343 recent_sessions_url: string | null;
1344} {
1345 const publicBaseUrl = artifactStore?.getPublicBaseUrl() ?? null;
1346
1347 return {
1348 recent_sessions_url: buildArtifactPublicUrl(publicBaseUrl, RECENT_SESSIONS_TXT_ARTIFACT_PATH),
1349 recent_sessions_json_url: buildArtifactPublicUrl(publicBaseUrl, RECENT_SESSIONS_JSON_ARTIFACT_PATH)
1350 };
1351}
1352
1353function summarizeTask(task: TaskRecord): JsonObject {
1354 return {
1355 task_id: task.taskId,
1356 repo: task.repo,
1357 task_type: task.taskType,
1358 title: task.title,
1359 goal: task.goal,
1360 source: task.source,
1361 priority: task.priority,
1362 status: task.status,
1363 planner_provider: task.plannerProvider,
1364 planning_strategy: task.planningStrategy,
1365 branch_name: task.branchName,
1366 base_ref: task.baseRef,
1367 target_host: task.targetHost,
1368 assigned_controller_id: task.assignedControllerId,
1369 current_step_index: task.currentStepIndex,
1370 result_summary: task.resultSummary,
1371 error_text: task.errorText,
1372 created_at: toUnixMilliseconds(task.createdAt),
1373 updated_at: toUnixMilliseconds(task.updatedAt),
1374 started_at: toUnixMilliseconds(task.startedAt),
1375 finished_at: toUnixMilliseconds(task.finishedAt)
1376 };
1377}
1378
1379function summarizeRun(run: TaskRunRecord): JsonObject {
1380 return {
1381 run_id: run.runId,
1382 task_id: run.taskId,
1383 step_id: run.stepId,
1384 worker_id: run.workerId,
1385 controller_id: run.controllerId,
1386 host: run.host,
1387 status: run.status,
1388 pid: run.pid,
1389 checkpoint_seq: run.checkpointSeq,
1390 exit_code: run.exitCode,
1391 created_at: toUnixMilliseconds(run.createdAt),
1392 started_at: toUnixMilliseconds(run.startedAt),
1393 finished_at: toUnixMilliseconds(run.finishedAt),
1394 heartbeat_at: toUnixMilliseconds(run.heartbeatAt),
1395 lease_expires_at: toUnixMilliseconds(run.leaseExpiresAt)
1396 };
1397}
1398
1399function summarizeController(controller: ControllerRecord, leaderControllerId: string | null): JsonObject {
1400 return {
1401 controller_id: controller.controllerId,
1402 host: controller.host,
1403 role: controller.role,
1404 priority: controller.priority,
1405 status: controller.status,
1406 version: controller.version,
1407 last_heartbeat_at: toUnixMilliseconds(controller.lastHeartbeatAt),
1408 last_started_at: toUnixMilliseconds(controller.lastStartedAt),
1409 is_leader: leaderControllerId != null && leaderControllerId === controller.controllerId
1410 };
1411}
1412
1413function summarizeTaskLog(log: TaskLogRecord): JsonObject {
1414 return {
1415 seq: log.seq,
1416 stream: log.stream,
1417 level: log.level,
1418 message: log.message,
1419 created_at: toUnixMilliseconds(log.createdAt),
1420 step_id: log.stepId
1421 };
1422}
1423
1424function extractAutomationMetadata(valueJson: string | null | undefined): {
1425 mode: AutomationMode | null;
1426 reason: string | null;
1427 requestedBy: string | null;
1428 source: string | null;
1429} {
1430 const payload = parseJsonText<{
1431 mode?: unknown;
1432 reason?: unknown;
1433 requested_by?: unknown;
1434 source?: unknown;
1435 }>(valueJson);
1436
1437 const mode = payload?.mode;
1438
1439 return {
1440 mode: mode === "running" || mode === "draining" || mode === "paused" ? mode : null,
1441 reason: typeof payload?.reason === "string" ? payload.reason : null,
1442 requestedBy: typeof payload?.requested_by === "string" ? payload.requested_by : null,
1443 source: typeof payload?.source === "string" ? payload.source : null
1444 };
1445}
1446
1447export async function buildSystemStateData(repository: ControlPlaneRepository): Promise<JsonObject> {
1448 const [automationState, lease, activeRuns, queuedTasks] = await Promise.all([
1449 repository.getAutomationState(),
1450 repository.getCurrentLease(),
1451 repository.countActiveRuns(),
1452 repository.countQueuedTasks()
1453 ]);
1454
1455 const leaderController = lease?.holderId ? await repository.getController(lease.holderId) : null;
1456 const automationMetadata = extractAutomationMetadata(automationState?.valueJson);
1457 const mode = automationMetadata.mode ?? automationState?.mode ?? DEFAULT_AUTOMATION_MODE;
1458 const updatedAt = toUnixMilliseconds(automationState?.updatedAt);
1459 const leaseExpiresAt = toUnixMilliseconds(lease?.leaseExpiresAt);
1460
1461 return {
1462 mode,
1463 updated_at: updatedAt,
1464 holder_id: lease?.holderId ?? null,
1465 holder_host: lease?.holderHost ?? null,
1466 lease_expires_at: leaseExpiresAt,
1467 term: lease?.term ?? null,
1468 automation: {
1469 mode,
1470 updated_at: updatedAt,
1471 requested_by: automationMetadata.requestedBy,
1472 reason: automationMetadata.reason,
1473 source: automationMetadata.source
1474 },
1475 leader: {
1476 controller_id: lease?.holderId ?? null,
1477 host: lease?.holderHost ?? leaderController?.host ?? null,
1478 role: leaderController?.role ?? null,
1479 status: leaderController?.status ?? null,
1480 version: leaderController?.version ?? null,
1481 lease_expires_at: leaseExpiresAt,
1482 term: lease?.term ?? null
1483 },
1484 queue: {
1485 active_runs: activeRuns,
1486 queued_tasks: queuedTasks
1487 }
1488 };
1489}
1490
1491function asJsonObject(value: JsonValue | null | undefined): JsonObject | null {
1492 return isJsonObject(value ?? null) ? (value as JsonObject) : null;
1493}
1494
1495function readJsonObjectField(record: JsonObject | null, fieldName: string): JsonObject | null {
1496 if (record == null) {
1497 return null;
1498 }
1499
1500 return asJsonObject(record[fieldName]);
1501}
1502
1503function readJsonArrayField(record: JsonObject | null, fieldName: string): JsonValue[] {
1504 if (record == null) {
1505 return [];
1506 }
1507
1508 const value = record[fieldName];
1509 return Array.isArray(value) ? value : [];
1510}
1511
1512function readStringValue(record: JsonObject | null, fieldName: string): string | null {
1513 if (record == null) {
1514 return null;
1515 }
1516
1517 const value = record[fieldName];
1518 return typeof value === "string" ? value : null;
1519}
1520
1521function readBooleanValue(record: JsonObject | null, fieldName: string): boolean | null {
1522 if (record == null) {
1523 return null;
1524 }
1525
1526 const value = record[fieldName];
1527 return typeof value === "boolean" ? value : null;
1528}
1529
1530function readNumberValue(record: JsonObject | null, fieldName: string): number | null {
1531 if (record == null) {
1532 return null;
1533 }
1534
1535 const value = record[fieldName];
1536 return typeof value === "number" && Number.isFinite(value) ? value : null;
1537}
1538
1539interface ParsedBrowserProxyResponse {
1540 apiResponse: BrowserBridgeApiResponse;
1541 body: JsonValue | string | null;
1542 rootObject: JsonObject | null;
1543}
1544
1545interface ClaudeBrowserSelection {
1546 client: BrowserBridgeClientSnapshot | null;
1547 credential: BrowserBridgeCredentialSnapshot | null;
1548 requestHook: BrowserBridgeRequestHookSnapshot | null;
1549 shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
1550}
1551
1552interface ReadyClaudeBrowserSelection {
1553 client: BrowserBridgeClientSnapshot;
1554 credential: BrowserBridgeCredentialSnapshot;
1555 requestHook: BrowserBridgeRequestHookSnapshot | null;
1556 shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
1557}
1558
1559interface ClaudeOrganizationSummary {
1560 id: string;
1561 name: string | null;
1562 raw: JsonObject | null;
1563}
1564
1565interface ClaudeConversationSummary {
1566 created_at: number | null;
1567 id: string;
1568 raw: JsonObject | null;
1569 title: string | null;
1570 updated_at: number | null;
1571}
1572
1573interface BrowserActionDispatchResult {
1574 accepted: boolean;
1575 action: BrowserActionName;
1576 client_id: string;
1577 completed: boolean;
1578 connection_id: string;
1579 dispatched_at: number;
1580 failed: boolean;
1581 platform: string | null;
1582 reason: string | null;
1583 request_id: string;
1584 result: JsonObject;
1585 results: JsonObject[];
1586 shell_runtime: JsonObject[];
1587 target: JsonObject;
1588 type: string;
1589}
1590
1591interface BrowserRequestExecutionResult {
1592 client_id: string;
1593 conversation: ClaudeConversationSummary | null;
1594 lease: BrowserRequestPolicyLease | null;
1595 organization: ClaudeOrganizationSummary | null;
1596 platform: string;
1597 policy: BrowserRequestAdmission;
1598 request_body: JsonValue | null;
1599 request_id: string;
1600 request_method: string;
1601 request_mode: "api_request" | "claude_prompt";
1602 request_path: string;
1603 response: JsonValue | string | null;
1604 response_mode: BrowserRequestResponseMode;
1605 status: number | null;
1606 stream: BrowserBridgeApiStream | null;
1607}
1608
1609function asUnknownRecord(value: unknown): Record<string, unknown> | null {
1610 if (value === null || typeof value !== "object" || Array.isArray(value)) {
1611 return null;
1612 }
1613
1614 return value as Record<string, unknown>;
1615}
1616
1617function readUnknownString(
1618 input: Record<string, unknown> | null,
1619 fieldNames: readonly string[]
1620): string | null {
1621 if (input == null) {
1622 return null;
1623 }
1624
1625 for (const fieldName of fieldNames) {
1626 const value = input[fieldName];
1627
1628 if (typeof value === "string") {
1629 const normalized = value.trim();
1630
1631 if (normalized !== "") {
1632 return normalized;
1633 }
1634 }
1635 }
1636
1637 return null;
1638}
1639
1640function readUnknownBoolean(
1641 input: Record<string, unknown> | null,
1642 fieldNames: readonly string[]
1643): boolean | null {
1644 if (input == null) {
1645 return null;
1646 }
1647
1648 for (const fieldName of fieldNames) {
1649 const value = input[fieldName];
1650
1651 if (typeof value === "boolean") {
1652 return value;
1653 }
1654 }
1655
1656 return null;
1657}
1658
1659function normalizeTimestampLike(value: unknown): number | null {
1660 if (typeof value === "number" && Number.isFinite(value) && value > 0) {
1661 return Math.round(value >= 1_000_000_000_000 ? value : value * 1000);
1662 }
1663
1664 if (typeof value !== "string") {
1665 return null;
1666 }
1667
1668 const normalized = value.trim();
1669
1670 if (normalized === "") {
1671 return null;
1672 }
1673
1674 const numeric = Number(normalized);
1675
1676 if (Number.isFinite(numeric) && numeric > 0) {
1677 return Math.round(numeric >= 1_000_000_000_000 ? numeric : numeric * 1000);
1678 }
1679
1680 const timestamp = Date.parse(normalized);
1681 return Number.isFinite(timestamp) ? timestamp : null;
1682}
1683
1684function parseBrowserProxyBody(body: unknown): JsonValue | string | null {
1685 if (body == null) {
1686 return null;
1687 }
1688
1689 if (typeof body === "string") {
1690 const normalized = body.trim();
1691
1692 if (normalized === "") {
1693 return null;
1694 }
1695
1696 try {
1697 return JSON.parse(normalized) as JsonValue;
1698 } catch {
1699 const parsedSseBody = parseBufferedSseProxyBody(body);
1700
1701 if (parsedSseBody != null) {
1702 return parsedSseBody;
1703 }
1704
1705 return body;
1706 }
1707 }
1708
1709 if (
1710 typeof body === "boolean"
1711 || typeof body === "number"
1712 || Array.isArray(body)
1713 || typeof body === "object"
1714 ) {
1715 return body as JsonValue;
1716 }
1717
1718 return String(body);
1719}
1720
1721function serializeSseFrame(event: string, data: JsonValue): string {
1722 const serialized = JSON.stringify(data);
1723 return `event: ${event}\ndata: ${serialized}\n\n`;
1724}
1725
1726function parseSseJsonValue(value: string): JsonValue | string {
1727 try {
1728 return JSON.parse(value) as JsonValue;
1729 } catch {
1730 return value;
1731 }
1732}
1733
1734function parseBrowserSseChunks(
1735 rawBody: string
1736): Array<{
1737 data: JsonValue | string;
1738 event: string | null;
1739 raw: string;
1740}> {
1741 const chunks = String(rawBody || "")
1742 .split(/\r?\n\r?\n/gu)
1743 .map((chunk) => chunk.trim())
1744 .filter((chunk) => chunk !== "");
1745
1746 return chunks.map((chunk) => {
1747 const lines = chunk.split(/\r?\n/gu);
1748 let event: string | null = null;
1749 const dataLines: string[] = [];
1750
1751 for (const line of lines) {
1752 if (line.startsWith("event:")) {
1753 event = line.slice("event:".length).trim() || null;
1754 continue;
1755 }
1756
1757 if (line.startsWith("data:")) {
1758 dataLines.push(line.slice("data:".length).trimStart());
1759 }
1760 }
1761
1762 const joinedData = dataLines.join("\n");
1763
1764 return {
1765 data: parseSseJsonValue(joinedData === "" ? chunk : joinedData),
1766 event,
1767 raw: chunk
1768 };
1769 });
1770}
1771
1772function isLikelyBufferedSseText(value: string): boolean {
1773 const normalized = value.trim();
1774
1775 if (normalized === "") {
1776 return false;
1777 }
1778
1779 let prefixedLineCount = 0;
1780
1781 for (const rawLine of normalized.split(/\r?\n/gu)) {
1782 const line = rawLine.trimStart();
1783
1784 if (line === "") {
1785 continue;
1786 }
1787
1788 if (line.startsWith(":")) {
1789 continue;
1790 }
1791
1792 if (/^(event|data|id|retry):/u.test(line)) {
1793 prefixedLineCount += 1;
1794 continue;
1795 }
1796
1797 return false;
1798 }
1799
1800 return prefixedLineCount > 0;
1801}
1802
1803const BUFFERED_SSE_TEXT_PATH_KEYS = [
1804 "text",
1805 "value",
1806 "content",
1807 "message",
1808 "markdown",
1809 "completion",
1810 "delta",
1811 "parts",
1812 "choices"
1813] as const;
1814
1815function extractBufferedSseTextFragments(value: JsonValue | null): string[] {
1816 if (typeof value === "string") {
1817 return value === "" ? [] : [value];
1818 }
1819
1820 if (Array.isArray(value)) {
1821 return value.flatMap((entry) => extractBufferedSseTextFragments(entry));
1822 }
1823
1824 if (!isJsonObject(value)) {
1825 return [];
1826 }
1827
1828 // Only follow fields that are known to contain text or wrap nested text.
1829 return BUFFERED_SSE_TEXT_PATH_KEYS.flatMap((fieldName) =>
1830 extractBufferedSseTextFragments(value[fieldName] as JsonValue)
1831 );
1832}
1833
1834function parseBufferedSseProxyBody(body: string): JsonObject | null {
1835 if (!isLikelyBufferedSseText(body)) {
1836 return null;
1837 }
1838
1839 const events = parseBrowserSseChunks(body).map((entry) =>
1840 compactJsonObject({
1841 data: entry.data as JsonValue,
1842 event: entry.event ?? undefined,
1843 raw: entry.raw
1844 })
1845 );
1846 const fullText = events
1847 .flatMap((entry) => extractBufferedSseTextFragments((entry.data ?? null) as JsonValue | null))
1848 .join("");
1849
1850 return compactJsonObject({
1851 content_type: "text/event-stream",
1852 events,
1853 full_text: fullText.trim() === "" ? undefined : fullText,
1854 raw: body
1855 });
1856}
1857
1858function createSseResponse(
1859 body: string,
1860 streamBody: AsyncIterable<string> | null = null
1861): ConductorHttpResponse {
1862 return {
1863 status: 200,
1864 headers: {
1865 ...SSE_RESPONSE_HEADERS
1866 },
1867 body,
1868 streamBody
1869 };
1870}
1871
1872function readOptionalQueryString(url: URL, ...fieldNames: string[]): string | undefined {
1873 for (const fieldName of fieldNames) {
1874 const value = url.searchParams.get(fieldName);
1875
1876 if (typeof value === "string") {
1877 const normalized = value.trim();
1878
1879 if (normalized !== "") {
1880 return normalized;
1881 }
1882 }
1883 }
1884
1885 return undefined;
1886}
1887
1888function readOptionalBooleanQuery(url: URL, ...fieldNames: string[]): boolean | undefined {
1889 const rawValue = readOptionalQueryString(url, ...fieldNames);
1890
1891 if (rawValue == null) {
1892 return undefined;
1893 }
1894
1895 switch (rawValue.toLowerCase()) {
1896 case "1":
1897 case "true":
1898 case "yes":
1899 return true;
1900 case "0":
1901 case "false":
1902 case "no":
1903 return false;
1904 default:
1905 throw new LocalApiHttpError(
1906 400,
1907 "invalid_request",
1908 `Query parameter "${fieldNames[0] ?? "value"}" must be a boolean.`,
1909 {
1910 field: fieldNames[0] ?? "value"
1911 }
1912 );
1913 }
1914}
1915
1916function readOptionalRenewalAutomationStatusQuery(
1917 url: URL,
1918 ...fieldNames: string[]
1919): ConversationAutomationStatus | undefined {
1920 const rawValue = readOptionalQueryString(url, ...fieldNames);
1921
1922 if (rawValue == null) {
1923 return undefined;
1924 }
1925
1926 if (!RENEWAL_AUTOMATION_STATUS_SET.has(rawValue as ConversationAutomationStatus)) {
1927 throw new LocalApiHttpError(
1928 400,
1929 "invalid_request",
1930 `Query parameter "${fieldNames[0] ?? "automation_status"}" must be one of manual, auto, paused.`,
1931 {
1932 field: fieldNames[0] ?? "automation_status"
1933 }
1934 );
1935 }
1936
1937 return rawValue as ConversationAutomationStatus;
1938}
1939
1940function normalizeRenewalPauseReason(
1941 value: string | undefined
1942): ConversationPauseReason | undefined {
1943 if (value == null) {
1944 return undefined;
1945 }
1946
1947 if (!RENEWAL_PAUSE_REASON_SET.has(value as ConversationPauseReason)) {
1948 throw new LocalApiHttpError(
1949 400,
1950 "invalid_request",
1951 'Field "pause_reason" must be one of user_pause, ai_pause, system_pause, rescue_wait, repeated_message, repeated_renewal, execution_failure, error_loop.',
1952 {
1953 field: "pause_reason"
1954 }
1955 );
1956 }
1957
1958 return value as ConversationPauseReason;
1959}
1960
1961function readOptionalRenewalJobStatusQuery(
1962 url: URL,
1963 ...fieldNames: string[]
1964): RenewalJobStatus | undefined {
1965 const rawValue = readOptionalQueryString(url, ...fieldNames);
1966
1967 if (rawValue == null) {
1968 return undefined;
1969 }
1970
1971 if (!RENEWAL_JOB_STATUS_SET.has(rawValue as RenewalJobStatus)) {
1972 throw new LocalApiHttpError(
1973 400,
1974 "invalid_request",
1975 `Query parameter "${fieldNames[0] ?? "status"}" must be one of pending, running, done, failed.`,
1976 {
1977 field: fieldNames[0] ?? "status"
1978 }
1979 );
1980 }
1981
1982 return rawValue as RenewalJobStatus;
1983}
1984
1985function readOptionalNumberField(body: JsonObject, fieldName: string): number | undefined {
1986 const value = body[fieldName];
1987
1988 if (value == null) {
1989 return undefined;
1990 }
1991
1992 if (typeof value !== "number" || !Number.isFinite(value)) {
1993 throw new LocalApiHttpError(400, "invalid_request", `Field "${fieldName}" must be a finite number.`, {
1994 field: fieldName
1995 });
1996 }
1997
1998 return value;
1999}
2000
2001function readOptionalNumberBodyField(body: JsonObject, ...fieldNames: string[]): number | undefined {
2002 for (const fieldName of fieldNames) {
2003 const value = readOptionalNumberField(body, fieldName);
2004 if (value !== undefined) {
2005 return value;
2006 }
2007 }
2008
2009 return undefined;
2010}
2011
2012function readOptionalIntegerBodyField(
2013 body: JsonObject,
2014 options: {
2015 allowZero?: boolean;
2016 fieldNames: string[];
2017 label: string;
2018 max: number;
2019 min?: number;
2020 }
2021): number | undefined {
2022 const value = readOptionalNumberBodyField(body, ...options.fieldNames);
2023
2024 if (value === undefined) {
2025 return undefined;
2026 }
2027
2028 if (!Number.isInteger(value)) {
2029 throw new LocalApiHttpError(
2030 400,
2031 "invalid_request",
2032 `Field "${options.label}" must be an integer.`,
2033 {
2034 field: options.label
2035 }
2036 );
2037 }
2038
2039 const min = options.allowZero === true
2040 ? Math.max(0, options.min ?? 0)
2041 : Math.max(1, options.min ?? 1);
2042
2043 if (value < min || value > options.max) {
2044 throw new LocalApiHttpError(
2045 400,
2046 "invalid_request",
2047 `Field "${options.label}" must be between ${min} and ${options.max}.`,
2048 {
2049 field: options.label,
2050 max: options.max,
2051 min
2052 }
2053 );
2054 }
2055
2056 return Math.round(value);
2057}
2058
2059function readOptionalObjectField(body: JsonObject, fieldName: string): JsonObject | undefined {
2060 const value = body[fieldName];
2061
2062 if (value == null) {
2063 return undefined;
2064 }
2065
2066 if (!isJsonObject(value)) {
2067 throw new LocalApiHttpError(400, "invalid_request", `Field "${fieldName}" must be a JSON object.`, {
2068 field: fieldName
2069 });
2070 }
2071
2072 return value;
2073}
2074
2075function readOptionalStringMap(body: JsonObject, fieldName: string): Record<string, string> | undefined {
2076 const value = readOptionalObjectField(body, fieldName);
2077
2078 if (value == null) {
2079 return undefined;
2080 }
2081
2082 const normalized: Record<string, string> = {};
2083
2084 for (const [name, entry] of Object.entries(value)) {
2085 if (typeof entry !== "string") {
2086 throw new LocalApiHttpError(
2087 400,
2088 "invalid_request",
2089 `Field "${fieldName}.${name}" must be a string.`,
2090 {
2091 field: fieldName,
2092 header: name
2093 }
2094 );
2095 }
2096
2097 const normalizedName = name.trim();
2098
2099 if (normalizedName === "") {
2100 continue;
2101 }
2102
2103 normalized[normalizedName] = entry;
2104 }
2105
2106 return Object.keys(normalized).length > 0 ? normalized : undefined;
2107}
2108
2109function readOptionalTimeoutMs(body: JsonObject, url: URL): number | undefined {
2110 const bodyTimeoutMs =
2111 readOptionalNumberField(body, "timeoutMs")
2112 ?? readOptionalNumberField(body, "timeout_ms");
2113
2114 if (bodyTimeoutMs !== undefined) {
2115 if (bodyTimeoutMs <= 0) {
2116 throw new LocalApiHttpError(400, "invalid_request", 'Field "timeoutMs" must be greater than 0.', {
2117 field: "timeoutMs"
2118 });
2119 }
2120
2121 return Math.round(bodyTimeoutMs);
2122 }
2123
2124 const queryTimeoutMs = readOptionalQueryString(url, "timeoutMs", "timeout_ms");
2125
2126 if (queryTimeoutMs == null) {
2127 return undefined;
2128 }
2129
2130 const numeric = Number(queryTimeoutMs);
2131
2132 if (!Number.isFinite(numeric) || numeric <= 0) {
2133 throw new LocalApiHttpError(400, "invalid_request", 'Query parameter "timeoutMs" must be greater than 0.', {
2134 field: "timeoutMs"
2135 });
2136 }
2137
2138 return Math.round(numeric);
2139}
2140
2141function readBrowserActionName(body: JsonObject): BrowserActionName {
2142 const action = readOptionalStringBodyField(body, "action", "type");
2143
2144 if (action == null) {
2145 throw new LocalApiHttpError(
2146 400,
2147 "invalid_request",
2148 'Field "action" is required for POST /v1/browser/actions.',
2149 {
2150 field: "action",
2151 supported_actions: [...SUPPORTED_BROWSER_ACTIONS, ...RESERVED_BROWSER_ACTIONS]
2152 }
2153 );
2154 }
2155
2156 const supportedAction = [...SUPPORTED_BROWSER_ACTIONS, ...RESERVED_BROWSER_ACTIONS].find(
2157 (entry) => entry === action
2158 );
2159
2160 if (supportedAction == null) {
2161 throw new LocalApiHttpError(
2162 400,
2163 "invalid_request",
2164 `Unsupported browser action "${action}".`,
2165 {
2166 action,
2167 field: "action",
2168 supported_actions: [...SUPPORTED_BROWSER_ACTIONS, ...RESERVED_BROWSER_ACTIONS]
2169 }
2170 );
2171 }
2172
2173 return supportedAction;
2174}
2175
2176function readBrowserRequestResponseMode(body: JsonObject): BrowserRequestResponseMode {
2177 const responseMode = readOptionalStringBodyField(body, "responseMode", "response_mode");
2178
2179 if (responseMode == null) {
2180 return "buffered";
2181 }
2182
2183 const supportedMode = [
2184 ...SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES,
2185 ...RESERVED_BROWSER_REQUEST_RESPONSE_MODES
2186 ].find((entry) => entry === responseMode);
2187
2188 if (supportedMode == null) {
2189 throw new LocalApiHttpError(
2190 400,
2191 "invalid_request",
2192 `Unsupported browser response mode "${responseMode}".`,
2193 {
2194 field: "responseMode",
2195 supported_response_modes: [
2196 ...SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES,
2197 ...RESERVED_BROWSER_REQUEST_RESPONSE_MODES
2198 ]
2199 }
2200 );
2201 }
2202
2203 return supportedMode;
2204}
2205
2206function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): BrowserBridgeStateSnapshot {
2207 return {
2208 active_client_id: null,
2209 active_connection_id: null,
2210 automation_conversations: [],
2211 client_count: 0,
2212 clients: [],
2213 delivery: {
2214 activeSessionCount: 0,
2215 lastRoute: null,
2216 lastSession: null
2217 },
2218 instruction_ingest: createEmptyBrowserInstructionIngestSnapshot(),
2219 ws_path: "/ws/browser",
2220 ws_url: snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null
2221 };
2222}
2223
2224function createEmptyBrowserInstructionIngestSnapshot(): BrowserBridgeStateSnapshot["instruction_ingest"] {
2225 return {
2226 last_execute: null,
2227 last_ingest: null,
2228 recent_executes: [],
2229 recent_ingests: []
2230 };
2231}
2232
2233function normalizeBrowserStateSnapshot(state: BrowserBridgeStateSnapshot): BrowserBridgeStateSnapshot {
2234 return {
2235 ...state,
2236 automation_conversations: Array.isArray(state.automation_conversations)
2237 ? state.automation_conversations
2238 : [],
2239 delivery: state.delivery ?? {
2240 activeSessionCount: 0,
2241 lastRoute: null,
2242 lastSession: null
2243 },
2244 instruction_ingest: state.instruction_ingest ?? createEmptyBrowserInstructionIngestSnapshot()
2245 };
2246}
2247
2248function loadBrowserState(context: LocalApiRequestContext): BrowserBridgeStateSnapshot {
2249 return normalizeBrowserStateSnapshot(
2250 context.browserStateLoader() ?? createEmptyBrowserState(context.snapshotLoader())
2251 );
2252}
2253
2254function selectClaudeBrowserClient(
2255 state: BrowserBridgeStateSnapshot,
2256 requestedClientId?: string | null
2257): ClaudeBrowserSelection {
2258 const normalizedRequestedClientId = normalizeOptionalString(requestedClientId);
2259 const client =
2260 normalizedRequestedClientId == null
2261 ? (
2262 state.clients.find((entry) => entry.client_id === state.active_client_id)
2263 ?? [...state.clients].sort((left, right) => right.last_message_at - left.last_message_at)[0]
2264 ?? null
2265 )
2266 : state.clients.find((entry) => entry.client_id === normalizedRequestedClientId) ?? null;
2267
2268 return {
2269 client,
2270 credential: client?.credentials.find((entry) => entry.platform === BROWSER_CLAUDE_PLATFORM) ?? null,
2271 requestHook: client?.request_hooks.find((entry) => entry.platform === BROWSER_CLAUDE_PLATFORM) ?? null,
2272 shellRuntime: client?.shell_runtime.find((entry) => entry.platform === BROWSER_CLAUDE_PLATFORM) ?? null
2273 };
2274}
2275
2276function buildBrowserRecordKey(platform: string, clientId: string, account: string | null): string {
2277 return `${platform}\u0000${clientId}\u0000${account ?? ""}`;
2278}
2279
2280function findMatchingRequestHook(
2281 client: BrowserBridgeClientSnapshot,
2282 platform: string,
2283 account: string | null
2284): BrowserBridgeRequestHookSnapshot | null {
2285 const directMatch =
2286 client.request_hooks.find((entry) =>
2287 entry.platform === platform && (account == null || entry.account == null || entry.account === account)
2288 ) ?? null;
2289
2290 if (directMatch != null) {
2291 return directMatch;
2292 }
2293
2294 return client.request_hooks.find((entry) => entry.platform === platform) ?? null;
2295}
2296
2297function findMatchingShellRuntime(
2298 client: BrowserBridgeClientSnapshot,
2299 platform: string
2300): BrowserBridgeShellRuntimeSnapshot | null {
2301 return client.shell_runtime.find((entry) => entry.platform === platform) ?? null;
2302}
2303
2304function upsertBrowserMergedRecord(
2305 records: Map<string, BrowserMergedRecord>,
2306 record: BrowserMergedRecord
2307): void {
2308 const key = buildBrowserRecordKey(record.platform, record.clientId, record.account);
2309 const current = records.get(key);
2310
2311 if (current == null) {
2312 records.set(key, record);
2313 return;
2314 }
2315
2316 records.set(key, {
2317 account: record.account ?? current.account,
2318 activeConnection: record.activeConnection ?? current.activeConnection,
2319 browser: record.browser ?? current.browser,
2320 clientId: record.clientId,
2321 credential: record.credential ?? current.credential,
2322 endpointMetadata: record.endpointMetadata ?? current.endpointMetadata,
2323 host: record.host ?? current.host,
2324 persistedLoginState: record.persistedLoginState ?? current.persistedLoginState,
2325 platform: record.platform,
2326 requestHook: record.requestHook ?? current.requestHook,
2327 shellRuntime: record.shellRuntime ?? current.shellRuntime,
2328 view:
2329 (record.activeConnection ?? current.activeConnection) != null
2330 && (record.persistedLoginState ?? current.persistedLoginState) != null
2331 ? "active_and_persisted"
2332 : (record.activeConnection ?? current.activeConnection) != null
2333 ? "active_only"
2334 : "persisted_only"
2335 });
2336}
2337
2338function buildActiveBrowserRecords(
2339 state: BrowserBridgeStateSnapshot,
2340 host: string | null
2341): Map<string, BrowserMergedRecord> {
2342 const records = new Map<string, BrowserMergedRecord>();
2343
2344 for (const client of state.clients) {
2345 for (const credential of client.credentials) {
2346 upsertBrowserMergedRecord(records, {
2347 account: credential.account,
2348 activeConnection: client,
2349 browser: client.node_platform,
2350 clientId: client.client_id,
2351 credential,
2352 endpointMetadata: null,
2353 host,
2354 persistedLoginState: null,
2355 platform: credential.platform,
2356 requestHook: findMatchingRequestHook(client, credential.platform, credential.account),
2357 shellRuntime: findMatchingShellRuntime(client, credential.platform),
2358 view: "active_only"
2359 });
2360 }
2361
2362 for (const requestHook of client.request_hooks) {
2363 upsertBrowserMergedRecord(records, {
2364 account: requestHook.account,
2365 activeConnection: client,
2366 browser: client.node_platform,
2367 clientId: client.client_id,
2368 credential:
2369 client.credentials.find((entry) =>
2370 entry.platform === requestHook.platform
2371 && (requestHook.account == null || entry.account == null || entry.account === requestHook.account)
2372 ) ?? null,
2373 endpointMetadata: null,
2374 host,
2375 persistedLoginState: null,
2376 platform: requestHook.platform,
2377 requestHook,
2378 shellRuntime: findMatchingShellRuntime(client, requestHook.platform),
2379 view: "active_only"
2380 });
2381 }
2382 }
2383
2384 return records;
2385}
2386
2387function resolveBrowserRecordStatus(record: BrowserMergedRecord): BrowserLoginStateStatus | null {
2388 return record.persistedLoginState?.status ?? record.credential?.freshness ?? null;
2389}
2390
2391function matchesBrowserStatusFilters(record: BrowserMergedRecord, filters: BrowserStatusFilters): boolean {
2392 if (filters.platform != null && record.platform !== filters.platform) {
2393 return false;
2394 }
2395
2396 if (filters.browser != null && record.browser !== filters.browser) {
2397 return false;
2398 }
2399
2400 if (filters.host != null && record.host !== filters.host) {
2401 return false;
2402 }
2403
2404 if (filters.account != null && record.account !== filters.account) {
2405 return false;
2406 }
2407
2408 if (filters.clientId != null && record.clientId !== filters.clientId) {
2409 return false;
2410 }
2411
2412 if (filters.status != null && resolveBrowserRecordStatus(record) !== filters.status) {
2413 return false;
2414 }
2415
2416 return true;
2417}
2418
2419function getBrowserRecordSortTimestamp(record: BrowserMergedRecord): number {
2420 return (
2421 record.activeConnection?.last_message_at
2422 ?? record.credential?.last_seen_at
2423 ?? toUnixMilliseconds(record.persistedLoginState?.lastSeenAt)
2424 ?? record.requestHook?.updated_at
2425 ?? toUnixMilliseconds(record.endpointMetadata?.updatedAt)
2426 ?? 0
2427 );
2428}
2429
2430function summarizeBrowserFilters(filters: BrowserStatusFilters): JsonObject {
2431 return compactJsonObject({
2432 account: filters.account,
2433 browser: filters.browser,
2434 client_id: filters.clientId,
2435 host: filters.host,
2436 limit: filters.limit,
2437 platform: filters.platform,
2438 status: filters.status
2439 });
2440}
2441
2442async function listBrowserMergedRecords(
2443 context: LocalApiRequestContext,
2444 state: BrowserBridgeStateSnapshot,
2445 filters: BrowserStatusFilters
2446): Promise<BrowserMergedRecord[]> {
2447 const snapshot = context.snapshotLoader();
2448 const records = buildActiveBrowserRecords(state, snapshot.daemon.host);
2449
2450 if (context.repository != null) {
2451 const persistedLoginStates = await context.repository.listBrowserLoginStates({
2452 account: filters.account,
2453 browser: filters.browser,
2454 clientId: filters.clientId,
2455 host: filters.host,
2456 limit: filters.limit,
2457 platform: filters.platform,
2458 status: filters.status
2459 });
2460 const endpointMetadata = await context.repository.listBrowserEndpointMetadata({
2461 account: filters.account,
2462 clientId: filters.clientId,
2463 limit: filters.limit,
2464 platform: filters.platform
2465 });
2466 const endpointsByKey = new Map<string, BrowserEndpointMetadataRecord>();
2467
2468 for (const entry of endpointMetadata) {
2469 endpointsByKey.set(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account), entry);
2470 }
2471
2472 for (const entry of persistedLoginStates) {
2473 upsertBrowserMergedRecord(records, {
2474 account: entry.account,
2475 activeConnection: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.activeConnection ?? null,
2476 browser: entry.browser,
2477 clientId: entry.clientId,
2478 credential: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.credential ?? null,
2479 endpointMetadata: endpointsByKey.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account)) ?? null,
2480 host: entry.host,
2481 persistedLoginState: entry,
2482 platform: entry.platform,
2483 requestHook: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.requestHook ?? null,
2484 shellRuntime: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.shellRuntime ?? null,
2485 view: "persisted_only"
2486 });
2487 }
2488
2489 for (const entry of endpointMetadata) {
2490 upsertBrowserMergedRecord(records, {
2491 account: entry.account,
2492 activeConnection: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.activeConnection ?? null,
2493 browser: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.browser ?? null,
2494 clientId: entry.clientId,
2495 credential: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.credential ?? null,
2496 endpointMetadata: entry,
2497 host: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.host ?? null,
2498 persistedLoginState: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.persistedLoginState ?? null,
2499 platform: entry.platform,
2500 requestHook: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.requestHook ?? null,
2501 shellRuntime: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.shellRuntime ?? null,
2502 view: "persisted_only"
2503 });
2504 }
2505 }
2506
2507 return [...records.values()]
2508 .filter((record) => matchesBrowserStatusFilters(record, filters))
2509 .sort((left, right) => getBrowserRecordSortTimestamp(right) - getBrowserRecordSortTimestamp(left))
2510 .slice(0, filters.limit);
2511}
2512
2513function serializeBrowserMergedRecord(
2514 record: BrowserMergedRecord,
2515 activeClientId: string | null
2516): JsonObject {
2517 return {
2518 account: record.account,
2519 active_connection:
2520 record.activeConnection == null
2521 ? null
2522 : compactJsonObject({
2523 active_client: record.activeConnection.client_id === activeClientId,
2524 connected_at: record.activeConnection.connected_at,
2525 connection_id: record.activeConnection.connection_id,
2526 last_message_at: record.activeConnection.last_message_at,
2527 node_category: record.activeConnection.node_category,
2528 node_platform: record.activeConnection.node_platform,
2529 node_type: record.activeConnection.node_type
2530 }),
2531 browser: record.browser,
2532 client_id: record.clientId,
2533 host: record.host,
2534 live:
2535 record.credential == null && record.requestHook == null
2536 ? null
2537 : compactJsonObject({
2538 credentials:
2539 record.credential == null
2540 ? undefined
2541 : serializeBrowserCredentialSnapshot(record.credential),
2542 request_hooks:
2543 record.requestHook == null
2544 ? undefined
2545 : serializeBrowserRequestHookSnapshot(record.requestHook),
2546 shell_runtime:
2547 record.shellRuntime == null
2548 ? undefined
2549 : serializeBrowserShellRuntimeSnapshot(record.shellRuntime)
2550 }),
2551 persisted:
2552 record.persistedLoginState == null && record.endpointMetadata == null
2553 ? null
2554 : compactJsonObject({
2555 captured_at: toUnixMilliseconds(record.persistedLoginState?.capturedAt),
2556 credential_fingerprint: record.persistedLoginState?.credentialFingerprint,
2557 endpoints: record.endpointMetadata?.endpoints,
2558 last_seen_at: toUnixMilliseconds(record.persistedLoginState?.lastSeenAt),
2559 last_verified_at: toUnixMilliseconds(record.endpointMetadata?.lastVerifiedAt),
2560 status: record.persistedLoginState?.status,
2561 updated_at: toUnixMilliseconds(record.endpointMetadata?.updatedAt)
2562 }),
2563 platform: record.platform,
2564 status: resolveBrowserRecordStatus(record),
2565 view: record.view
2566 };
2567}
2568
2569function readBridgeErrorCode(error: unknown): string | null {
2570 return readUnknownString(asUnknownRecord(error), ["code"]);
2571}
2572
2573function readBridgeTimeoutMs(error: unknown): number | null {
2574 const record = asUnknownRecord(error);
2575 const value = record?.timeoutMs;
2576
2577 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
2578 return null;
2579 }
2580
2581 return Math.round(value);
2582}
2583
2584function logBrowserBridgeTimeout(
2585 action: string,
2586 code: string | null,
2587 clientId: string | null,
2588 requestId: string | null,
2589 timeoutMs: number | null
2590): void {
2591 console.warn(
2592 `[baa-browser-timeout] action=${action} code=${code ?? "timeout"} client_id=${clientId ?? "-"} `
2593 + `request_id=${requestId ?? "-"} timeout_ms=${timeoutMs ?? "-"}`
2594 );
2595}
2596
2597function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiHttpError {
2598 const record = asUnknownRecord(error);
2599 const code = readBridgeErrorCode(error);
2600 const timeoutMs = readBridgeTimeoutMs(error);
2601 const clientId = readUnknownString(record, ["clientId", "client_id"]);
2602 const requestId = readUnknownString(record, ["requestId", "request_id", "id"]);
2603 const details = compactJsonObject({
2604 action,
2605 bridge_client_id: clientId,
2606 bridge_connection_id: readUnknownString(record, ["connectionId", "connection_id"]),
2607 bridge_request_id: requestId,
2608 cause: error instanceof Error ? error.message : String(error),
2609 error_code: code,
2610 timeout_ms: timeoutMs ?? undefined
2611 });
2612
2613 switch (code) {
2614 case "no_active_client":
2615 return new LocalApiHttpError(
2616 503,
2617 "browser_bridge_unavailable",
2618 "No active browser bridge client is connected.",
2619 details
2620 );
2621 case "client_not_found":
2622 return new LocalApiHttpError(
2623 409,
2624 "browser_client_not_found",
2625 "The requested browser bridge client is not connected.",
2626 details
2627 );
2628 case "duplicate_request_id":
2629 return new LocalApiHttpError(
2630 409,
2631 "browser_request_conflict",
2632 "The requested browser proxy request id is already in flight.",
2633 details
2634 );
2635 case "request_not_found":
2636 return new LocalApiHttpError(
2637 404,
2638 "browser_request_not_found",
2639 `The requested browser proxy request is not in flight for ${action}.`,
2640 details
2641 );
2642 case "request_timeout":
2643 logBrowserBridgeTimeout(action, code, clientId, requestId, timeoutMs);
2644 return new LocalApiHttpError(
2645 504,
2646 "browser_request_timeout",
2647 `Timed out while waiting for the browser bridge to complete ${action}.`,
2648 details
2649 );
2650 case "action_timeout":
2651 logBrowserBridgeTimeout(action, code, clientId, requestId, timeoutMs);
2652 return new LocalApiHttpError(
2653 504,
2654 "browser_action_timeout",
2655 `Timed out while waiting for the browser bridge to complete ${action}.`,
2656 details
2657 );
2658 case "client_disconnected":
2659 case "client_replaced":
2660 case "send_failed":
2661 case "service_stopped":
2662 return new LocalApiHttpError(
2663 503,
2664 "browser_bridge_unavailable",
2665 `The browser bridge became unavailable while processing ${action}.`,
2666 details
2667 );
2668 default:
2669 return new LocalApiHttpError(
2670 502,
2671 "browser_bridge_error",
2672 `Failed to execute ${action} through the browser bridge.`,
2673 details
2674 );
2675 }
2676}
2677
2678function createBrowserPolicyHttpError(action: string, error: BrowserRequestPolicyError): LocalApiHttpError {
2679 switch (error.code) {
2680 case "circuit_open":
2681 return new LocalApiHttpError(
2682 429,
2683 "browser_risk_limited",
2684 `Browser request risk controls rejected ${action}.`,
2685 compactJsonObject({
2686 ...error.details,
2687 action,
2688 error_code: error.code
2689 })
2690 );
2691 default:
2692 return new LocalApiHttpError(
2693 503,
2694 "browser_risk_limited",
2695 `Browser request risk controls could not schedule ${action}.`,
2696 compactJsonObject({
2697 ...error.details,
2698 action,
2699 error_code: error.code
2700 })
2701 );
2702 }
2703}
2704
2705function requireBrowserBridge(context: LocalApiRequestContext): BrowserBridgeController {
2706 if (context.browserBridge == null) {
2707 throw new LocalApiHttpError(
2708 503,
2709 "browser_bridge_unavailable",
2710 "Firefox browser bridge is not configured on this conductor runtime."
2711 );
2712 }
2713
2714 return context.browserBridge;
2715}
2716
2717function resolveBrowserRequestPolicy(context: LocalApiRequestContext): BrowserRequestPolicyController {
2718 return context.browserRequestPolicy ?? new BrowserRequestPolicyController({
2719 config: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG
2720 });
2721}
2722
2723function selectBrowserClient(
2724 state: BrowserBridgeStateSnapshot,
2725 requestedClientId?: string | null
2726): BrowserBridgeClientSnapshot | null {
2727 const normalizedRequestedClientId = normalizeOptionalString(requestedClientId);
2728
2729 if (normalizedRequestedClientId != null) {
2730 return state.clients.find((entry) => entry.client_id === normalizedRequestedClientId) ?? null;
2731 }
2732
2733 return (
2734 state.clients.find((entry) => entry.client_id === state.active_client_id)
2735 ?? [...state.clients].sort((left, right) => right.last_message_at - left.last_message_at)[0]
2736 ?? null
2737 );
2738}
2739
2740function ensureBrowserClientReady(
2741 client: BrowserBridgeClientSnapshot | null,
2742 platform: string,
2743 requestedClientId?: string | null
2744): BrowserBridgeClientSnapshot {
2745 if (client != null) {
2746 return client;
2747 }
2748
2749 const normalizedRequestedClientId = normalizeOptionalString(requestedClientId);
2750
2751 if (normalizedRequestedClientId == null) {
2752 throw new LocalApiHttpError(
2753 503,
2754 "browser_bridge_unavailable",
2755 `No active browser bridge client is connected for ${platform} requests.`
2756 );
2757 }
2758
2759 throw new LocalApiHttpError(
2760 409,
2761 "browser_client_not_found",
2762 `Browser bridge client "${normalizedRequestedClientId}" is not connected.`,
2763 compactJsonObject({
2764 client_id: normalizedRequestedClientId,
2765 platform
2766 })
2767 );
2768}
2769
2770function ensureClaudeBridgeReady(
2771 selection: ClaudeBrowserSelection,
2772 requestedClientId?: string | null
2773): ReadyClaudeBrowserSelection {
2774 if (selection.client == null) {
2775 throw new LocalApiHttpError(
2776 503,
2777 "browser_bridge_unavailable",
2778 "No active browser bridge client is connected for Claude actions."
2779 );
2780 }
2781
2782 if (selection.credential == null) {
2783 throw new LocalApiHttpError(
2784 409,
2785 "claude_credentials_unavailable",
2786 "Claude credentials are not available yet on the selected browser bridge client.",
2787 compactJsonObject({
2788 client_id: selection.client.client_id,
2789 requested_client_id: normalizeOptionalString(requestedClientId),
2790 suggested_action: "Open Claude in Firefox and let the extension observe one real request first."
2791 })
2792 );
2793 }
2794
2795 return {
2796 client: selection.client,
2797 credential: selection.credential,
2798 requestHook: selection.requestHook,
2799 shellRuntime: selection.shellRuntime
2800 };
2801}
2802
2803function serializeBrowserCredentialSnapshot(snapshot: BrowserBridgeCredentialSnapshot): JsonObject {
2804 return compactJsonObject({
2805 account: snapshot.account ?? undefined,
2806 account_captured_at: snapshot.account_captured_at ?? undefined,
2807 account_last_seen_at: snapshot.account_last_seen_at ?? undefined,
2808 captured_at: snapshot.captured_at,
2809 credential_fingerprint: snapshot.credential_fingerprint ?? undefined,
2810 freshness: snapshot.freshness ?? undefined,
2811 header_count: snapshot.header_count,
2812 last_seen_at: snapshot.last_seen_at ?? undefined,
2813 platform: snapshot.platform
2814 });
2815}
2816
2817function serializeBrowserRequestHookSnapshot(snapshot: BrowserBridgeRequestHookSnapshot): JsonObject {
2818 return compactJsonObject({
2819 account: snapshot.account ?? undefined,
2820 credential_fingerprint: snapshot.credential_fingerprint ?? undefined,
2821 endpoint_count: snapshot.endpoint_count,
2822 endpoint_metadata: snapshot.endpoint_metadata.map((entry) =>
2823 compactJsonObject({
2824 first_seen_at: entry.first_seen_at ?? undefined,
2825 last_seen_at: entry.last_seen_at ?? undefined,
2826 method: entry.method ?? undefined,
2827 path: entry.path
2828 })
2829 ),
2830 endpoints: [...snapshot.endpoints],
2831 last_verified_at: snapshot.last_verified_at ?? undefined,
2832 platform: snapshot.platform,
2833 updated_at: snapshot.updated_at
2834 });
2835}
2836
2837function serializeBrowserShellRuntimeSnapshot(snapshot: BrowserBridgeShellRuntimeSnapshot): JsonObject {
2838 return compactJsonObject({
2839 actual: compactJsonObject({
2840 active: snapshot.actual.active ?? undefined,
2841 candidate_tab_id: snapshot.actual.candidate_tab_id ?? undefined,
2842 candidate_url: snapshot.actual.candidate_url ?? undefined,
2843 discarded: snapshot.actual.discarded ?? undefined,
2844 exists: snapshot.actual.exists,
2845 healthy: snapshot.actual.healthy ?? undefined,
2846 hidden: snapshot.actual.hidden ?? undefined,
2847 issue: snapshot.actual.issue ?? undefined,
2848 last_ready_at: snapshot.actual.last_ready_at ?? undefined,
2849 last_seen_at: snapshot.actual.last_seen_at ?? undefined,
2850 status: snapshot.actual.status ?? undefined,
2851 tab_id: snapshot.actual.tab_id ?? undefined,
2852 title: snapshot.actual.title ?? undefined,
2853 url: snapshot.actual.url ?? undefined,
2854 window_id: snapshot.actual.window_id ?? undefined
2855 }),
2856 desired: compactJsonObject({
2857 exists: snapshot.desired.exists,
2858 last_action: snapshot.desired.last_action ?? undefined,
2859 last_action_at: snapshot.desired.last_action_at ?? undefined,
2860 reason: snapshot.desired.reason ?? undefined,
2861 shell_url: snapshot.desired.shell_url ?? undefined,
2862 source: snapshot.desired.source ?? undefined,
2863 updated_at: snapshot.desired.updated_at ?? undefined
2864 }),
2865 drift: compactJsonObject({
2866 aligned: snapshot.drift.aligned,
2867 needs_restore: snapshot.drift.needs_restore,
2868 reason: snapshot.drift.reason ?? undefined,
2869 unexpected_actual: snapshot.drift.unexpected_actual
2870 }),
2871 platform: snapshot.platform
2872 });
2873}
2874
2875function serializeBrowserFinalMessageSnapshot(snapshot: BrowserBridgeFinalMessageSnapshot): JsonObject {
2876 return compactJsonObject({
2877 conversation_id: snapshot.conversation_id ?? undefined,
2878 observed_at: snapshot.observed_at,
2879 organization_id: snapshot.organization_id ?? undefined,
2880 page_title: snapshot.page_title ?? undefined,
2881 page_url: snapshot.page_url ?? undefined,
2882 platform: snapshot.platform,
2883 raw_text: snapshot.raw_text,
2884 shell_page: snapshot.shell_page ?? undefined,
2885 tab_id: snapshot.tab_id ?? undefined
2886 });
2887}
2888
2889function extractChatgptConversationIdFromPageUrl(url: string | null | undefined): string | null {
2890 const normalizedUrl = normalizeOptionalString(url);
2891
2892 if (normalizedUrl == null) {
2893 return null;
2894 }
2895
2896 try {
2897 const parsed = new URL(normalizedUrl, BROWSER_CHATGPT_ROOT_URL);
2898 const pathname = parsed.pathname || "/";
2899 const match = pathname.match(/\/c\/([^/?#]+)/u);
2900
2901 if (match?.[1]) {
2902 return match[1];
2903 }
2904
2905 return normalizeOptionalString(parsed.searchParams.get("conversation_id"));
2906 } catch {
2907 return null;
2908 }
2909}
2910
2911function extractGeminiConversationIdFromPageUrl(url: string | null | undefined): string | null {
2912 const normalizedUrl = normalizeOptionalString(url);
2913
2914 if (normalizedUrl == null) {
2915 return null;
2916 }
2917
2918 try {
2919 const parsed = new URL(normalizedUrl, BROWSER_GEMINI_ROOT_URL);
2920 const pathname = parsed.pathname || "/";
2921 const match = pathname.match(/\/app\/([^/?#]+)/u);
2922
2923 if (match?.[1]) {
2924 return match[1];
2925 }
2926
2927 return normalizeOptionalString(parsed.searchParams.get("conversation_id"));
2928 } catch {
2929 return null;
2930 }
2931}
2932
2933function buildChatgptConversationPath(conversationId: string): string {
2934 return `${BROWSER_CHATGPT_CONVERSATION_PATH}/${encodeURIComponent(conversationId)}`;
2935}
2936
2937function findBrowserCredentialForPlatform(
2938 client: BrowserBridgeClientSnapshot,
2939 platform: string
2940): BrowserBridgeCredentialSnapshot | null {
2941 return client.credentials.find((entry) => entry.platform === platform) ?? null;
2942}
2943
2944function findLatestBrowserFinalMessage(
2945 client: BrowserBridgeClientSnapshot,
2946 platform: string,
2947 conversationId?: string | null
2948): BrowserBridgeFinalMessageSnapshot | null {
2949 const normalizedConversationId = normalizeOptionalString(conversationId);
2950 const finalMessages = Array.isArray(client.final_messages) ? client.final_messages : [];
2951
2952 if (normalizedConversationId != null) {
2953 const directMatch =
2954 finalMessages
2955 .filter((entry) => entry.platform === platform && entry.conversation_id === normalizedConversationId)
2956 .sort((left, right) => right.observed_at - left.observed_at)[0]
2957 ?? null;
2958
2959 if (directMatch != null) {
2960 return directMatch;
2961 }
2962 }
2963
2964 return (
2965 finalMessages
2966 .filter((entry) => entry.platform === platform)
2967 .sort((left, right) => right.observed_at - left.observed_at)[0]
2968 ?? null
2969 );
2970}
2971
2972function resolveBrowserLegacyConversationId(
2973 platform: string,
2974 selection: {
2975 latestFinalMessage: BrowserBridgeFinalMessageSnapshot | null;
2976 shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
2977 },
2978 requestedConversationId?: string | null
2979): string | null {
2980 const normalizedRequestedConversationId = normalizeOptionalString(requestedConversationId);
2981
2982 if (normalizedRequestedConversationId != null) {
2983 return normalizedRequestedConversationId;
2984 }
2985
2986 const finalMessageConversationId = normalizeOptionalString(selection.latestFinalMessage?.conversation_id ?? null);
2987
2988 if (finalMessageConversationId != null) {
2989 return finalMessageConversationId;
2990 }
2991
2992 const actualUrl = normalizeOptionalString(selection.shellRuntime?.actual.url ?? null);
2993 const pageUrl = normalizeOptionalString(selection.latestFinalMessage?.page_url ?? null);
2994
2995 switch (platform) {
2996 case BROWSER_CHATGPT_PLATFORM:
2997 return extractChatgptConversationIdFromPageUrl(actualUrl)
2998 ?? extractChatgptConversationIdFromPageUrl(pageUrl);
2999 case BROWSER_GEMINI_PLATFORM:
3000 return extractGeminiConversationIdFromPageUrl(actualUrl)
3001 ?? extractGeminiConversationIdFromPageUrl(pageUrl);
3002 default:
3003 return null;
3004 }
3005}
3006
3007function buildBrowserLegacyCurrentMessages(
3008 finalMessage: BrowserBridgeFinalMessageSnapshot | null
3009): JsonObject[] {
3010 if (finalMessage == null) {
3011 return [];
3012 }
3013
3014 return [
3015 compactJsonObject({
3016 observed_at: finalMessage.observed_at,
3017 role: "assistant",
3018 text: finalMessage.raw_text
3019 })
3020 ];
3021}
3022
3023function serializeBrowserActionResultItemSnapshot(
3024 snapshot: BrowserBridgeActionResultItemSnapshot
3025): JsonObject {
3026 return compactJsonObject({
3027 delivery_ack:
3028 snapshot.delivery_ack == null
3029 ? undefined
3030 : compactJsonObject({
3031 confirmed_at: snapshot.delivery_ack.confirmed_at ?? undefined,
3032 failed: snapshot.delivery_ack.failed,
3033 level: snapshot.delivery_ack.level,
3034 reason: snapshot.delivery_ack.reason ?? undefined,
3035 status_code: snapshot.delivery_ack.status_code ?? undefined
3036 }),
3037 ok: snapshot.ok,
3038 platform: snapshot.platform ?? undefined,
3039 restored: snapshot.restored ?? undefined,
3040 shell_runtime:
3041 snapshot.shell_runtime == null
3042 ? undefined
3043 : serializeBrowserShellRuntimeSnapshot(snapshot.shell_runtime),
3044 skipped: snapshot.skipped ?? undefined,
3045 tab_id: snapshot.tab_id ?? undefined
3046 });
3047}
3048
3049function serializeBrowserActionResultSnapshot(snapshot: BrowserBridgeActionResultSnapshot): JsonObject {
3050 return compactJsonObject({
3051 accepted: snapshot.accepted,
3052 action: snapshot.action,
3053 completed: snapshot.completed,
3054 failed: snapshot.failed,
3055 reason: snapshot.reason ?? undefined,
3056 received_at: snapshot.received_at,
3057 request_id: snapshot.request_id,
3058 result: compactJsonObject({
3059 actual_count: snapshot.result.actual_count,
3060 desired_count: snapshot.result.desired_count,
3061 drift_count: snapshot.result.drift_count,
3062 failed_count: snapshot.result.failed_count,
3063 ok_count: snapshot.result.ok_count,
3064 platform_count: snapshot.result.platform_count,
3065 restored_count: snapshot.result.restored_count,
3066 skipped_reasons: snapshot.result.skipped_reasons
3067 }),
3068 results: snapshot.results.map(serializeBrowserActionResultItemSnapshot),
3069 shell_runtime: snapshot.shell_runtime.map(serializeBrowserShellRuntimeSnapshot),
3070 target: compactJsonObject({
3071 client_id: snapshot.target.client_id ?? undefined,
3072 connection_id: snapshot.target.connection_id ?? undefined,
3073 platform: snapshot.target.platform ?? undefined,
3074 requested_client_id: snapshot.target.requested_client_id ?? undefined,
3075 requested_platform: snapshot.target.requested_platform ?? undefined
3076 }),
3077 type: snapshot.type
3078 });
3079}
3080
3081function serializeBrowserInstructionIngestSummary(
3082 summary: BrowserBridgeStateSnapshot["instruction_ingest"]["last_ingest"]
3083): JsonObject | null {
3084 if (summary == null) {
3085 return null;
3086 }
3087
3088 return compactJsonObject({
3089 assistant_message_id: summary.assistant_message_id,
3090 block_count: summary.block_count,
3091 conversation_id: summary.conversation_id ?? undefined,
3092 duplicate_instruction_count: summary.duplicate_instruction_count,
3093 duplicate_tools: [...summary.duplicate_tools],
3094 error_block_index: summary.error_block_index ?? undefined,
3095 error_message: summary.error_message ?? undefined,
3096 error_stage: summary.error_stage ?? undefined,
3097 executed_tools: [...summary.executed_tools],
3098 execution_count: summary.execution_count,
3099 execution_failed_count: summary.execution_failed_count,
3100 execution_ok_count: summary.execution_ok_count,
3101 ingested_at: summary.ingested_at,
3102 instruction_count: summary.instruction_count,
3103 instruction_tools: [...summary.instruction_tools],
3104 message_dedupe_key: summary.message_dedupe_key,
3105 observed_at: summary.observed_at ?? undefined,
3106 platform: summary.platform,
3107 source: summary.source,
3108 status: summary.status
3109 });
3110}
3111
3112function serializeBrowserInstructionIngestSnapshot(
3113 snapshot: BrowserBridgeStateSnapshot["instruction_ingest"] | undefined
3114): JsonObject {
3115 const normalized = snapshot ?? createEmptyBrowserInstructionIngestSnapshot();
3116
3117 return {
3118 last_execute: serializeBrowserInstructionIngestSummary(normalized.last_execute),
3119 last_ingest: serializeBrowserInstructionIngestSummary(normalized.last_ingest),
3120 recent_executes: normalized.recent_executes.map((summary) =>
3121 serializeBrowserInstructionIngestSummary(summary)
3122 ),
3123 recent_ingests: normalized.recent_ingests.map((summary) =>
3124 serializeBrowserInstructionIngestSummary(summary)
3125 )
3126 };
3127}
3128
3129function serializeBrowserDeliverySnapshot(
3130 snapshot: BrowserBridgeStateSnapshot["delivery"] | undefined
3131): JsonObject {
3132 const normalized = snapshot ?? {
3133 activeSessionCount: 0,
3134 lastRoute: null,
3135 lastSession: null
3136 };
3137
3138 return compactJsonObject({
3139 active_session_count: normalized.activeSessionCount,
3140 last_route:
3141 normalized.lastRoute == null
3142 ? null
3143 : compactJsonObject({
3144 assistant_message_id: normalized.lastRoute.assistantMessageId,
3145 conversation_id: normalized.lastRoute.conversationId ?? undefined,
3146 observed_at: normalized.lastRoute.observedAt,
3147 organization_id: normalized.lastRoute.organizationId ?? undefined,
3148 page_title: normalized.lastRoute.pageTitle ?? undefined,
3149 page_url: normalized.lastRoute.pageUrl ?? undefined,
3150 platform: normalized.lastRoute.platform,
3151 shell_page: normalized.lastRoute.shellPage,
3152 tab_id: normalized.lastRoute.tabId ?? undefined
3153 }),
3154 last_session:
3155 normalized.lastSession == null
3156 ? null
3157 : compactJsonObject({
3158 auto_send: normalized.lastSession.autoSend,
3159 client_id: normalized.lastSession.clientId ?? undefined,
3160 completed_at: normalized.lastSession.completedAt ?? undefined,
3161 connection_id: normalized.lastSession.connectionId ?? undefined,
3162 conversation_id: normalized.lastSession.conversationId ?? undefined,
3163 created_at: normalized.lastSession.createdAt,
3164 delivery_mode: normalized.lastSession.mode ?? undefined,
3165 execution_count: normalized.lastSession.executionCount,
3166 failed_at: normalized.lastSession.failedAt ?? undefined,
3167 failed_reason: normalized.lastSession.failedReason ?? undefined,
3168 inject_completed_at: normalized.lastSession.injectCompletedAt ?? undefined,
3169 inject_request_id: normalized.lastSession.injectRequestId ?? undefined,
3170 inject_started_at: normalized.lastSession.injectStartedAt ?? undefined,
3171 message_char_count: normalized.lastSession.messageCharCount,
3172 message_line_count: normalized.lastSession.messageLineCount,
3173 message_line_limit: normalized.lastSession.messageLineLimit,
3174 message_truncated: normalized.lastSession.messageTruncated,
3175 plan_id: normalized.lastSession.planId,
3176 platform: normalized.lastSession.platform,
3177 proxy_completed_at: normalized.lastSession.proxyCompletedAt ?? undefined,
3178 proxy_failed_reason: normalized.lastSession.proxyFailedReason ?? undefined,
3179 proxy_request_id: normalized.lastSession.proxyRequestId ?? undefined,
3180 proxy_started_at: normalized.lastSession.proxyStartedAt ?? undefined,
3181 round_id: normalized.lastSession.roundId,
3182 send_completed_at: normalized.lastSession.sendCompletedAt ?? undefined,
3183 send_request_id: normalized.lastSession.sendRequestId ?? undefined,
3184 send_started_at: normalized.lastSession.sendStartedAt ?? undefined,
3185 source_line_count: normalized.lastSession.sourceLineCount,
3186 stage: normalized.lastSession.stage,
3187 target_organization_id: normalized.lastSession.targetOrganizationId ?? undefined,
3188 target_page_title: normalized.lastSession.targetPageTitle ?? undefined,
3189 target_page_url: normalized.lastSession.targetPageUrl ?? undefined,
3190 target_shell_page: normalized.lastSession.targetShellPage,
3191 target_tab_id: normalized.lastSession.targetTabId ?? undefined,
3192 trace_id: normalized.lastSession.traceId
3193 })
3194 });
3195}
3196
3197function serializeBrowserClientSnapshot(snapshot: BrowserBridgeClientSnapshot): JsonObject {
3198 return {
3199 client_id: snapshot.client_id,
3200 connected_at: snapshot.connected_at,
3201 connection_id: snapshot.connection_id,
3202 credentials: snapshot.credentials.map(serializeBrowserCredentialSnapshot),
3203 last_action_result:
3204 snapshot.last_action_result == null
3205 ? null
3206 : serializeBrowserActionResultSnapshot(snapshot.last_action_result),
3207 last_message_at: snapshot.last_message_at,
3208 node_category: snapshot.node_category,
3209 node_platform: snapshot.node_platform,
3210 node_type: snapshot.node_type,
3211 request_hooks: snapshot.request_hooks.map(serializeBrowserRequestHookSnapshot),
3212 shell_runtime: snapshot.shell_runtime.map(serializeBrowserShellRuntimeSnapshot)
3213 };
3214}
3215
3216function serializeBrowserAutomationConversationSnapshot(
3217 snapshot: BrowserBridgeStateSnapshot["automation_conversations"][number]
3218): JsonObject {
3219 return compactJsonObject({
3220 active_link:
3221 snapshot.active_link == null
3222 ? null
3223 : compactJsonObject({
3224 client_id: snapshot.active_link.client_id ?? undefined,
3225 link_id: snapshot.active_link.link_id,
3226 local_conversation_id: snapshot.active_link.local_conversation_id,
3227 page_title: snapshot.active_link.page_title ?? undefined,
3228 page_url: snapshot.active_link.page_url ?? undefined,
3229 remote_conversation_id: snapshot.active_link.remote_conversation_id ?? undefined,
3230 route_path: snapshot.active_link.route_path ?? undefined,
3231 route_pattern: snapshot.active_link.route_pattern ?? undefined,
3232 target_id: snapshot.active_link.target_id ?? undefined,
3233 target_kind: snapshot.active_link.target_kind ?? undefined,
3234 updated_at: snapshot.active_link.updated_at
3235 }),
3236 automation_status: snapshot.automation_status,
3237 execution_state: snapshot.execution_state,
3238 last_error: snapshot.last_error ?? undefined,
3239 last_non_paused_automation_status: snapshot.last_non_paused_automation_status,
3240 local_conversation_id: snapshot.local_conversation_id,
3241 pause_reason: snapshot.pause_reason ?? undefined,
3242 paused_at: snapshot.paused_at ?? undefined,
3243 platform: snapshot.platform,
3244 remote_conversation_id: snapshot.remote_conversation_id ?? undefined,
3245 updated_at: snapshot.updated_at
3246 });
3247}
3248
3249function extractArrayObjects(value: JsonValue | string | null, fieldNames: readonly string[]): JsonObject[] {
3250 if (Array.isArray(value)) {
3251 return value.filter((entry): entry is JsonObject => isJsonObject(entry));
3252 }
3253
3254 if (!isJsonObject(value)) {
3255 return [];
3256 }
3257
3258 for (const fieldName of fieldNames) {
3259 const directValue = value[fieldName];
3260
3261 if (Array.isArray(directValue)) {
3262 return directValue.filter((entry): entry is JsonObject => isJsonObject(entry));
3263 }
3264 }
3265
3266 const dataObject = readJsonObjectField(value, "data");
3267
3268 if (dataObject != null) {
3269 for (const fieldName of fieldNames) {
3270 const directValue = dataObject[fieldName];
3271
3272 if (Array.isArray(directValue)) {
3273 return directValue.filter((entry): entry is JsonObject => isJsonObject(entry));
3274 }
3275 }
3276 }
3277
3278 const dataValue = value.data;
3279
3280 if (Array.isArray(dataValue)) {
3281 return dataValue.filter((entry): entry is JsonObject => isJsonObject(entry));
3282 }
3283
3284 return [];
3285}
3286
3287function summarizeClaudeOrganization(record: JsonObject): ClaudeOrganizationSummary | null {
3288 const organizationId =
3289 readStringValue(record, "uuid")
3290 ?? readStringValue(record, "id")
3291 ?? readStringValue(record, "organization_uuid")
3292 ?? readStringValue(record, "organizationId");
3293
3294 if (organizationId == null) {
3295 return null;
3296 }
3297
3298 return {
3299 id: organizationId,
3300 name:
3301 readStringValue(record, "name")
3302 ?? readStringValue(record, "display_name")
3303 ?? readStringValue(record, "slug"),
3304 raw: record
3305 };
3306}
3307
3308function summarizeClaudeConversation(record: JsonObject): ClaudeConversationSummary | null {
3309 const conversationId =
3310 readStringValue(record, "uuid")
3311 ?? readStringValue(record, "id")
3312 ?? readStringValue(record, "conversation_uuid")
3313 ?? readStringValue(record, "chat_conversation_uuid");
3314
3315 if (conversationId == null) {
3316 return null;
3317 }
3318
3319 return {
3320 created_at: normalizeTimestampLike(
3321 record.created_at
3322 ?? record.createdAt
3323 ?? record.inserted_at
3324 ?? record.insertedAt
3325 ),
3326 id: conversationId,
3327 raw: record,
3328 title:
3329 readStringValue(record, "name")
3330 ?? readStringValue(record, "title")
3331 ?? readStringValue(record, "summary"),
3332 updated_at: normalizeTimestampLike(
3333 record.updated_at
3334 ?? record.updatedAt
3335 ?? record.last_updated_at
3336 ?? record.lastUpdatedAt
3337 )
3338 };
3339}
3340
3341function pickLatestClaudeConversation(
3342 conversations: ClaudeConversationSummary[]
3343): ClaudeConversationSummary | null {
3344 if (conversations.length === 0) {
3345 return null;
3346 }
3347
3348 const currentConversation = conversations.find((conversation) =>
3349 readUnknownBoolean(asUnknownRecord(conversation.raw), ["is_current", "current", "selected", "active"]) === true
3350 );
3351
3352 if (currentConversation != null) {
3353 return currentConversation;
3354 }
3355
3356 return [...conversations].sort((left, right) => {
3357 const rightTimestamp = right.updated_at ?? right.created_at ?? 0;
3358 const leftTimestamp = left.updated_at ?? left.created_at ?? 0;
3359 return rightTimestamp - leftTimestamp;
3360 })[0] ?? null;
3361}
3362
3363function extractTextFragments(value: JsonValue | null): string[] {
3364 if (typeof value === "string") {
3365 const normalized = value.trim();
3366 return normalized === "" ? [] : [normalized];
3367 }
3368
3369 if (Array.isArray(value)) {
3370 return value.flatMap((entry) => extractTextFragments(entry));
3371 }
3372
3373 if (!isJsonObject(value)) {
3374 return [];
3375 }
3376
3377 const directKeys = ["text", "value", "content", "message", "markdown", "completion"];
3378 const directValues = directKeys.flatMap((fieldName) => extractTextFragments(value[fieldName] as JsonValue));
3379
3380 if (directValues.length > 0) {
3381 return directValues;
3382 }
3383
3384 return Object.values(value).flatMap((entry) => extractTextFragments(entry as JsonValue));
3385}
3386
3387function normalizeClaudeMessageRole(record: JsonObject): string | null {
3388 const author = asUnknownRecord(record.author);
3389 const rawRole =
3390 readStringValue(record, "role")
3391 ?? readStringValue(record, "sender")
3392 ?? readUnknownString(author, ["role", "sender"])
3393 ?? readStringValue(record, "type");
3394
3395 if (rawRole == null) {
3396 return null;
3397 }
3398
3399 const normalized = rawRole.toLowerCase();
3400
3401 if (normalized === "human") {
3402 return "user";
3403 }
3404
3405 if (normalized === "assistant") {
3406 return "assistant";
3407 }
3408
3409 if (normalized === "user") {
3410 return "user";
3411 }
3412
3413 return normalized;
3414}
3415
3416function collectClaudeMessages(value: JsonValue | string | null): JsonObject[] {
3417 const rootValue = typeof value === "string" ? parseBrowserProxyBody(value) : value;
3418 const messages: JsonObject[] = [];
3419 const seen = new Set<string>();
3420
3421 const walk = (node: JsonValue | null, depth: number): void => {
3422 if (node == null || depth > 12) {
3423 return;
3424 }
3425
3426 if (Array.isArray(node)) {
3427 for (const entry of node) {
3428 walk(entry, depth + 1);
3429 }
3430
3431 return;
3432 }
3433
3434 if (!isJsonObject(node)) {
3435 return;
3436 }
3437
3438 const role = normalizeClaudeMessageRole(node);
3439 const content = extractTextFragments(node).join("\n\n").trim();
3440
3441 if (role != null && content !== "") {
3442 const id =
3443 readStringValue(node, "uuid")
3444 ?? readStringValue(node, "id")
3445 ?? readStringValue(node, "message_uuid");
3446 const dedupeKey = `${id ?? "anonymous"}|${role}|${content}`;
3447
3448 if (!seen.has(dedupeKey)) {
3449 seen.add(dedupeKey);
3450 messages.push(compactJsonObject({
3451 content,
3452 created_at: normalizeTimestampLike(
3453 node.created_at
3454 ?? node.createdAt
3455 ?? node.updated_at
3456 ?? node.updatedAt
3457 ) ?? undefined,
3458 id: id ?? undefined,
3459 role
3460 }));
3461 }
3462 }
3463
3464 for (const entry of Object.values(node)) {
3465 walk(entry as JsonValue, depth + 1);
3466 }
3467 };
3468
3469 walk(rootValue as JsonValue | null, 0);
3470 return messages;
3471}
3472
3473function buildClaudeRequestPath(template: string, organizationId: string, conversationId?: string): string {
3474 return template
3475 .replace("{id}", encodeURIComponent(organizationId))
3476 .replace("{id}", encodeURIComponent(conversationId ?? ""));
3477}
3478
3479async function requestBrowserProxy(
3480 context: LocalApiRequestContext,
3481 input: {
3482 action: string;
3483 body?: JsonValue;
3484 clientId?: string | null;
3485 conversationId?: string | null;
3486 headers?: Record<string, string>;
3487 id?: string | null;
3488 method: string;
3489 path: string;
3490 platform: string;
3491 timeoutMs?: number;
3492 }
3493): Promise<ParsedBrowserProxyResponse> {
3494 const bridge = requireBrowserBridge(context);
3495 const requestId = normalizeOptionalString(input.id) ?? randomUUID();
3496 const abortSignal = context.request.signal;
3497 const cancelOnAbort = () => {
3498 try {
3499 bridge.cancelApiRequest({
3500 clientId: input.clientId,
3501 platform: input.platform,
3502 reason: "request_aborted",
3503 requestId
3504 });
3505 } catch {
3506 // Best-effort cancel: the bridge request may have already completed or failed.
3507 }
3508 };
3509
3510 if (abortSignal != null && !abortSignal.aborted) {
3511 abortSignal.addEventListener("abort", cancelOnAbort, {
3512 once: true
3513 });
3514 }
3515
3516 let apiResponse: BrowserBridgeApiResponse;
3517
3518 try {
3519 const apiRequestPromise = bridge.apiRequest({
3520 body: input.body,
3521 clientId: input.clientId,
3522 conversationId: input.conversationId,
3523 headers: input.headers,
3524 id: requestId,
3525 method: input.method,
3526 path: input.path,
3527 platform: input.platform,
3528 timeoutMs: input.timeoutMs ?? DEFAULT_BROWSER_PROXY_TIMEOUT_MS
3529 });
3530 if (abortSignal?.aborted) {
3531 cancelOnAbort();
3532 }
3533 apiResponse = await apiRequestPromise;
3534 } catch (error) {
3535 throw createBrowserBridgeHttpError(input.action, error);
3536 } finally {
3537 abortSignal?.removeEventListener("abort", cancelOnAbort);
3538 }
3539
3540 const parsedBody = parseBrowserProxyBody(apiResponse.body);
3541 const status = apiResponse.status;
3542
3543 if (apiResponse.ok === false || (status != null && status >= 400)) {
3544 throw new LocalApiHttpError(
3545 status != null && status >= 400 && status < 600 ? status : 502,
3546 "browser_upstream_error",
3547 `Browser proxy request failed for ${input.method.toUpperCase()} ${input.path}.`,
3548 compactJsonObject({
3549 bridge_client_id: apiResponse.clientId,
3550 bridge_request_id: apiResponse.id,
3551 platform: input.platform,
3552 upstream_body: parsedBody ?? undefined,
3553 upstream_error: apiResponse.error ?? undefined,
3554 upstream_status: status ?? undefined
3555 })
3556 );
3557 }
3558
3559 return {
3560 apiResponse,
3561 body: parsedBody,
3562 rootObject: isJsonObject(parsedBody) ? parsedBody : null
3563 };
3564}
3565
3566async function resolveClaudeOrganization(
3567 context: LocalApiRequestContext,
3568 selection: ClaudeBrowserSelection,
3569 requestedOrganizationId?: string | null,
3570 timeoutMs?: number
3571): Promise<ClaudeOrganizationSummary> {
3572 const normalizedRequestedOrganizationId = normalizeOptionalString(requestedOrganizationId);
3573 const result = await requestBrowserProxy(context, {
3574 action: "claude organization resolve",
3575 clientId: selection.client?.client_id,
3576 method: "GET",
3577 path: BROWSER_CLAUDE_ORGANIZATIONS_PATH,
3578 platform: BROWSER_CLAUDE_PLATFORM,
3579 timeoutMs
3580 });
3581 const organizations = extractArrayObjects(result.body, ["organizations", "items", "entries"])
3582 .map((entry) => summarizeClaudeOrganization(entry))
3583 .filter((entry): entry is ClaudeOrganizationSummary => entry != null);
3584
3585 if (organizations.length === 0) {
3586 throw new LocalApiHttpError(
3587 502,
3588 "browser_upstream_invalid_response",
3589 "Claude organization list returned no usable organizations.",
3590 {
3591 path: BROWSER_CLAUDE_ORGANIZATIONS_PATH
3592 }
3593 );
3594 }
3595
3596 if (normalizedRequestedOrganizationId != null) {
3597 const matchedOrganization = organizations.find((entry) => entry.id === normalizedRequestedOrganizationId);
3598
3599 if (matchedOrganization == null) {
3600 throw new LocalApiHttpError(
3601 404,
3602 "not_found",
3603 `Claude organization "${normalizedRequestedOrganizationId}" was not found.`,
3604 {
3605 organization_id: normalizedRequestedOrganizationId,
3606 resource: "claude_organization"
3607 }
3608 );
3609 }
3610
3611 return matchedOrganization;
3612 }
3613
3614 const currentOrganization = organizations.find((entry) =>
3615 readUnknownBoolean(asUnknownRecord(entry.raw), ["is_default", "default", "selected", "active"]) === true
3616 );
3617
3618 return currentOrganization ?? organizations[0]!;
3619}
3620
3621function resolveBrowserRequestId(requestId?: string | null): string {
3622 return normalizeOptionalString(requestId) ?? randomUUID();
3623}
3624
3625async function beginBrowserRequestLease(
3626 context: LocalApiRequestContext,
3627 target: {
3628 clientId: string;
3629 platform: string;
3630 },
3631 requestId: string
3632): Promise<BrowserRequestPolicyLease> {
3633 try {
3634 return await resolveBrowserRequestPolicy(context).beginRequest(target, requestId);
3635 } catch (error) {
3636 if (error instanceof BrowserRequestPolicyError) {
3637 throw createBrowserPolicyHttpError("browser request", error);
3638 }
3639
3640 throw error;
3641 }
3642}
3643
3644async function listClaudeConversations(
3645 context: LocalApiRequestContext,
3646 selection: ClaudeBrowserSelection,
3647 organizationId: string,
3648 timeoutMs?: number
3649): Promise<ClaudeConversationSummary[]> {
3650 const result = await requestBrowserProxy(context, {
3651 action: "claude conversation list",
3652 clientId: selection.client?.client_id,
3653 method: "GET",
3654 path: buildClaudeRequestPath(BROWSER_CLAUDE_CONVERSATIONS_PATH, organizationId),
3655 platform: BROWSER_CLAUDE_PLATFORM,
3656 timeoutMs
3657 });
3658
3659 return extractArrayObjects(result.body, ["chat_conversations", "conversations", "items", "entries"])
3660 .map((entry) => summarizeClaudeConversation(entry))
3661 .filter((entry): entry is ClaudeConversationSummary => entry != null);
3662}
3663
3664async function createClaudeConversation(
3665 context: LocalApiRequestContext,
3666 selection: ClaudeBrowserSelection,
3667 organizationId: string,
3668 timeoutMs?: number
3669): Promise<ClaudeConversationSummary> {
3670 const result = await requestBrowserProxy(context, {
3671 action: "claude conversation create",
3672 body: {},
3673 clientId: selection.client?.client_id,
3674 method: "POST",
3675 path: buildClaudeRequestPath(BROWSER_CLAUDE_CONVERSATIONS_PATH, organizationId),
3676 platform: BROWSER_CLAUDE_PLATFORM,
3677 timeoutMs
3678 });
3679 const conversationRecord =
3680 readJsonObjectField(result.rootObject, "chat_conversation")
3681 ?? readJsonObjectField(result.rootObject, "conversation")
3682 ?? readJsonObjectField(readJsonObjectField(result.rootObject, "data"), "chat_conversation")
3683 ?? readJsonObjectField(readJsonObjectField(result.rootObject, "data"), "conversation")
3684 ?? result.rootObject;
3685 const conversation = conversationRecord == null ? null : summarizeClaudeConversation(conversationRecord);
3686
3687 if (conversation == null) {
3688 throw new LocalApiHttpError(
3689 502,
3690 "browser_upstream_invalid_response",
3691 "Claude conversation create returned no usable conversation id.",
3692 {
3693 organization_id: organizationId
3694 }
3695 );
3696 }
3697
3698 return conversation;
3699}
3700
3701async function resolveClaudeConversation(
3702 context: LocalApiRequestContext,
3703 selection: ClaudeBrowserSelection,
3704 organizationId: string,
3705 options: {
3706 conversationId?: string | null;
3707 createIfMissing?: boolean;
3708 timeoutMs?: number;
3709 } = {}
3710): Promise<ClaudeConversationSummary> {
3711 const normalizedConversationId = normalizeOptionalString(options.conversationId);
3712
3713 if (normalizedConversationId != null) {
3714 return {
3715 created_at: null,
3716 id: normalizedConversationId,
3717 raw: null,
3718 title: null,
3719 updated_at: null
3720 };
3721 }
3722
3723 const conversations = await listClaudeConversations(context, selection, organizationId, options.timeoutMs);
3724 const currentConversation = pickLatestClaudeConversation(conversations);
3725
3726 if (currentConversation != null) {
3727 return currentConversation;
3728 }
3729
3730 if (options.createIfMissing) {
3731 return await createClaudeConversation(context, selection, organizationId, options.timeoutMs);
3732 }
3733
3734 throw new LocalApiHttpError(
3735 404,
3736 "not_found",
3737 "No Claude conversation is available for the selected organization.",
3738 {
3739 organization_id: organizationId,
3740 resource: "claude_conversation"
3741 }
3742 );
3743}
3744
3745async function readClaudeConversationCurrentData(
3746 context: LocalApiRequestContext,
3747 input: {
3748 clientId?: string | null;
3749 conversationId?: string | null;
3750 createIfMissing?: boolean;
3751 organizationId?: string | null;
3752 timeoutMs?: number;
3753 } = {}
3754): Promise<{
3755 client: BrowserBridgeClientSnapshot;
3756 conversation: ClaudeConversationSummary;
3757 detail: ParsedBrowserProxyResponse;
3758 organization: ClaudeOrganizationSummary;
3759 page: JsonObject;
3760}> {
3761 const selection = ensureClaudeBridgeReady(
3762 selectClaudeBrowserClient(loadBrowserState(context), input.clientId),
3763 input.clientId
3764 );
3765 const organization = await resolveClaudeOrganization(
3766 context,
3767 selection,
3768 input.organizationId,
3769 input.timeoutMs
3770 );
3771 const conversation = await resolveClaudeConversation(
3772 context,
3773 selection,
3774 organization.id,
3775 {
3776 conversationId: input.conversationId,
3777 createIfMissing: input.createIfMissing,
3778 timeoutMs: input.timeoutMs
3779 }
3780 );
3781 const detail = await requestBrowserProxy(context, {
3782 action: "claude conversation read",
3783 clientId: selection.client.client_id,
3784 method: "GET",
3785 path: buildClaudeRequestPath(BROWSER_CLAUDE_CONVERSATION_PATH, organization.id, conversation.id),
3786 platform: BROWSER_CLAUDE_PLATFORM,
3787 timeoutMs: input.timeoutMs
3788 });
3789
3790 return {
3791 client: selection.client,
3792 conversation,
3793 detail,
3794 organization,
3795 page: compactJsonObject({
3796 client_id: selection.client.client_id,
3797 credentials: serializeBrowserCredentialSnapshot(selection.credential),
3798 request_hooks:
3799 selection.requestHook == null
3800 ? undefined
3801 : serializeBrowserRequestHookSnapshot(selection.requestHook),
3802 shell_runtime:
3803 selection.shellRuntime == null
3804 ? undefined
3805 : serializeBrowserShellRuntimeSnapshot(selection.shellRuntime)
3806 })
3807 };
3808}
3809
3810async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<JsonObject> {
3811 const browserState = loadBrowserState(context);
3812 const policySnapshot = resolveBrowserRequestPolicy(context).getSnapshot();
3813 const filters = readBrowserStatusFilters(context.url);
3814 const records = await listBrowserMergedRecords(context, browserState, filters);
3815 const currentClient =
3816 browserState.clients.find((client) => client.client_id === browserState.active_client_id)
3817 ?? [...browserState.clients].sort((left, right) => right.last_message_at - left.last_message_at)[0]
3818 ?? null;
3819 const claudeSelection = selectClaudeBrowserClient(browserState);
3820 const runtimeSnapshotsByKey = new Map<string, BrowserBridgeShellRuntimeSnapshot>();
3821 const statusCounts = {
3822 fresh: 0,
3823 stale: 0,
3824 lost: 0
3825 };
3826
3827 for (const client of browserState.clients) {
3828 for (const runtime of client.shell_runtime) {
3829 runtimeSnapshotsByKey.set(`${client.client_id}\u0000${runtime.platform}`, runtime);
3830 }
3831 }
3832
3833 for (const record of records) {
3834 const status = resolveBrowserRecordStatus(record);
3835
3836 if (status != null) {
3837 statusCounts[status] += 1;
3838 }
3839 }
3840
3841 return {
3842 automation_conversations: browserState.automation_conversations.map(
3843 serializeBrowserAutomationConversationSnapshot
3844 ),
3845 bridge: {
3846 active_client_id: browserState.active_client_id,
3847 active_connection_id: browserState.active_connection_id,
3848 client_count: browserState.client_count,
3849 clients: browserState.clients.map(serializeBrowserClientSnapshot),
3850 status: browserState.client_count > 0 ? "connected" : "disconnected",
3851 transport: "local_browser_ws",
3852 ws_path: browserState.ws_path,
3853 ws_url: browserState.ws_url
3854 },
3855 delivery: serializeBrowserDeliverySnapshot(browserState.delivery),
3856 instruction_ingest: serializeBrowserInstructionIngestSnapshot(browserState.instruction_ingest),
3857 current_client: currentClient == null ? null : serializeBrowserClientSnapshot(currentClient),
3858 claude: {
3859 credentials:
3860 claudeSelection.credential == null
3861 ? null
3862 : serializeBrowserCredentialSnapshot(claudeSelection.credential),
3863 current_client_id: claudeSelection.client?.client_id ?? null,
3864 open_url: BROWSER_CLAUDE_ROOT_URL,
3865 platform: BROWSER_CLAUDE_PLATFORM,
3866 ready: claudeSelection.client != null && claudeSelection.credential != null,
3867 request_hooks:
3868 claudeSelection.requestHook == null
3869 ? null
3870 : serializeBrowserRequestHookSnapshot(claudeSelection.requestHook),
3871 shell_runtime:
3872 claudeSelection.shellRuntime == null
3873 ? null
3874 : serializeBrowserShellRuntimeSnapshot(claudeSelection.shellRuntime),
3875 supported: true
3876 },
3877 filters: summarizeBrowserFilters(filters),
3878 policy: {
3879 defaults: {
3880 backoff: policySnapshot.defaults.backoff,
3881 circuit_breaker: policySnapshot.defaults.circuitBreaker,
3882 concurrency: policySnapshot.defaults.concurrency,
3883 jitter: policySnapshot.defaults.jitter,
3884 rate_limit: policySnapshot.defaults.rateLimit,
3885 stale_lease: {
3886 idle_ms: policySnapshot.defaults.staleLease.idleMs,
3887 sweep_interval_ms: policySnapshot.defaults.staleLease.sweepIntervalMs
3888 },
3889 stream: {
3890 idle_timeout_ms: policySnapshot.defaults.stream.idleTimeoutMs,
3891 max_buffered_bytes: policySnapshot.defaults.stream.maxBufferedBytes,
3892 max_buffered_events: policySnapshot.defaults.stream.maxBufferedEvents,
3893 open_timeout_ms: policySnapshot.defaults.stream.openTimeoutMs
3894 }
3895 },
3896 platforms: policySnapshot.platforms.map((entry) => compactJsonObject({
3897 last_dispatched_at: entry.lastDispatchedAt ?? undefined,
3898 platform: entry.platform,
3899 recent_dispatch_count: entry.recentDispatchCount,
3900 waiting: entry.waiting
3901 })),
3902 targets: policySnapshot.targets.map((entry) => compactJsonObject({
3903 backoff_until: entry.backoffUntil ?? undefined,
3904 circuit_retry_at: entry.circuitRetryAt ?? undefined,
3905 circuit_state: entry.circuitState,
3906 client_id: entry.clientId,
3907 consecutive_failures: entry.consecutiveFailures,
3908 in_flight: entry.inFlight,
3909 last_activity_at: entry.lastActivityAt ?? undefined,
3910 last_activity_reason: entry.lastActivityReason ?? undefined,
3911 last_error: entry.lastError ?? undefined,
3912 last_failure_at: entry.lastFailureAt ?? undefined,
3913 last_stale_sweep_at: entry.lastStaleSweepAt ?? undefined,
3914 last_stale_sweep_idle_ms: entry.lastStaleSweepIdleMs ?? undefined,
3915 last_stale_sweep_reason: entry.lastStaleSweepReason ?? undefined,
3916 last_stale_sweep_request_id: entry.lastStaleSweepRequestId ?? undefined,
3917 last_success_at: entry.lastSuccessAt ?? undefined,
3918 platform: entry.platform,
3919 stale_sweep_count: entry.staleSweepCount,
3920 waiting: entry.waiting
3921 }))
3922 },
3923 records: records.map((record) =>
3924 serializeBrowserMergedRecord(record, browserState.active_client_id)
3925 ),
3926 summary: {
3927 automation_conversation_count: browserState.automation_conversations.length,
3928 active_records: records.filter((record) => record.activeConnection != null).length,
3929 matched_records: records.length,
3930 persisted_only_records: records.filter((record) => record.view === "persisted_only").length,
3931 runtime_counts: {
3932 actual: [...runtimeSnapshotsByKey.values()].filter((runtime) => runtime.actual.exists).length,
3933 desired: [...runtimeSnapshotsByKey.values()].filter((runtime) => runtime.desired.exists).length,
3934 drift: [...runtimeSnapshotsByKey.values()].filter((runtime) => runtime.drift.aligned === false).length
3935 },
3936 status_counts: statusCounts
3937 }
3938 };
3939}
3940
3941function buildCodexRouteCatalog(): JsonObject[] {
3942 return LOCAL_API_ROUTES.filter((route) => isCodexRoute(route)).map((route) => describeRoute(route));
3943}
3944
3945function buildCodexProxyNotes(snapshot: ConductorRuntimeApiSnapshot): string[] {
3946 if (getSnapshotCodexdLocalApiBase(snapshot) == null) {
3947 return [
3948 "Codex routes stay unavailable until independent codexd is configured.",
3949 `Set ${CODEXD_LOCAL_API_ENV} to the codexd local HTTP base URL before using /v1/codex.`
3950 ];
3951 }
3952
3953 return [
3954 "All /v1/codex routes proxy the independent codexd daemon over local HTTP.",
3955 "This surface is session-oriented: status, session list/read/create, and turn create."
3956 ];
3957}
3958
3959function buildCodexProxyData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
3960 const codexdLocalApiBase = getSnapshotCodexdLocalApiBase(snapshot);
3961 return {
3962 auth_mode: "local_network_only",
3963 backend: "independent_codexd",
3964 enabled: codexdLocalApiBase != null,
3965 route_prefix: "/v1/codex",
3966 routes: buildCodexRouteCatalog(),
3967 target_base_url: codexdLocalApiBase,
3968 transport: "local_http",
3969 notes: buildCodexProxyNotes(snapshot)
3970 };
3971}
3972
3973function buildCodexStatusData(data: JsonValue, proxyTargetBaseUrl: string): JsonObject {
3974 const root = asJsonObject(data);
3975 const service = readJsonObjectField(root, "service");
3976 const snapshot = readJsonObjectField(root, "snapshot");
3977 const identity = readJsonObjectField(snapshot, "identity");
3978 const daemon = readJsonObjectField(snapshot, "daemon");
3979 const child = readJsonObjectField(daemon, "child");
3980 const sessionRegistry = readJsonObjectField(snapshot, "sessionRegistry");
3981 const recentEvents = readJsonObjectField(snapshot, "recentEvents");
3982 const sessions = readJsonArrayField(sessionRegistry, "sessions");
3983 const events = readJsonArrayField(recentEvents, "events");
3984
3985 return {
3986 backend: "independent_codexd",
3987 daemon: {
3988 daemon_id: readStringValue(identity, "daemonId"),
3989 node_id: readStringValue(identity, "nodeId"),
3990 version: readStringValue(identity, "version"),
3991 started: readBooleanValue(daemon, "started"),
3992 started_at: readStringValue(daemon, "startedAt"),
3993 updated_at: readStringValue(daemon, "updatedAt"),
3994 child: {
3995 endpoint: readStringValue(child, "endpoint"),
3996 last_error: readStringValue(child, "lastError"),
3997 pid: readNumberValue(child, "pid"),
3998 status: readStringValue(child, "status"),
3999 strategy: readStringValue(child, "strategy")
4000 }
4001 },
4002 proxy: {
4003 route_prefix: "/v1/codex",
4004 target_base_url: proxyTargetBaseUrl,
4005 transport: "local_http"
4006 },
4007 recent_events: {
4008 count: events.length,
4009 updated_at: readStringValue(recentEvents, "updatedAt")
4010 },
4011 routes: buildCodexRouteCatalog(),
4012 service: {
4013 event_stream_url: readStringValue(service, "eventStreamUrl"),
4014 listening: readBooleanValue(service, "listening"),
4015 resolved_base_url: readStringValue(service, "resolvedBaseUrl"),
4016 websocket_clients: readNumberValue(service, "websocketClients")
4017 },
4018 sessions: {
4019 active_count: sessions.filter((entry) => readStringValue(asJsonObject(entry), "status") === "active").length,
4020 count: sessions.length,
4021 updated_at: readStringValue(sessionRegistry, "updatedAt")
4022 },
4023 surface: ["status", "sessions", "turn"],
4024 notes: [
4025 "This conductor route proxies the independent codexd daemon.",
4026 "Only session/status/turn operations are exposed on this surface."
4027 ]
4028 };
4029}
4030
4031async function requestCodexd(
4032 context: LocalApiRequestContext,
4033 input: {
4034 body?: JsonObject;
4035 method: LocalApiRouteMethod;
4036 path: string;
4037 }
4038): Promise<{ data: JsonValue; status: number }> {
4039 const codexdLocalApiBase =
4040 normalizeOptionalString(context.codexdLocalApiBase) ?? getSnapshotCodexdLocalApiBase(context.snapshotLoader());
4041
4042 if (codexdLocalApiBase == null) {
4043 throw new LocalApiHttpError(
4044 503,
4045 "codexd_not_configured",
4046 "Independent codexd local API is not configured for /v1/codex routes.",
4047 {
4048 env_var: CODEXD_LOCAL_API_ENV
4049 }
4050 );
4051 }
4052
4053 let response: Response;
4054
4055 try {
4056 response = await context.fetchImpl(`${codexdLocalApiBase}${input.path}`, {
4057 method: input.method,
4058 headers: input.body
4059 ? {
4060 accept: "application/json",
4061 "content-type": "application/json"
4062 }
4063 : {
4064 accept: "application/json"
4065 },
4066 body: input.body ? JSON.stringify(input.body) : undefined,
4067 signal: context.request.signal
4068 });
4069 } catch (error) {
4070 throw new LocalApiHttpError(
4071 503,
4072 "codexd_unavailable",
4073 `Independent codexd is unavailable at ${codexdLocalApiBase}.`,
4074 {
4075 cause: error instanceof Error ? error.message : String(error),
4076 target_base_url: codexdLocalApiBase,
4077 upstream_path: input.path
4078 }
4079 );
4080 }
4081
4082 const rawBody = await response.text();
4083 let parsedBody: JsonValue | null = null;
4084
4085 if (rawBody !== "") {
4086 try {
4087 parsedBody = JSON.parse(rawBody) as JsonValue;
4088 } catch {
4089 throw new LocalApiHttpError(
4090 502,
4091 "codexd_invalid_response",
4092 `Independent codexd returned invalid JSON for ${input.method} ${input.path}.`,
4093 {
4094 target_base_url: codexdLocalApiBase,
4095 upstream_path: input.path
4096 }
4097 );
4098 }
4099 }
4100
4101 if (!response.ok) {
4102 if (isUpstreamErrorEnvelope(parsedBody)) {
4103 throw new LocalApiHttpError(
4104 response.status,
4105 response.status === 404 ? "not_found" : parsedBody.error,
4106 parsedBody.message,
4107 compactJsonObject({
4108 target_base_url: codexdLocalApiBase,
4109 upstream_details: parsedBody.details,
4110 upstream_error: parsedBody.error,
4111 upstream_path: input.path
4112 })
4113 );
4114 }
4115
4116 throw new LocalApiHttpError(
4117 response.status,
4118 response.status >= 500 ? "codexd_unavailable" : "codexd_proxy_error",
4119 `Independent codexd returned HTTP ${response.status} for ${input.method} ${input.path}.`,
4120 {
4121 target_base_url: codexdLocalApiBase,
4122 upstream_path: input.path
4123 }
4124 );
4125 }
4126
4127 if (!isUpstreamSuccessEnvelope(parsedBody)) {
4128 throw new LocalApiHttpError(
4129 502,
4130 "codexd_invalid_response",
4131 `Independent codexd returned an unexpected payload for ${input.method} ${input.path}.`,
4132 {
4133 target_base_url: codexdLocalApiBase,
4134 upstream_path: input.path
4135 }
4136 );
4137 }
4138
4139 return {
4140 data: parsedBody.data,
4141 status: response.status
4142 };
4143}
4144
4145function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
4146 const canonicalUrl = snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null;
4147
4148 return {
4149 auth_mode: "local_network_only",
4150 enabled: canonicalUrl != null,
4151 inbound_messages: [
4152 "hello",
4153 "state_request",
4154 "action_request",
4155 "credentials",
4156 "api_endpoints",
4157 "client_log",
4158 "browser.final_message",
4159 "api_response",
4160 "stream_open",
4161 "stream_event",
4162 "stream_end",
4163 "stream_error"
4164 ],
4165 outbound_messages: [
4166 "hello_ack",
4167 "state_snapshot",
4168 "action_result",
4169 "browser.inject_message",
4170 "browser.send_message",
4171 "open_tab",
4172 "plugin_status",
4173 "ws_reconnect",
4174 "controller_reload",
4175 "tab_restore",
4176 "api_request",
4177 "request_cancel",
4178 "request_credentials",
4179 "reload",
4180 "error"
4181 ],
4182 compat_paths: ["/ws/firefox"],
4183 compat_url: snapshot.controlApi.firefoxWsUrl ?? null,
4184 path: "/ws/browser",
4185 purpose: "local browser extension bridge",
4186 reconnect: "client auto-reconnect is expected and supported",
4187 url: canonicalUrl
4188 };
4189}
4190
4191function buildBrowserActionContract(origin: string): JsonObject {
4192 return {
4193 route: describeRoute(requireRouteDefinition("browser.actions")),
4194 request_body: {
4195 action:
4196 "必填字符串。当前正式支持 plugin_status、request_credentials、tab_open、tab_focus、tab_reload、tab_restore、ws_reconnect、controller_reload。",
4197 platform:
4198 "tab_open、tab_focus、tab_reload、request_credentials、tab_restore 建议带非空平台字符串;当前正式 shell / credential 管理平台已覆盖 claude 和 chatgpt,Gemini 仍留在下一波。",
4199 clientId: "可选字符串;指定目标 browser bridge client。",
4200 reason: "可选字符串;request_credentials、tab_reload、tab_restore、ws_reconnect、controller_reload 会原样透传给浏览器侧。",
4201 disconnectMs:
4202 `仅 ws_reconnect 可选;断开后保持离线的毫秒数。支持 disconnect_ms / delayMs / delay_ms 别名;范围 0-${MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS}。`,
4203 repeatCount:
4204 `仅 ws_reconnect 可选;连续断开重连的轮数。支持 repeat_count 别名;范围 1-${MAX_BROWSER_WS_RECONNECT_REPEAT_COUNT}。`,
4205 repeatIntervalMs:
4206 `仅 ws_reconnect 可选;每轮成功重连后到下一轮再次断开的等待毫秒数。支持 repeat_interval_ms / intervalMs / interval_ms 别名;范围 0-${MAX_BROWSER_WS_RECONNECT_REPEAT_INTERVAL_MS}。`
4207 },
4208 response_body: {
4209 accepted: "布尔值;插件是否接受了该动作。",
4210 completed: "布尔值;插件是否已经完成本轮动作执行。",
4211 failed: "布尔值;若执行失败或被拒绝则为 true。",
4212 reason: "可选字符串;失败原因、跳过原因或动作补充说明。",
4213 target: "目标摘要;包含 client / connection / platform 与请求目标。",
4214 result: "结果摘要;包含 desired / actual / drift / restored / skipped 等聚合计数。",
4215 results: "逐平台结果明细;会带 tab_id、skipped、restored 和 shell_runtime。",
4216 shell_runtime: "本次动作返回的最新 runtime 快照列表。"
4217 },
4218 supported_actions: [...SUPPORTED_BROWSER_ACTIONS],
4219 supported_platforms: [...FORMAL_BROWSER_SHELL_PLATFORMS],
4220 reserved_actions: [...RESERVED_BROWSER_ACTIONS],
4221 examples: [
4222 {
4223 title: "Open or focus the Claude shell page",
4224 curl: buildCurlExample(origin, requireRouteDefinition("browser.actions"), {
4225 action: "tab_open",
4226 platform: "claude"
4227 })
4228 },
4229 {
4230 title: "Ask the browser plugin to refresh credentials",
4231 curl: buildCurlExample(origin, requireRouteDefinition("browser.actions"), {
4232 action: "request_credentials",
4233 platform: "chatgpt",
4234 reason: "describe_refresh"
4235 })
4236 }
4237 ],
4238 error_semantics: [
4239 "503 browser_bridge_unavailable: 当前没有可用 browser bridge client。",
4240 "409 browser_client_not_found: 指定的 clientId 当前未连接。",
4241 "504 browser_action_timeout: 插件未在约定时间内回传结构化 action_result。"
4242 ]
4243 };
4244}
4245
4246function buildBrowserRequestContract(origin: string): JsonObject {
4247 return {
4248 route: describeRoute(requireRouteDefinition("browser.request")),
4249 request_body: {
4250 platform:
4251 "必填字符串;当前正式支持 claude、chatgpt 和 gemini。claude 额外支持省略 path + prompt 的兼容模式;chatgpt / gemini 当前只支持显式 path 的 raw proxy 请求。",
4252 clientId: "可选字符串;指定目标 browser bridge client。",
4253 requestId: "可选字符串;用于 trace 和未来 cancel 对齐。缺省时由 conductor 生成。",
4254 method: "可选字符串;默认 GET。若携带 requestBody 或 prompt 且未显式指定,则默认 POST。",
4255 path: "raw proxy 模式下必填;直接转发给浏览器本地 HTTP 代理路径。",
4256 headers: "可选 string map;附加到浏览器本地代发请求。",
4257 requestBody: "可选任意 JSON;作为代发请求体原样传入。",
4258 prompt: '可选 Claude 兼容字段;当 platform=claude 且省略 path 时,会自动补全 completion 路径。',
4259 organizationId: "可选 Claude 字段;覆盖自动选择的 organization。",
4260 conversationId:
4261 "可选字符串;Claude 可覆盖自动选择的 conversation;Gemini 会优先用于匹配会话级持久化发送模板。",
4262 responseMode:
4263 '可选字符串 buffered 或 sse;buffered 返回 JSON,sse 返回 text/event-stream,并按 stream_open / stream_event / stream_end / stream_error 编码。',
4264 timeoutMs: `可选整数 > 0;默认 ${DEFAULT_BROWSER_PROXY_TIMEOUT_MS}。`
4265 },
4266 supported_platforms: [...FORMAL_BROWSER_REQUEST_PLATFORMS],
4267 supported_response_modes: [...SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES],
4268 reserved_response_modes: [...RESERVED_BROWSER_REQUEST_RESPONSE_MODES],
4269 examples: [
4270 {
4271 title: "Send a Claude prompt through the generic browser request route",
4272 curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
4273 platform: "claude",
4274 prompt: "Summarize the current bridge state."
4275 })
4276 },
4277 {
4278 title: "Issue a raw browser proxy read against a captured Claude endpoint",
4279 curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
4280 platform: "claude",
4281 method: "GET",
4282 path: "/api/organizations"
4283 })
4284 },
4285 {
4286 title: "Issue a raw ChatGPT browser proxy read against a captured endpoint",
4287 curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
4288 platform: "chatgpt",
4289 method: "GET",
4290 path: "/backend-api/models"
4291 })
4292 },
4293 {
4294 title: "Issue a Gemini raw relay request against a captured StreamGenerate template",
4295 curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
4296 conversationId: "conv-gemini-current",
4297 platform: "gemini",
4298 path: BROWSER_GEMINI_STREAM_GENERATE_PATH,
4299 prompt: "Summarize the latest conductor health snapshot."
4300 })
4301 }
4302 ],
4303 error_semantics: [
4304 "400 invalid_request: 缺字段或组合不合法;例如既没有 path,也不是 platform=claude + prompt 的兼容模式。",
4305 "409 claude_credentials_unavailable: Claude prompt 模式还没有捕获到可用凭证。",
4306 "503 browser_bridge_unavailable: 当前没有活跃 browser bridge client。",
4307 "4xx/5xx browser_upstream_error: 浏览器本地代理已返回上游 HTTP 错误;responseMode=sse 时会在事件流里交付 stream_error。"
4308 ]
4309 };
4310}
4311
4312function buildBrowserRequestCancelContract(): JsonObject {
4313 return {
4314 route: describeRoute(requireRouteDefinition("browser.request.cancel")),
4315 request_body: {
4316 requestId: "必填字符串;对应 /v1/browser/request 的 requestId 或响应里返回的 proxy.request_id。",
4317 platform: "必填字符串;与原始请求平台保持一致。",
4318 clientId: "可选字符串;优先用于校验调用方期望的执行侧。",
4319 reason: "可选字符串;取消原因。"
4320 },
4321 current_state: "active",
4322 implementation_status: "会把 cancel 请求转发给当前执行中的 browser bridge client,并让原始 request 尽快以取消错误结束。",
4323 error_semantics: [
4324 "400 invalid_request: requestId 或 platform 缺失。",
4325 "404 browser_request_not_found: 对应 requestId 当前不在执行中。",
4326 "409 browser_request_client_mismatch: 指定了 clientId,但与实际执行中的 client 不一致。",
4327 "503 browser_bridge_unavailable: 当前执行中的 browser bridge client 已断开。"
4328 ]
4329 };
4330}
4331
4332function buildBrowserLegacyRouteData(): JsonObject[] {
4333 return [
4334 describeRoute(requireRouteDefinition("browser.claude.open")),
4335 describeRoute(requireRouteDefinition("browser.claude.send")),
4336 describeRoute(requireRouteDefinition("browser.claude.current")),
4337 describeRoute(requireRouteDefinition("browser.chatgpt.send")),
4338 describeRoute(requireRouteDefinition("browser.chatgpt.current")),
4339 describeRoute(requireRouteDefinition("browser.gemini.send")),
4340 describeRoute(requireRouteDefinition("browser.gemini.current")),
4341 describeRoute(requireRouteDefinition("browser.claude.reload"))
4342 ];
4343}
4344
4345function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: string): JsonObject {
4346 return {
4347 enabled: (snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl) != null,
4348 legacy_helper_platform: BROWSER_CLAUDE_PLATFORM,
4349 legacy_helper_platforms: [
4350 BROWSER_CLAUDE_PLATFORM,
4351 BROWSER_CHATGPT_PLATFORM,
4352 BROWSER_GEMINI_PLATFORM
4353 ],
4354 platform: BROWSER_CLAUDE_PLATFORM,
4355 supported_action_platforms: [...FORMAL_BROWSER_SHELL_PLATFORMS],
4356 supported_request_platforms: [...FORMAL_BROWSER_REQUEST_PLATFORMS],
4357 route_prefix: "/v1/browser",
4358 routes: [
4359 describeRoute(requireRouteDefinition("browser.status")),
4360 describeRoute(requireRouteDefinition("browser.actions")),
4361 describeRoute(requireRouteDefinition("browser.request")),
4362 describeRoute(requireRouteDefinition("browser.request.cancel"))
4363 ],
4364 action_contract: buildBrowserActionContract(origin),
4365 request_contract: buildBrowserRequestContract(origin),
4366 cancel_contract: buildBrowserRequestCancelContract(),
4367 legacy_routes: buildBrowserLegacyRouteData(),
4368 transport: {
4369 http: snapshot.controlApi.localApiBase ?? null,
4370 websocket: snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null
4371 },
4372 notes: [
4373 "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
4374 "GET /v1/browser remains the shared read model for login-state metadata, plugin connectivity, shell_runtime, and the latest structured action_result per client.",
4375 "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT and Gemini raw relay, and expects a local browser bridge client.",
4376 "Claude keeps the prompt shortcut when path is omitted; ChatGPT and Gemini require an explicit path and a real browser login context captured on the selected client.",
4377 "Gemini raw relay can additionally use conversationId to prefer a conversation-matched persisted send template when the plugin has one.",
4378 "The legacy helper surface now also exposes /v1/browser/chatgpt/* and /v1/browser/gemini/* wrappers for BAA target compatibility.",
4379 "POST /v1/browser/actions now waits for the plugin to return a structured action_result instead of returning only a dispatch ack.",
4380 "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.",
4381 "The /v1/browser/{claude,chatgpt,gemini}/* routes remain available as legacy wrappers during the migration window."
4382 ]
4383 };
4384}
4385
4386function isHostOperationsRoute(route: LocalApiRouteDefinition): boolean {
4387 return HOST_OPERATIONS_ROUTE_IDS.has(route.id);
4388}
4389
4390function readHeaderValue(request: ConductorHttpRequest, headerName: string): string | undefined {
4391 const headers = request.headers;
4392
4393 if (headers == null) {
4394 return undefined;
4395 }
4396
4397 const exactMatch = headers[headerName];
4398
4399 if (typeof exactMatch === "string") {
4400 return exactMatch;
4401 }
4402
4403 const normalizedHeaderName = headerName.toLowerCase();
4404 const lowerCaseMatch = headers[normalizedHeaderName];
4405
4406 if (typeof lowerCaseMatch === "string") {
4407 return lowerCaseMatch;
4408 }
4409
4410 for (const [name, value] of Object.entries(headers)) {
4411 if (name.toLowerCase() === normalizedHeaderName && typeof value === "string") {
4412 return value;
4413 }
4414 }
4415
4416 return undefined;
4417}
4418
4419function buildUiSessionResponseData(
4420 context: LocalApiRequestContext,
4421 session: UiSessionRecord | null
4422): JsonObject {
4423 return {
4424 authenticated: session != null,
4425 available_roles: context.uiSessionManager.getAvailableRoles(),
4426 session: UiSessionManager.toSnapshot(session) as unknown as JsonValue
4427 };
4428}
4429
4430function buildUiSessionSuccessResponse(
4431 context: LocalApiRequestContext,
4432 session: UiSessionRecord | null,
4433 headers: Record<string, string> = {}
4434): ConductorHttpResponse {
4435 return buildSuccessEnvelope(context.requestId, 200, buildUiSessionResponseData(context, session), headers);
4436}
4437
4438function buildUiSessionCookieHeader(context: LocalApiRequestContext, session: UiSessionRecord): string {
4439 return context.uiSessionManager.buildSessionCookieHeader(session.sessionId, context.url.protocol);
4440}
4441
4442function buildUiSessionClearCookieHeader(context: LocalApiRequestContext): string {
4443 return context.uiSessionManager.buildClearCookieHeader(context.url.protocol);
4444}
4445
4446function hasUiSessionCookie(request: ConductorHttpRequest): boolean {
4447 const cookieHeader = readHeaderValue(request, "cookie");
4448 return typeof cookieHeader === "string" && cookieHeader.includes(`${UI_SESSION_COOKIE_NAME}=`);
4449}
4450
4451function readUiSessionPrincipal(context: LocalApiRequestContext): AuthPrincipal | null {
4452 return context.uiSessionManager.resolvePrincipal(readHeaderValue(context.request, "cookie"));
4453}
4454
4455function extractBearerToken(
4456 authorizationHeader: string | undefined
4457): { ok: true; token: string } | { ok: false; reason: SharedTokenAuthFailureReason } {
4458 if (!authorizationHeader) {
4459 return {
4460 ok: false,
4461 reason: "missing_authorization_header"
4462 };
4463 }
4464
4465 const [scheme, ...rest] = authorizationHeader.trim().split(/\s+/u);
4466
4467 if (scheme !== "Bearer") {
4468 return {
4469 ok: false,
4470 reason: "invalid_authorization_scheme"
4471 };
4472 }
4473
4474 const token = rest.join(" ").trim();
4475
4476 if (token === "") {
4477 return {
4478 ok: false,
4479 reason: "empty_bearer_token"
4480 };
4481 }
4482
4483 return {
4484 ok: true,
4485 token
4486 };
4487}
4488
4489function buildHostOperationsAuthData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
4490 return {
4491 configured: snapshot.controlApi.hasSharedToken,
4492 env_var: "BAA_SHARED_TOKEN",
4493 header: HOST_OPERATIONS_AUTH_HEADER,
4494 mode: "bearer_shared_token",
4495 protected_routes: ["/v1/exec", "/v1/files/read", "/v1/files/write"],
4496 public_access: "anonymous_denied",
4497 uses_placeholder_token: snapshot.controlApi.usesPlaceholderToken
4498 };
4499}
4500
4501function buildHttpAuthData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
4502 return {
4503 mode: "mixed",
4504 default_routes: {
4505 access: "local_network",
4506 header_required: false
4507 },
4508 host_operations: buildHostOperationsAuthData(snapshot)
4509 };
4510}
4511
4512function buildHostOperationsNotes(snapshot: ConductorRuntimeApiSnapshot): string[] {
4513 const notes = [
4514 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>.",
4515 "Missing or incorrect bearer tokens return a 401 JSON error and do not execute any host operation.",
4516 "These host-ops routes are not anonymously public, even if this listener is reverse-proxied or tunneled.",
4517 "These three routes still return the outer conductor success envelope; the inner operation result remains in data.",
4518 "Request bodies should prefer camelCase fields; timeout_ms, max_buffer_bytes and create_parents remain accepted aliases.",
4519 "These operations act only on the current node and do not hop through control-api-worker."
4520 ];
4521
4522 if (!snapshot.controlApi.hasSharedToken) {
4523 notes.push("BAA_SHARED_TOKEN is not configured on this node; host operations stay unavailable until it is set.");
4524 } else if (snapshot.controlApi.usesPlaceholderToken) {
4525 notes.push("BAA_SHARED_TOKEN is still set to replace-me; replace it before exposing this surface.");
4526 }
4527
4528 return notes;
4529}
4530
4531function buildHostOperationsData(origin: string, snapshot: ConductorRuntimeApiSnapshot): JsonObject {
4532 return {
4533 enabled: true,
4534 auth: buildHostOperationsAuthData(snapshot),
4535 contract: "HTTP success envelope data 直接嵌入 @baa-conductor/host-ops 的结构化 result union。",
4536 semantics: {
4537 cwd: "可选字符串;省略时使用 conductor-daemon 进程当前工作目录。",
4538 path: "files/read 和 files/write 的 path 可以是绝对路径,也可以是相对 cwd 的路径。",
4539 timeoutMs: `仅 /v1/exec 使用;可选整数 >= 0,默认 ${DEFAULT_EXEC_TIMEOUT_MS},0 表示不启用超时。`,
4540 maxBufferBytes: `仅 /v1/exec 使用;可选整数 > 0,默认 ${DEFAULT_EXEC_MAX_BUFFER_BYTES}。`,
4541 overwrite: "仅 /v1/files/write 使用;可选布尔值,默认 true。false 时如果目标文件已存在,会返回 FILE_ALREADY_EXISTS。",
4542 createParents: "仅 /v1/files/write 使用;可选布尔值,默认 true。true 时会递归创建缺失父目录。",
4543 encoding: "当前只支持 utf8。"
4544 },
4545 operations: [
4546 {
4547 method: "POST",
4548 path: "/v1/exec",
4549 summary: "执行本机 shell 命令。",
4550 request_body: {
4551 command: "必填,非空字符串。",
4552 cwd: "可选,命令执行目录。",
4553 timeoutMs: DEFAULT_EXEC_TIMEOUT_MS,
4554 maxBufferBytes: DEFAULT_EXEC_MAX_BUFFER_BYTES
4555 },
4556 curl: buildCurlExample(
4557 origin,
4558 LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!,
4559 {
4560 command: "printf 'hello from conductor'",
4561 cwd: "/tmp",
4562 timeoutMs: 2000
4563 },
4564 {
4565 bearerTokenEnvVar: "BAA_SHARED_TOKEN"
4566 }
4567 )
4568 },
4569 {
4570 method: "POST",
4571 path: "/v1/files/read",
4572 summary: "读取本机文本文件。",
4573 request_body: {
4574 path: "README.md",
4575 cwd: "/Users/george/code/baa-conductor",
4576 encoding: "utf8"
4577 },
4578 curl: buildCurlExample(
4579 origin,
4580 LOCAL_API_ROUTES.find((route) => route.id === "host.files.read")!,
4581 {
4582 path: "README.md",
4583 cwd: "/Users/george/code/baa-conductor",
4584 encoding: "utf8"
4585 },
4586 {
4587 bearerTokenEnvVar: "BAA_SHARED_TOKEN"
4588 }
4589 )
4590 },
4591 {
4592 method: "POST",
4593 path: "/v1/files/write",
4594 summary: "写入本机文本文件。",
4595 request_body: {
4596 path: "tmp/conductor-note.txt",
4597 cwd: "/Users/george/code/baa-conductor",
4598 content: "hello from conductor",
4599 overwrite: true,
4600 createParents: true,
4601 encoding: "utf8"
4602 },
4603 curl: buildCurlExample(
4604 origin,
4605 LOCAL_API_ROUTES.find((route) => route.id === "host.files.write")!,
4606 {
4607 path: "tmp/conductor-note.txt",
4608 cwd: "/Users/george/code/baa-conductor",
4609 content: "hello from conductor",
4610 overwrite: true,
4611 createParents: true,
4612 encoding: "utf8"
4613 },
4614 {
4615 bearerTokenEnvVar: "BAA_SHARED_TOKEN"
4616 }
4617 )
4618 }
4619 ],
4620 notes: buildHostOperationsNotes(snapshot)
4621 };
4622}
4623
4624function describeRoute(route: LocalApiRouteDefinition): JsonObject {
4625 return compactJsonObject({
4626 id: route.id,
4627 method: route.method,
4628 path: route.pathPattern,
4629 kind: route.kind === "probe" ? "read" : route.kind,
4630 implementation: "implemented",
4631 legacy_replacement_path: route.legacyReplacementPath,
4632 lifecycle: route.lifecycle ?? "stable",
4633 summary: route.summary,
4634 access: isHostOperationsRoute(route) ? "bearer_shared_token" : "local_network",
4635 ...(isHostOperationsRoute(route)
4636 ? {
4637 auth: {
4638 header: HOST_OPERATIONS_AUTH_HEADER,
4639 required: true
4640 }
4641 }
4642 : {})
4643 });
4644}
4645
4646function routeBelongsToSurface(
4647 route: LocalApiRouteDefinition,
4648 surface: LocalApiDescribeSurface
4649): boolean {
4650 if (route.exposeInDescribe === false || route.id === "service.describe") {
4651 return false;
4652 }
4653
4654 if (surface === "business") {
4655 return [
4656 "service.describe.business",
4657 "service.health",
4658 "service.version",
4659 "system.capabilities",
4660 "status.view.json",
4661 "status.view.ui",
4662 "browser.status",
4663 "browser.request",
4664 "browser.request.cancel",
4665 "browser.claude.send",
4666 "browser.claude.current",
4667 "browser.chatgpt.send",
4668 "browser.chatgpt.current",
4669 "browser.gemini.send",
4670 "browser.gemini.current",
4671 "codex.status",
4672 "codex.sessions.list",
4673 "codex.sessions.read",
4674 "codex.sessions.create",
4675 "codex.turn.create",
4676 "controllers.list",
4677 "tasks.list",
4678 "tasks.read",
4679 "tasks.logs.read",
4680 "artifact.messages.list",
4681 "artifact.messages.read",
4682 "artifact.executions.list",
4683 "artifact.executions.read",
4684 "artifact.sessions.list",
4685 "artifact.sessions.latest",
4686 "renewal.conversations.list",
4687 "renewal.conversations.read",
4688 "renewal.links.list",
4689 "renewal.jobs.list",
4690 "renewal.jobs.read"
4691 ].includes(route.id);
4692 }
4693
4694 return [
4695 "service.describe.control",
4696 "service.health",
4697 "service.version",
4698 "system.capabilities",
4699 "browser.status",
4700 "browser.actions",
4701 "browser.claude.open",
4702 "browser.claude.reload",
4703 "system.state",
4704 "system.pause",
4705 "system.resume",
4706 "system.drain",
4707 "renewal.conversations.list",
4708 "renewal.conversations.read",
4709 "renewal.links.list",
4710 "renewal.jobs.list",
4711 "renewal.jobs.read",
4712 "renewal.conversations.manual",
4713 "renewal.conversations.auto",
4714 "renewal.conversations.paused",
4715 "host.exec",
4716 "host.files.read",
4717 "host.files.write",
4718 "probe.healthz",
4719 "probe.readyz",
4720 "probe.rolez",
4721 "probe.runtime"
4722 ].includes(route.id);
4723}
4724
4725function buildCapabilitiesData(
4726 snapshot: ConductorRuntimeApiSnapshot,
4727 surface: LocalApiDescribeSurface | "all" = "all"
4728): JsonObject {
4729 const origin = snapshot.controlApi.localApiBase ?? "http://127.0.0.1";
4730 const exposedRoutes = LOCAL_API_ROUTES.filter((route) =>
4731 surface === "all" ? route.exposeInDescribe !== false : routeBelongsToSurface(route, surface)
4732 );
4733
4734 return {
4735 deployment_mode: "single-node mini",
4736 auth: buildHttpAuthData(snapshot),
4737 auth_mode: "mixed",
4738 repository_configured: true,
4739 truth_source: "local sqlite control plane",
4740 workflow: [
4741 "GET /describe",
4742 "GET /v1/capabilities",
4743 "GET /v1/status for the narrower read-only status view",
4744 "GET /v1/browser if browser mediation or plugin state is needed",
4745 "GET /v1/system/state",
4746 "POST /v1/browser/request for browser-mediated business requests",
4747 "POST /v1/browser/actions for browser/plugin management actions",
4748 "GET /v1/browser/{claude,chatgpt,gemini}/current or /v1/tasks or /v1/codex when a legacy helper read is needed",
4749 "Use /v1/codex/* for interactive Codex session and turn work",
4750 "Use /v1/browser/{claude,chatgpt,gemini}/* only as legacy compatibility wrappers",
4751 "GET /describe/control if local shell/file access is needed",
4752 "Use POST system routes or host operations only when a write/exec is intended"
4753 ],
4754 read_endpoints: exposedRoutes.filter((route) => route.kind === "read").map(describeRoute),
4755 write_endpoints: exposedRoutes.filter((route) => route.kind === "write").map(describeRoute),
4756 diagnostics: LOCAL_API_ROUTES.filter((route) => route.kind === "probe").map(describeRoute),
4757 codex: buildCodexProxyData(snapshot),
4758 runtime: {
4759 identity: snapshot.identity,
4760 lease_state: snapshot.daemon.leaseState,
4761 scheduler_enabled: snapshot.daemon.schedulerEnabled,
4762 started: snapshot.runtime.started
4763 },
4764 browser: buildBrowserHttpData(snapshot, origin),
4765 transports: {
4766 http: {
4767 auth: buildHttpAuthData(snapshot),
4768 auth_mode: "mixed",
4769 url: snapshot.controlApi.localApiBase ?? null
4770 },
4771 websocket: buildFirefoxWebSocketData(snapshot)
4772 },
4773 host_operations: buildHostOperationsData(origin, snapshot)
4774 };
4775}
4776
4777function buildCurlExample(
4778 origin: string,
4779 route: LocalApiRouteDefinition,
4780 body?: JsonObject,
4781 options: {
4782 bearerTokenEnvVar?: string;
4783 } = {}
4784): string {
4785 const serializedBody = body ? JSON.stringify(body).replaceAll("'", "'\"'\"'") : null;
4786 const authHeader = options.bearerTokenEnvVar
4787 ? ` \\\n -H "Authorization: Bearer \${${options.bearerTokenEnvVar}}"`
4788 : "";
4789 const payload = body
4790 ? `${authHeader} \\\n -H 'Content-Type: application/json' \\\n -d '${serializedBody}'`
4791 : authHeader;
4792 return `curl -X ${route.method} '${origin}${route.pathPattern}'${payload}`;
4793}
4794
4795async function handleDescribeRead(context: LocalApiRequestContext, version: string): Promise<ConductorHttpResponse> {
4796 const repository = requireRepository(context.repository);
4797 const snapshot = context.snapshotLoader();
4798 const origin = snapshot.controlApi.localApiBase ?? "http://127.0.0.1";
4799 const system = await buildSystemStateData(repository);
4800
4801 return buildSuccessEnvelope(context.requestId, 200, {
4802 name: resolveServiceName(),
4803 version,
4804 description: "BAA conductor local API describe index. Read one of the scoped describe endpoints next.",
4805 environment: {
4806 summary: "single-node mini local daemon",
4807 deployment_mode: "single-node mini",
4808 topology: "No Cloudflare Worker or D1 control-plane hop is required for these routes.",
4809 auth: buildHttpAuthData(snapshot),
4810 auth_mode: "mixed",
4811 truth_source: "local sqlite control plane",
4812 origin
4813 },
4814 ...buildRecentSessionsArtifactUrls(context.artifactStore),
4815 auth: buildHttpAuthData(snapshot),
4816 system,
4817 websocket: buildFirefoxWebSocketData(snapshot),
4818 browser: buildBrowserHttpData(snapshot, origin),
4819 codex: buildCodexProxyData(snapshot),
4820 describe_endpoints: {
4821 business: {
4822 path: "/describe/business",
4823 summary: "业务查询和 Codex session 入口;适合 CLI AI、网页版 AI、手机网页 AI 先读。"
4824 },
4825 control: {
4826 path: "/describe/control",
4827 summary:
4828 "控制和本机 host-ops 入口;在 pause/resume/drain 或 exec/files/* 前先读。/v1/exec 和 /v1/files/* 需要 shared token。"
4829 }
4830 },
4831 endpoints: [
4832 {
4833 method: "GET",
4834 path: "/describe/business"
4835 },
4836 {
4837 method: "GET",
4838 path: "/describe/control"
4839 }
4840 ],
4841 capabilities: buildCapabilitiesData(snapshot),
4842 host_operations: buildHostOperationsData(origin, snapshot),
4843 examples: [
4844 {
4845 title: "Read the business describe surface first",
4846 method: "GET",
4847 path: "/describe/business",
4848 curl: buildCurlExample(
4849 origin,
4850 LOCAL_API_ROUTES.find((route) => route.id === "service.describe.business")!
4851 )
4852 },
4853 {
4854 title: "Read the control describe surface before any write",
4855 method: "GET",
4856 path: "/describe/control",
4857 curl: buildCurlExample(
4858 origin,
4859 LOCAL_API_ROUTES.find((route) => route.id === "service.describe.control")!
4860 )
4861 },
4862 {
4863 title: "Inspect the narrower capability surface if needed",
4864 method: "GET",
4865 path: "/v1/capabilities",
4866 curl: buildCurlExample(origin, requireRouteDefinition("system.capabilities"))
4867 },
4868 {
4869 title: "Inspect the browser bridge surface",
4870 method: "GET",
4871 path: "/v1/browser",
4872 curl: buildCurlExample(origin, requireRouteDefinition("browser.status"))
4873 },
4874 {
4875 title: "Inspect the codex proxy surface",
4876 method: "GET",
4877 path: "/v1/codex",
4878 curl: buildCurlExample(origin, requireRouteDefinition("codex.status"))
4879 },
4880 {
4881 title: "Read the current automation state",
4882 method: "GET",
4883 path: "/v1/system/state",
4884 curl: buildCurlExample(origin, requireRouteDefinition("system.state"))
4885 },
4886 {
4887 title: "Read the read-only compatibility status view",
4888 method: "GET",
4889 path: "/v1/status",
4890 curl: buildCurlExample(origin, requireRouteDefinition("status.view.json"))
4891 },
4892 {
4893 title: "Pause local automation explicitly",
4894 method: "POST",
4895 path: "/v1/system/pause",
4896 curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "system.pause")!, {
4897 requested_by: "human_operator",
4898 reason: "manual_pause",
4899 source: "local_control_surface"
4900 })
4901 },
4902 {
4903 title: "Run a small local command",
4904 method: "POST",
4905 path: "/v1/exec",
4906 curl: buildCurlExample(
4907 origin,
4908 LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!,
4909 {
4910 command: "printf 'hello from conductor'",
4911 cwd: "/tmp",
4912 timeoutMs: 2000
4913 },
4914 {
4915 bearerTokenEnvVar: "BAA_SHARED_TOKEN"
4916 }
4917 )
4918 }
4919 ],
4920 notes: [
4921 "AI callers should prefer /describe/business for business queries and /describe/control for control actions.",
4922 "GET /v1/status and GET /v1/status/ui expose the narrow read-only compatibility status view; /v1/system/state remains the fuller control-oriented truth surface.",
4923 "The formal /v1/browser/* surface is now split into generic GET /v1/browser, POST /v1/browser/request, and POST /v1/browser/actions contracts.",
4924 "The /v1/browser/{claude,chatgpt,gemini}/* routes remain available as legacy compatibility wrappers during migration.",
4925 "All /v1/codex routes proxy the independent codexd daemon; this process does not host Codex sessions itself.",
4926 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
4927 "These routes read and mutate the mini node's local truth source directly.",
4928 "GET /healthz, /readyz, /rolez and /v1/runtime remain available as low-level diagnostics.",
4929 "The optional browser bridge WS reuses the same local listener and upgrades on /ws/browser, with /ws/firefox kept as a compatibility path."
4930 ]
4931 });
4932}
4933
4934async function handleScopedDescribeRead(
4935 context: LocalApiRequestContext,
4936 version: string,
4937 surface: LocalApiDescribeSurface
4938): Promise<ConductorHttpResponse> {
4939 const repository = requireRepository(context.repository);
4940 const snapshot = context.snapshotLoader();
4941 const origin = snapshot.controlApi.localApiBase ?? "http://127.0.0.1";
4942 const system = await buildSystemStateData(repository);
4943 const routes = LOCAL_API_ROUTES.filter((route) => routeBelongsToSurface(route, surface));
4944
4945 if (surface === "business") {
4946 return buildSuccessEnvelope(context.requestId, 200, {
4947 name: resolveServiceName(),
4948 version,
4949 surface: "business",
4950 description: "Business describe surface for discovery-first AI callers.",
4951 audience: ["cli_ai", "web_ai", "mobile_web_ai"],
4952 environment: {
4953 deployment_mode: "single-node mini",
4954 auth: buildHttpAuthData(snapshot),
4955 auth_mode: "mixed",
4956 truth_source: "local sqlite control plane",
4957 origin
4958 },
4959 ...buildRecentSessionsArtifactUrls(context.artifactStore),
4960 recommended_flow: [
4961 "GET /describe/business",
4962 "Optionally GET /v1/capabilities",
4963 "GET /v1/browser if browser login-state metadata is needed",
4964 "Use business routes such as /v1/browser/request, /v1/controllers, /v1/tasks and /v1/codex",
4965 "Use /describe/control if a local shell or file operation is intended"
4966 ],
4967 system,
4968 websocket: buildFirefoxWebSocketData(snapshot),
4969 browser: buildBrowserHttpData(snapshot, origin),
4970 codex: buildCodexProxyData(snapshot),
4971 endpoints: routes.map(describeRoute),
4972 examples: [
4973 {
4974 title: "Inspect browser bridge readiness",
4975 method: "GET",
4976 path: "/v1/browser",
4977 curl: buildCurlExample(origin, requireRouteDefinition("browser.status"))
4978 },
4979 {
4980 title: "Send a Claude prompt through the generic browser request route",
4981 method: "POST",
4982 path: "/v1/browser/request",
4983 curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
4984 platform: "claude",
4985 prompt: "Summarize the current bridge state."
4986 })
4987 },
4988 {
4989 title: "Read current Claude conversation state through the legacy helper route",
4990 method: "GET",
4991 path: "/v1/browser/claude/current",
4992 curl: buildCurlExample(origin, requireRouteDefinition("browser.claude.current"))
4993 },
4994 {
4995 title: "Read the compatibility status snapshot",
4996 method: "GET",
4997 path: "/v1/status",
4998 curl: buildCurlExample(origin, requireRouteDefinition("status.view.json"))
4999 },
5000 {
5001 title: "List recent tasks",
5002 method: "GET",
5003 path: "/v1/tasks?limit=5",
5004 curl: buildCurlExample(origin, requireRouteDefinition("tasks.list"))
5005 },
5006 {
5007 title: "Inspect the codex proxy status",
5008 method: "GET",
5009 path: "/v1/codex",
5010 curl: buildCurlExample(origin, requireRouteDefinition("codex.status"))
5011 },
5012 {
5013 title: "Create a codex session",
5014 method: "POST",
5015 path: "/v1/codex/sessions",
5016 curl: buildCurlExample(origin, requireRouteDefinition("codex.sessions.create"), {
5017 cwd: "/Users/george/code/baa-conductor",
5018 purpose: "duplex"
5019 })
5020 }
5021 ],
5022 notes: [
5023 "This surface is intended to be enough for business-query discovery without reading external docs.",
5024 "Use GET /v1/status for the narrow read-only compatibility snapshot and GET /v1/status/ui for the matching HTML panel.",
5025 "Business-facing browser work now lands on POST /v1/browser/request; POST /v1/browser/request/cancel cancels an in-flight request by requestId.",
5026 "GET /v1/browser/{claude,chatgpt,gemini}/current and POST /v1/browser/{claude,chatgpt,gemini}/send remain available as legacy helper wrappers during migration.",
5027 "All /v1/codex routes proxy the independent codexd daemon instead of an in-process bridge.",
5028 "If you pivot to /describe/control for /v1/exec or /v1/files/*, those host-ops routes require Authorization: Bearer <BAA_SHARED_TOKEN>.",
5029 "Browser/plugin management actions such as tab open/reload live under /describe/control via POST /v1/browser/actions."
5030 ]
5031 });
5032 }
5033
5034 return buildSuccessEnvelope(context.requestId, 200, {
5035 name: resolveServiceName(),
5036 version,
5037 surface: "control",
5038 description: "Control describe surface for state-first AI callers.",
5039 audience: ["cli_ai", "web_ai", "mobile_web_ai", "human_operator"],
5040 environment: {
5041 deployment_mode: "single-node mini",
5042 auth: buildHttpAuthData(snapshot),
5043 auth_mode: "mixed",
5044 truth_source: "local sqlite control plane",
5045 origin
5046 },
5047 recommended_flow: [
5048 "GET /describe/control",
5049 "Optionally GET /v1/capabilities",
5050 "GET /v1/browser if plugin status or bridge connectivity is relevant",
5051 "GET /v1/system/state",
5052 "Review POST /v1/browser/actions if a browser/plugin management action is intended",
5053 "Read host_operations if a local shell/file action is intended",
5054 "Only then decide whether to call pause, resume, drain or a host operation"
5055 ],
5056 system,
5057 websocket: buildFirefoxWebSocketData(snapshot),
5058 auth: buildHttpAuthData(snapshot),
5059 browser: buildBrowserHttpData(snapshot, origin),
5060 codex: buildCodexProxyData(snapshot),
5061 host_operations: buildHostOperationsData(origin, snapshot),
5062 endpoints: routes.map(describeRoute),
5063 examples: [
5064 {
5065 title: "Inspect browser plugin and bridge status",
5066 method: "GET",
5067 path: "/v1/browser",
5068 curl: buildCurlExample(origin, requireRouteDefinition("browser.status"))
5069 },
5070 {
5071 title: "Open or focus the Claude shell page through the generic action route",
5072 method: "POST",
5073 path: "/v1/browser/actions",
5074 curl: buildCurlExample(origin, requireRouteDefinition("browser.actions"), {
5075 action: "tab_open",
5076 platform: "claude"
5077 })
5078 },
5079 {
5080 title: "Read current system state first",
5081 method: "GET",
5082 path: "/v1/system/state",
5083 curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "system.state")!)
5084 },
5085 {
5086 title: "Pause local automation explicitly",
5087 method: "POST",
5088 path: "/v1/system/pause",
5089 curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "system.pause")!, {
5090 requested_by: "human_operator",
5091 reason: "manual_pause",
5092 source: "local_control_surface"
5093 })
5094 },
5095 {
5096 title: "Run a small local command",
5097 method: "POST",
5098 path: "/v1/exec",
5099 curl: buildCurlExample(
5100 origin,
5101 LOCAL_API_ROUTES.find((route) => route.id === "host.exec")!,
5102 {
5103 command: "printf 'hello from conductor'",
5104 cwd: "/tmp",
5105 timeoutMs: 2000
5106 },
5107 {
5108 bearerTokenEnvVar: "BAA_SHARED_TOKEN"
5109 }
5110 )
5111 },
5112 {
5113 title: "Write a local file safely",
5114 method: "POST",
5115 path: "/v1/files/write",
5116 curl: buildCurlExample(
5117 origin,
5118 LOCAL_API_ROUTES.find((route) => route.id === "host.files.write")!,
5119 {
5120 path: "tmp/conductor-note.txt",
5121 cwd: "/Users/george/code/baa-conductor",
5122 content: "hello from conductor",
5123 overwrite: false,
5124 createParents: true
5125 },
5126 {
5127 bearerTokenEnvVar: "BAA_SHARED_TOKEN"
5128 }
5129 )
5130 }
5131 ],
5132 notes: [
5133 "This surface is intended to be enough for control discovery without reading external docs.",
5134 "The interactive Codex surface is proxied to independent codexd; inspect /v1/codex or /describe/business for those routes.",
5135 "Business queries such as tasks and runs are intentionally excluded; use /describe/business.",
5136 "Browser/plugin management actions live on POST /v1/browser/actions; the legacy Claude open/reload routes remain available as compatibility wrappers.",
5137 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
5138 "Host operations return the structured host-ops union inside the outer conductor HTTP envelope."
5139 ]
5140 });
5141}
5142
5143async function handleHealthRead(context: LocalApiRequestContext, version: string): Promise<ConductorHttpResponse> {
5144 const repository = requireRepository(context.repository);
5145 const snapshot = context.snapshotLoader();
5146
5147 return buildSuccessEnvelope(context.requestId, 200, {
5148 name: resolveServiceName(),
5149 version,
5150 status: snapshot.daemon.leaseState === "degraded" ? "degraded" : "ok",
5151 deployment_mode: "single-node mini",
5152 auth_mode: "local_network_only",
5153 repository_configured: true,
5154 system: await buildSystemStateData(repository)
5155 });
5156}
5157
5158function handleVersionRead(requestId: string, version: string): ConductorHttpResponse {
5159 return buildSuccessEnvelope(requestId, 200, {
5160 name: resolveServiceName(),
5161 version,
5162 description: "BAA conductor local control surface"
5163 });
5164}
5165
5166async function handleCapabilitiesRead(
5167 context: LocalApiRequestContext,
5168 version: string
5169): Promise<ConductorHttpResponse> {
5170 const repository = requireRepository(context.repository);
5171 const snapshot = context.snapshotLoader();
5172
5173 return buildSuccessEnvelope(context.requestId, 200, {
5174 ...buildCapabilitiesData(snapshot),
5175 version,
5176 system: await buildSystemStateData(repository),
5177 notes: [
5178 "Read routes are safe for discovery and inspection.",
5179 "The browser HTTP contract is now split into GET /v1/browser, POST /v1/browser/request, and POST /v1/browser/actions.",
5180 "The generic browser request surface now formally supports Claude, ChatGPT, and Gemini; /v1/browser/{claude,chatgpt,gemini}/* remains available as legacy compatibility wrappers.",
5181 "All /v1/codex routes proxy the independent codexd daemon over local HTTP.",
5182 "POST /v1/system/* writes the local automation mode immediately.",
5183 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN> and return 401 JSON on missing or wrong tokens.",
5184 "POST /v1/exec and POST /v1/files/* return the structured host-ops union in data."
5185 ]
5186 });
5187}
5188
5189async function handleSystemStateRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
5190 return buildSuccessEnvelope(
5191 context.requestId,
5192 200,
5193 await buildSystemStateData(requireRepository(context.repository))
5194 );
5195}
5196
5197async function loadStatusViewSnapshot(context: LocalApiRequestContext) {
5198 return createStatusSnapshotFromControlApiPayload(
5199 await buildSystemStateData(requireRepository(context.repository)),
5200 new Date(context.now())
5201 );
5202}
5203
5204async function handleStatusViewJsonRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
5205 return jsonResponse(200, {
5206 ok: true,
5207 data: await loadStatusViewSnapshot(context)
5208 });
5209}
5210
5211async function handleStatusViewUiRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
5212 return {
5213 status: 200,
5214 headers: {
5215 ...STATUS_VIEW_HTML_HEADERS
5216 },
5217 body: renderStatusPage(await loadStatusViewSnapshot(context), {
5218 htmlPaths: ["/v1/status/ui"],
5219 jsonPath: "/v1/status"
5220 })
5221 };
5222}
5223
5224async function handleBrowserStatusRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
5225 return buildSuccessEnvelope(context.requestId, 200, await buildBrowserStatusData(context));
5226}
5227
5228function serializeClaudeOrganizationSummary(summary: ClaudeOrganizationSummary | null): JsonObject | null {
5229 return summary == null
5230 ? null
5231 : {
5232 organization_id: summary.id,
5233 name: summary.name
5234 };
5235}
5236
5237function serializeClaudeConversationSummary(summary: ClaudeConversationSummary | null): JsonObject | null {
5238 return summary == null
5239 ? null
5240 : {
5241 conversation_id: summary.id,
5242 created_at: summary.created_at,
5243 title: summary.title,
5244 updated_at: summary.updated_at
5245 };
5246}
5247
5248function serializeBrowserRequestPolicyAdmission(admission: BrowserRequestAdmission): JsonObject {
5249 return compactJsonObject({
5250 admitted_at: admission.admittedAt,
5251 backoff_delay_ms: admission.backoffDelayMs || undefined,
5252 circuit_state: admission.circuitState,
5253 jitter_delay_ms: admission.jitterDelayMs || undefined,
5254 platform: admission.platform,
5255 queue_delay_ms: admission.queueDelayMs || undefined,
5256 rate_limit_delay_ms: admission.rateLimitDelayMs || undefined,
5257 request_id: admission.requestId,
5258 requested_at: admission.requestedAt,
5259 target_client_id: admission.targetClientId
5260 });
5261}
5262
5263function serializeBrowserStreamPartialState(
5264 partial: {
5265 buffered_bytes: number;
5266 event_count: number;
5267 last_seq: number;
5268 opened: boolean;
5269 }
5270): JsonObject {
5271 return {
5272 buffered_bytes: partial.buffered_bytes,
5273 event_count: partial.event_count,
5274 last_seq: partial.last_seq,
5275 opened: partial.opened
5276 };
5277}
5278
5279function isBrowserRequestCancelledError(error: unknown): boolean {
5280 if (!(error instanceof Error)) {
5281 return false;
5282 }
5283
5284 const bridgeCode = readBridgeErrorCode(error);
5285 return bridgeCode === "request_cancelled" || bridgeCode === "downstream_disconnected";
5286}
5287
5288function buildBrowserRequestLeaseOutcome(error: unknown): {
5289 code?: string | null;
5290 message?: string | null;
5291 status: "cancelled" | "failure";
5292} {
5293 if (isBrowserRequestCancelledError(error)) {
5294 return {
5295 code: readBridgeErrorCode(error),
5296 message: error instanceof Error ? error.message : String(error),
5297 status: "cancelled"
5298 };
5299 }
5300
5301 return {
5302 code:
5303 readBridgeErrorCode(error)
5304 ?? (error instanceof LocalApiHttpError ? error.error : null),
5305 message: error instanceof Error ? error.message : String(error),
5306 status: "failure"
5307 };
5308}
5309
5310async function nextBrowserStreamResult(
5311 iterator: AsyncIterator<BrowserBridgeStreamEvent>,
5312 signal?: AbortSignal
5313): Promise<IteratorResult<BrowserBridgeStreamEvent> | null> {
5314 if (signal == null) {
5315 return await iterator.next();
5316 }
5317
5318 if (signal.aborted) {
5319 return null;
5320 }
5321
5322 return await new Promise<IteratorResult<BrowserBridgeStreamEvent> | null>((resolve, reject) => {
5323 const onAbort = () => {
5324 signal.removeEventListener("abort", onAbort);
5325 resolve(null);
5326 };
5327
5328 signal.addEventListener("abort", onAbort, {
5329 once: true
5330 });
5331
5332 iterator.next().then(
5333 (result) => {
5334 signal.removeEventListener("abort", onAbort);
5335 resolve(result);
5336 },
5337 (error) => {
5338 signal.removeEventListener("abort", onAbort);
5339 reject(error);
5340 }
5341 );
5342 });
5343}
5344
5345function buildBrowserSseSuccessResponse(
5346 execution: BrowserRequestExecutionResult,
5347 signal?: AbortSignal
5348): ConductorHttpResponse {
5349 if (execution.stream == null || execution.lease == null) {
5350 return createSseResponse(
5351 serializeSseFrame(
5352 "stream_error",
5353 compactJsonObject({
5354 error: "browser_stream_unavailable",
5355 message: "Browser stream execution did not return an active stream.",
5356 platform: execution.platform,
5357 request_id: execution.request_id,
5358 stream_id: execution.request_id
5359 })
5360 )
5361 );
5362 }
5363
5364 const stream = execution.stream;
5365 const iterator = stream[Symbol.asyncIterator]();
5366 let leaseCompleted = false;
5367
5368 const completeLease = (outcome: {
5369 code?: string | null;
5370 message?: string | null;
5371 status: "cancelled" | "failure" | "success";
5372 }) => {
5373 if (leaseCompleted) {
5374 return;
5375 }
5376
5377 leaseCompleted = true;
5378 execution.lease?.complete(outcome);
5379 };
5380
5381 const streamBody = (async function* (): AsyncGenerator<string> {
5382 try {
5383 while (true) {
5384 const next = await nextBrowserStreamResult(iterator, signal);
5385
5386 if (next == null) {
5387 stream.cancel("downstream_disconnected");
5388 execution.lease?.touch("sse_downstream_disconnected");
5389 completeLease({
5390 code: "downstream_disconnected",
5391 message: "The HTTP SSE client disconnected before the browser stream completed.",
5392 status: "cancelled"
5393 });
5394 return;
5395 }
5396
5397 if (next.done) {
5398 execution.lease?.touch("sse_iterator_done");
5399 completeLease({
5400 status: "success"
5401 });
5402 return;
5403 }
5404
5405 const event = next.value;
5406
5407 switch (event.type) {
5408 case "stream_open":
5409 execution.lease?.touch("stream_open");
5410 yield serializeSseFrame(
5411 "stream_open",
5412 compactJsonObject({
5413 client_id: execution.client_id,
5414 conversation: serializeClaudeConversationSummary(execution.conversation) ?? undefined,
5415 meta: parseBrowserProxyBody(event.meta) ?? undefined,
5416 organization: serializeClaudeOrganizationSummary(execution.organization) ?? undefined,
5417 platform: execution.platform,
5418 policy: serializeBrowserRequestPolicyAdmission(execution.policy),
5419 request_id: execution.request_id,
5420 request_method: execution.request_method,
5421 request_mode: execution.request_mode,
5422 request_path: execution.request_path,
5423 response_mode: execution.response_mode,
5424 status: event.status ?? undefined,
5425 stream_id: event.streamId
5426 })
5427 );
5428 break;
5429 case "stream_event":
5430 execution.lease?.touch("stream_event");
5431 yield serializeSseFrame(
5432 "stream_event",
5433 compactJsonObject({
5434 data: parseBrowserProxyBody(event.data) ?? null,
5435 event: event.event ?? undefined,
5436 raw: event.raw ?? undefined,
5437 request_id: execution.request_id,
5438 seq: event.seq,
5439 stream_id: event.streamId
5440 })
5441 );
5442 break;
5443 case "stream_end":
5444 execution.lease?.touch("stream_end");
5445 yield serializeSseFrame(
5446 "stream_end",
5447 compactJsonObject({
5448 partial: serializeBrowserStreamPartialState(event.partial),
5449 request_id: execution.request_id,
5450 status: event.status ?? undefined,
5451 stream_id: event.streamId
5452 })
5453 );
5454 completeLease({
5455 status: "success"
5456 });
5457 return;
5458 case "stream_error":
5459 execution.lease?.touch("stream_error");
5460 yield serializeSseFrame(
5461 "stream_error",
5462 compactJsonObject({
5463 error: event.code,
5464 message: event.message,
5465 partial: serializeBrowserStreamPartialState(event.partial),
5466 request_id: execution.request_id,
5467 status: event.status ?? undefined,
5468 stream_id: event.streamId
5469 })
5470 );
5471 completeLease(
5472 event.code === "request_cancelled" || event.code === "downstream_disconnected"
5473 ? {
5474 code: event.code,
5475 message: event.message,
5476 status: "cancelled"
5477 }
5478 : {
5479 code: event.code,
5480 message: event.message,
5481 status: "failure"
5482 }
5483 );
5484 return;
5485 }
5486 }
5487 } finally {
5488 if (!leaseCompleted) {
5489 stream.cancel("stream_closed");
5490 execution.lease?.touch("stream_closed");
5491 completeLease({
5492 code: "stream_closed",
5493 message: "Browser stream was closed before the conductor completed the SSE relay.",
5494 status: "cancelled"
5495 });
5496 }
5497 }
5498 })();
5499
5500 return createSseResponse("", streamBody);
5501}
5502
5503function buildBrowserSseErrorResponse(
5504 input: {
5505 error: string;
5506 message: string;
5507 platform: string;
5508 requestId: string;
5509 status?: number | null;
5510 }
5511): ConductorHttpResponse {
5512 return createSseResponse(
5513 serializeSseFrame(
5514 "stream_error",
5515 compactJsonObject({
5516 error: input.error,
5517 message: input.message,
5518 platform: input.platform,
5519 request_id: input.requestId,
5520 status: input.status ?? undefined,
5521 stream_id: input.requestId
5522 })
5523 )
5524 );
5525}
5526
5527function buildBrowserActionDispatchResult(
5528 dispatch: {
5529 clientId: string;
5530 connectionId: string;
5531 dispatchedAt: number;
5532 requestId: string;
5533 type: string;
5534 },
5535 actionResult: BrowserBridgeActionResultSnapshot,
5536 input: {
5537 action: BrowserActionName;
5538 disconnectMs?: number | null;
5539 platform?: string | null;
5540 reason?: string | null;
5541 repeatCount?: number | null;
5542 repeatIntervalMs?: number | null;
5543 }
5544): BrowserActionDispatchResult {
5545 return {
5546 accepted: actionResult.accepted,
5547 action: input.action,
5548 client_id: dispatch.clientId,
5549 completed: actionResult.completed,
5550 connection_id: dispatch.connectionId,
5551 dispatched_at: dispatch.dispatchedAt,
5552 failed: actionResult.failed,
5553 platform: input.platform ?? actionResult.target.platform ?? null,
5554 reason: actionResult.reason ?? input.reason ?? null,
5555 request_id: dispatch.requestId,
5556 result: compactJsonObject({
5557 actual_count: actionResult.result.actual_count,
5558 desired_count: actionResult.result.desired_count,
5559 drift_count: actionResult.result.drift_count,
5560 failed_count: actionResult.result.failed_count,
5561 ok_count: actionResult.result.ok_count,
5562 platform_count: actionResult.result.platform_count,
5563 restored_count: actionResult.result.restored_count,
5564 skipped_reasons: actionResult.result.skipped_reasons
5565 }),
5566 results: actionResult.results.map(serializeBrowserActionResultItemSnapshot),
5567 shell_runtime: actionResult.shell_runtime.map(serializeBrowserShellRuntimeSnapshot),
5568 target: compactJsonObject({
5569 client_id: actionResult.target.client_id ?? dispatch.clientId,
5570 connection_id: actionResult.target.connection_id ?? dispatch.connectionId,
5571 platform: actionResult.target.platform ?? input.platform ?? undefined,
5572 requested_client_id: actionResult.target.requested_client_id ?? undefined,
5573 requested_platform: actionResult.target.requested_platform ?? input.platform ?? undefined
5574 }),
5575 type: dispatch.type
5576 };
5577}
5578
5579async function dispatchBrowserAction(
5580 context: LocalApiRequestContext,
5581 input: {
5582 action: BrowserActionName;
5583 clientId?: string | null;
5584 disconnectMs?: number | null;
5585 platform?: string | null;
5586 reason?: string | null;
5587 repeatCount?: number | null;
5588 repeatIntervalMs?: number | null;
5589 }
5590): Promise<BrowserActionDispatchResult> {
5591 try {
5592 switch (input.action) {
5593 case "tab_open":
5594 case "tab_focus": {
5595 const dispatch = requireBrowserBridge(context).openTab({
5596 clientId: input.clientId,
5597 platform: input.platform
5598 });
5599 return buildBrowserActionDispatchResult(dispatch, await dispatch.result, input);
5600 }
5601 case "tab_reload": {
5602 const dispatch = requireBrowserBridge(context).reload({
5603 clientId: input.clientId,
5604 platform: input.platform,
5605 reason: input.reason
5606 });
5607 return buildBrowserActionDispatchResult(dispatch, await dispatch.result, input);
5608 }
5609 case "request_credentials": {
5610 const dispatch = requireBrowserBridge(context).requestCredentials({
5611 clientId: input.clientId,
5612 platform: input.platform,
5613 reason: input.reason
5614 });
5615 return buildBrowserActionDispatchResult(dispatch, await dispatch.result, input);
5616 }
5617 case "plugin_status":
5618 case "ws_reconnect":
5619 case "controller_reload":
5620 case "tab_restore": {
5621 const dispatch = requireBrowserBridge(context).dispatchPluginAction({
5622 action: input.action,
5623 clientId: input.clientId,
5624 disconnectMs: input.disconnectMs,
5625 platform: input.platform,
5626 reason: input.reason,
5627 repeatCount: input.repeatCount,
5628 repeatIntervalMs: input.repeatIntervalMs
5629 });
5630 return buildBrowserActionDispatchResult(dispatch, await dispatch.result, input);
5631 }
5632 }
5633 } catch (error) {
5634 if (error instanceof LocalApiHttpError) {
5635 throw error;
5636 }
5637
5638 throw createBrowserBridgeHttpError(`browser action ${input.action}`, error);
5639 }
5640}
5641
5642async function executeBrowserRequest(
5643 context: LocalApiRequestContext,
5644 input: {
5645 clientId?: string | null;
5646 conversationId?: string | null;
5647 headers?: Record<string, string>;
5648 method?: string | null;
5649 organizationId?: string | null;
5650 path?: string | null;
5651 platform: string;
5652 prompt?: string | null;
5653 requestBody?: JsonValue;
5654 requestId?: string | null;
5655 responseMode?: BrowserRequestResponseMode;
5656 timeoutMs?: number;
5657 }
5658): Promise<BrowserRequestExecutionResult> {
5659 const responseMode = input.responseMode ?? "buffered";
5660 const requestId = resolveBrowserRequestId(input.requestId);
5661
5662 const explicitPath = normalizeOptionalString(input.path);
5663 const prompt = normalizeOptionalString(input.prompt);
5664 const requestBody =
5665 input.requestBody !== undefined ? input.requestBody : prompt == null ? undefined : { prompt };
5666 const requestMethod =
5667 normalizeOptionalString(input.method)?.toUpperCase()
5668 ?? (
5669 requestBody !== undefined || explicitPath == null
5670 ? "POST"
5671 : "GET"
5672 );
5673
5674 if (explicitPath == null) {
5675 if (input.platform !== BROWSER_CLAUDE_PLATFORM || prompt == null) {
5676 throw new LocalApiHttpError(
5677 400,
5678 "invalid_request",
5679 'Field "path" is required unless platform="claude" and a non-empty "prompt" is provided.',
5680 {
5681 field: "path",
5682 platform: input.platform
5683 }
5684 );
5685 }
5686
5687 const selection = ensureClaudeBridgeReady(
5688 selectClaudeBrowserClient(loadBrowserState(context), input.clientId),
5689 input.clientId
5690 );
5691 const lease = await beginBrowserRequestLease(
5692 context,
5693 {
5694 clientId: selection.client.client_id,
5695 platform: input.platform
5696 },
5697 requestId
5698 );
5699 lease.touch("claude_request_begin");
5700
5701 try {
5702 const organization = await resolveClaudeOrganization(
5703 context,
5704 selection,
5705 input.organizationId,
5706 input.timeoutMs
5707 );
5708 lease.touch("claude_organization_resolved");
5709 const conversation = await resolveClaudeConversation(
5710 context,
5711 selection,
5712 organization.id,
5713 {
5714 conversationId: input.conversationId,
5715 createIfMissing: true,
5716 timeoutMs: input.timeoutMs
5717 }
5718 );
5719 lease.touch("claude_conversation_resolved");
5720 const requestPath = buildClaudeRequestPath(
5721 BROWSER_CLAUDE_COMPLETION_PATH,
5722 organization.id,
5723 conversation.id
5724 );
5725
5726 if (responseMode === "sse") {
5727 lease.touch("stream_created");
5728 const stream = requireBrowserBridge(context).streamRequest({
5729 body: requestBody ?? { prompt: "" },
5730 clientId: selection.client.client_id,
5731 conversationId: input.conversationId ?? conversation.id,
5732 headers: input.headers,
5733 id: requestId,
5734 idleTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.idleTimeoutMs,
5735 maxBufferedBytes: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedBytes,
5736 maxBufferedEvents: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedEvents,
5737 method: requestMethod,
5738 openTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.openTimeoutMs,
5739 path: requestPath,
5740 platform: input.platform,
5741 streamId: requestId,
5742 timeoutMs: input.timeoutMs
5743 });
5744
5745 return {
5746 client_id: selection.client.client_id,
5747 conversation,
5748 lease,
5749 organization,
5750 platform: input.platform,
5751 policy: lease.admission,
5752 request_body: requestBody ?? null,
5753 request_id: requestId,
5754 request_method: requestMethod,
5755 request_mode: "claude_prompt",
5756 request_path: requestPath,
5757 response: null,
5758 response_mode: responseMode,
5759 status: null,
5760 stream
5761 };
5762 }
5763
5764 lease.touch("buffered_proxy_dispatch");
5765 const result = await requestBrowserProxy(context, {
5766 action: "browser request",
5767 body: requestBody ?? { prompt: "" },
5768 clientId: selection.client.client_id,
5769 conversationId: input.conversationId ?? conversation.id,
5770 headers: input.headers,
5771 id: requestId,
5772 method: requestMethod,
5773 path: requestPath,
5774 platform: input.platform,
5775 timeoutMs: input.timeoutMs
5776 });
5777 lease.touch("buffered_response_received");
5778 lease.complete({
5779 status: "success"
5780 });
5781
5782 return {
5783 client_id: result.apiResponse.clientId,
5784 conversation,
5785 lease: null,
5786 organization,
5787 platform: input.platform,
5788 policy: lease.admission,
5789 request_body: requestBody ?? null,
5790 request_id: result.apiResponse.id,
5791 request_method: requestMethod,
5792 request_mode: "claude_prompt",
5793 request_path: requestPath,
5794 response: result.body,
5795 response_mode: responseMode,
5796 status: result.apiResponse.status,
5797 stream: null
5798 };
5799 } catch (error) {
5800 lease.complete(buildBrowserRequestLeaseOutcome(error));
5801 throw error;
5802 }
5803 }
5804
5805 const targetClient =
5806 input.platform === BROWSER_CLAUDE_PLATFORM
5807 ? ensureClaudeBridgeReady(
5808 selectClaudeBrowserClient(loadBrowserState(context), input.clientId),
5809 input.clientId
5810 ).client
5811 : ensureBrowserClientReady(
5812 selectBrowserClient(loadBrowserState(context), input.clientId),
5813 input.platform,
5814 input.clientId
5815 );
5816 const lease = await beginBrowserRequestLease(
5817 context,
5818 {
5819 clientId: targetClient.client_id,
5820 platform: input.platform
5821 },
5822 requestId
5823 );
5824 lease.touch("browser_request_begin");
5825
5826 try {
5827 if (responseMode === "sse") {
5828 lease.touch("stream_created");
5829 const stream = requireBrowserBridge(context).streamRequest({
5830 body: requestBody,
5831 clientId: targetClient.client_id,
5832 conversationId: input.conversationId,
5833 headers: input.headers,
5834 id: requestId,
5835 idleTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.idleTimeoutMs,
5836 maxBufferedBytes: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedBytes,
5837 maxBufferedEvents: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedEvents,
5838 method: requestMethod,
5839 openTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.openTimeoutMs,
5840 path: explicitPath,
5841 platform: input.platform,
5842 streamId: requestId,
5843 timeoutMs: input.timeoutMs
5844 });
5845
5846 return {
5847 client_id: targetClient.client_id,
5848 conversation: null,
5849 lease,
5850 organization: null,
5851 platform: input.platform,
5852 policy: lease.admission,
5853 request_body: requestBody ?? null,
5854 request_id: requestId,
5855 request_method: requestMethod,
5856 request_mode: "api_request",
5857 request_path: explicitPath,
5858 response: null,
5859 response_mode: responseMode,
5860 status: null,
5861 stream
5862 };
5863 }
5864
5865 lease.touch("buffered_proxy_dispatch");
5866 const result = await requestBrowserProxy(context, {
5867 action: "browser request",
5868 body: requestBody,
5869 clientId: targetClient.client_id,
5870 conversationId: input.conversationId,
5871 headers: input.headers,
5872 id: requestId,
5873 method: requestMethod,
5874 path: explicitPath,
5875 platform: input.platform,
5876 timeoutMs: input.timeoutMs
5877 });
5878 lease.touch("buffered_response_received");
5879 lease.complete({
5880 status: "success"
5881 });
5882
5883 return {
5884 client_id: result.apiResponse.clientId,
5885 conversation: null,
5886 lease: null,
5887 organization: null,
5888 platform: input.platform,
5889 policy: lease.admission,
5890 request_body: requestBody ?? null,
5891 request_id: result.apiResponse.id,
5892 request_method: requestMethod,
5893 request_mode: "api_request",
5894 request_path: explicitPath,
5895 response: result.body,
5896 response_mode: responseMode,
5897 status: result.apiResponse.status,
5898 stream: null
5899 };
5900 } catch (error) {
5901 lease.complete(buildBrowserRequestLeaseOutcome(error));
5902 throw error;
5903 }
5904}
5905
5906async function handleBrowserActions(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
5907 const body = readBodyObject(context.request, true);
5908 const action = readBrowserActionName(body);
5909 const clientId = readOptionalStringBodyField(body, "clientId", "client_id");
5910 const platform = readOptionalStringBodyField(body, "platform");
5911 const reason = readOptionalStringBodyField(body, "reason");
5912 const disconnectMs = action === "ws_reconnect"
5913 ? readOptionalIntegerBodyField(body, {
5914 allowZero: true,
5915 fieldNames: ["disconnectMs", "disconnect_ms", "delayMs", "delay_ms"],
5916 label: "disconnectMs",
5917 max: MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS
5918 })
5919 : undefined;
5920 const repeatCount = action === "ws_reconnect"
5921 ? readOptionalIntegerBodyField(body, {
5922 fieldNames: ["repeatCount", "repeat_count"],
5923 label: "repeatCount",
5924 max: MAX_BROWSER_WS_RECONNECT_REPEAT_COUNT
5925 })
5926 : undefined;
5927 const repeatIntervalMs = action === "ws_reconnect"
5928 ? readOptionalIntegerBodyField(body, {
5929 allowZero: true,
5930 fieldNames: ["repeatIntervalMs", "repeat_interval_ms", "intervalMs", "interval_ms"],
5931 label: "repeatIntervalMs",
5932 max: MAX_BROWSER_WS_RECONNECT_REPEAT_INTERVAL_MS
5933 })
5934 : undefined;
5935
5936 if (
5937 (action === "request_credentials" || action === "tab_focus" || action === "tab_open")
5938 && platform == null
5939 ) {
5940 throw new LocalApiHttpError(
5941 400,
5942 "invalid_request",
5943 `Field "platform" is required for browser action "${action}".`,
5944 {
5945 action,
5946 field: "platform"
5947 }
5948 );
5949 }
5950
5951 return buildSuccessEnvelope(
5952 context.requestId,
5953 200,
5954 await dispatchBrowserAction(context, {
5955 action,
5956 clientId,
5957 disconnectMs,
5958 platform,
5959 reason,
5960 repeatCount,
5961 repeatIntervalMs
5962 }) as unknown as JsonValue
5963 );
5964}
5965
5966async function handleBrowserRequest(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
5967 const body = readBodyObject(context.request, true);
5968 const platform = readOptionalStringBodyField(body, "platform");
5969 const responseMode = readBrowserRequestResponseMode(body);
5970 const requestId = readOptionalStringBodyField(body, "requestId", "request_id", "id");
5971
5972 if (platform == null) {
5973 throw new LocalApiHttpError(
5974 400,
5975 "invalid_request",
5976 'Field "platform" is required for POST /v1/browser/request.',
5977 {
5978 field: "platform"
5979 }
5980 );
5981 }
5982
5983 try {
5984 const execution = await executeBrowserRequest(context, {
5985 clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
5986 conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
5987 headers:
5988 readOptionalStringMap(body, "headers")
5989 ?? readOptionalStringMap(body, "request_headers")
5990 ?? undefined,
5991 method: readOptionalStringBodyField(body, "method"),
5992 organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
5993 path: readOptionalStringBodyField(body, "path"),
5994 platform,
5995 prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
5996 requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
5997 requestId,
5998 responseMode,
5999 timeoutMs: readOptionalTimeoutMs(body, context.url)
6000 });
6001
6002 if (responseMode === "sse") {
6003 return buildBrowserSseSuccessResponse(execution, context.request.signal);
6004 }
6005
6006 return buildSuccessEnvelope(context.requestId, 200, {
6007 client_id: execution.client_id,
6008 conversation: serializeClaudeConversationSummary(execution.conversation),
6009 organization: serializeClaudeOrganizationSummary(execution.organization),
6010 platform: execution.platform,
6011 policy: serializeBrowserRequestPolicyAdmission(execution.policy),
6012 proxy: {
6013 method: execution.request_method,
6014 path: execution.request_path,
6015 request_body: execution.request_body,
6016 request_id: execution.request_id,
6017 response_mode: execution.response_mode,
6018 status: execution.status
6019 },
6020 request_mode: execution.request_mode,
6021 response: execution.response
6022 });
6023 } catch (error) {
6024 if (responseMode !== "sse" || !(error instanceof LocalApiHttpError)) {
6025 throw error;
6026 }
6027
6028 return buildBrowserSseErrorResponse({
6029 error: error.error,
6030 message: error.message,
6031 platform,
6032 requestId:
6033 readUnknownString(asUnknownRecord(error.details), ["bridge_request_id"])
6034 ?? requestId
6035 ?? context.requestId,
6036 status: error.status
6037 });
6038 }
6039}
6040
6041async function handleBrowserRequestCancel(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6042 const body = readBodyObject(context.request, true);
6043 const requestId = readOptionalStringBodyField(body, "requestId", "request_id", "id");
6044 const platform = readOptionalStringBodyField(body, "platform");
6045 const clientId = readOptionalStringBodyField(body, "clientId", "client_id");
6046 const reason = readOptionalStringBodyField(body, "reason");
6047
6048 if (requestId == null) {
6049 throw new LocalApiHttpError(
6050 400,
6051 "invalid_request",
6052 'Field "requestId" is required for POST /v1/browser/request/cancel.',
6053 {
6054 field: "requestId"
6055 }
6056 );
6057 }
6058
6059 if (platform == null) {
6060 throw new LocalApiHttpError(
6061 400,
6062 "invalid_request",
6063 'Field "platform" is required for POST /v1/browser/request/cancel.',
6064 {
6065 field: "platform"
6066 }
6067 );
6068 }
6069
6070 let dispatch;
6071
6072 try {
6073 dispatch = requireBrowserBridge(context).cancelApiRequest({
6074 clientId,
6075 platform,
6076 reason,
6077 requestId
6078 });
6079 } catch (error) {
6080 if (error instanceof LocalApiHttpError) {
6081 throw error;
6082 }
6083
6084 if (readBridgeErrorCode(error) === "request_not_found") {
6085 throw new LocalApiHttpError(
6086 404,
6087 "browser_request_not_found",
6088 `Browser request "${requestId}" is not in flight.`,
6089 compactJsonObject({
6090 client_id: clientId ?? undefined,
6091 platform,
6092 reason: reason ?? undefined,
6093 request_id: requestId,
6094 route: "/v1/browser/request/cancel"
6095 })
6096 );
6097 }
6098
6099 if (readBridgeErrorCode(error) === "client_not_found" && clientId != null) {
6100 throw new LocalApiHttpError(
6101 409,
6102 "browser_request_client_mismatch",
6103 `Browser request "${requestId}" is not running on the requested browser bridge client.`,
6104 compactJsonObject({
6105 client_id: clientId,
6106 platform,
6107 reason: reason ?? undefined,
6108 request_id: requestId,
6109 route: "/v1/browser/request/cancel"
6110 })
6111 );
6112 }
6113
6114 throw createBrowserBridgeHttpError(`browser request cancel ${requestId}`, error);
6115 }
6116
6117 return buildSuccessEnvelope(context.requestId, 200, {
6118 client_id: dispatch.clientId,
6119 connection_id: dispatch.connectionId,
6120 dispatched_at: dispatch.dispatchedAt,
6121 platform,
6122 reason: reason ?? null,
6123 request_id: dispatch.requestId,
6124 status: "cancel_requested",
6125 type: dispatch.type
6126 });
6127}
6128
6129function buildBrowserSendSuccessPayload(execution: BrowserRequestExecutionResult): JsonObject {
6130 return compactJsonObject({
6131 client_id: execution.client_id,
6132 conversation: serializeClaudeConversationSummary(execution.conversation),
6133 organization: serializeClaudeOrganizationSummary(execution.organization),
6134 platform: execution.platform,
6135 policy: serializeBrowserRequestPolicyAdmission(execution.policy),
6136 proxy: {
6137 method: execution.request_method,
6138 path: execution.request_path,
6139 request_body: execution.request_body,
6140 request_id: execution.request_id,
6141 response_mode: execution.response_mode,
6142 status: execution.status
6143 },
6144 request_mode: execution.request_mode,
6145 response: execution.response
6146 });
6147}
6148
6149async function handleBrowserLegacySend(
6150 context: LocalApiRequestContext,
6151 input: {
6152 defaultPath: string;
6153 platform: string;
6154 }
6155): Promise<ConductorHttpResponse> {
6156 const body = readBodyObject(context.request, true);
6157 const responseMode = readBrowserRequestResponseMode(body);
6158
6159 try {
6160 const execution = await executeBrowserRequest(context, {
6161 clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
6162 conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
6163 headers:
6164 readOptionalStringMap(body, "headers")
6165 ?? readOptionalStringMap(body, "request_headers")
6166 ?? undefined,
6167 method: readOptionalStringBodyField(body, "method") ?? "POST",
6168 organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
6169 path: readOptionalStringBodyField(body, "path") ?? input.defaultPath,
6170 platform: input.platform,
6171 prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
6172 requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
6173 requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
6174 responseMode,
6175 timeoutMs: readOptionalTimeoutMs(body, context.url)
6176 });
6177
6178 if (responseMode === "sse") {
6179 return buildBrowserSseSuccessResponse(execution, context.request.signal);
6180 }
6181
6182 return buildSuccessEnvelope(context.requestId, 200, buildBrowserSendSuccessPayload(execution));
6183 } catch (error) {
6184 if (responseMode !== "sse" || !(error instanceof LocalApiHttpError)) {
6185 throw error;
6186 }
6187
6188 return buildBrowserSseErrorResponse({
6189 error: error.error,
6190 message: error.message,
6191 platform: input.platform,
6192 requestId:
6193 readUnknownString(asUnknownRecord(error.details), ["bridge_request_id"])
6194 ?? readOptionalStringBodyField(body, "requestId", "request_id", "id")
6195 ?? context.requestId,
6196 status: error.status
6197 });
6198 }
6199}
6200
6201function selectBrowserLegacyCurrentSelection(
6202 context: LocalApiRequestContext,
6203 platform: string,
6204 requestedClientId?: string | null,
6205 requestedConversationId?: string | null
6206): {
6207 client: BrowserBridgeClientSnapshot;
6208 credential: BrowserBridgeCredentialSnapshot | null;
6209 latestFinalMessage: BrowserBridgeFinalMessageSnapshot | null;
6210 requestHook: BrowserBridgeRequestHookSnapshot | null;
6211 shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
6212} {
6213 const client = ensureBrowserClientReady(
6214 selectBrowserClient(loadBrowserState(context), requestedClientId),
6215 platform,
6216 requestedClientId
6217 );
6218 const credential = findBrowserCredentialForPlatform(client, platform);
6219
6220 return {
6221 client,
6222 credential,
6223 latestFinalMessage: findLatestBrowserFinalMessage(client, platform, requestedConversationId),
6224 requestHook: findMatchingRequestHook(client, platform, credential?.account ?? null),
6225 shellRuntime: findMatchingShellRuntime(client, platform)
6226 };
6227}
6228
6229function buildBrowserLegacyCurrentPage(
6230 selection: {
6231 client: BrowserBridgeClientSnapshot;
6232 credential: BrowserBridgeCredentialSnapshot | null;
6233 latestFinalMessage: BrowserBridgeFinalMessageSnapshot | null;
6234 requestHook: BrowserBridgeRequestHookSnapshot | null;
6235 shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
6236 },
6237 conversationId: string | null
6238): JsonObject {
6239 return compactJsonObject({
6240 client_id: selection.client.client_id,
6241 conversation_id: conversationId ?? undefined,
6242 credentials:
6243 selection.credential == null
6244 ? undefined
6245 : serializeBrowserCredentialSnapshot(selection.credential),
6246 final_message:
6247 selection.latestFinalMessage == null
6248 ? undefined
6249 : serializeBrowserFinalMessageSnapshot(selection.latestFinalMessage),
6250 request_hooks:
6251 selection.requestHook == null
6252 ? undefined
6253 : serializeBrowserRequestHookSnapshot(selection.requestHook),
6254 shell_runtime:
6255 selection.shellRuntime == null
6256 ? undefined
6257 : serializeBrowserShellRuntimeSnapshot(selection.shellRuntime)
6258 });
6259}
6260
6261async function handleBrowserLegacyCurrent(
6262 context: LocalApiRequestContext,
6263 input: {
6264 platform: string;
6265 resolveProxyPath?: ((conversationId: string) => string) | undefined;
6266 }
6267): Promise<ConductorHttpResponse> {
6268 const timeoutMs = readOptionalTimeoutMs({}, context.url);
6269 const clientId = readOptionalQueryString(context.url, "clientId", "client_id");
6270 const requestedConversationId = readOptionalQueryString(
6271 context.url,
6272 "conversationId",
6273 "conversation_id"
6274 );
6275 const selection = selectBrowserLegacyCurrentSelection(
6276 context,
6277 input.platform,
6278 clientId,
6279 requestedConversationId
6280 );
6281 const conversationId = resolveBrowserLegacyConversationId(
6282 input.platform,
6283 selection,
6284 requestedConversationId
6285 );
6286 const page = buildBrowserLegacyCurrentPage(selection, conversationId);
6287 const finalMessage = selection.latestFinalMessage;
6288 const messages = buildBrowserLegacyCurrentMessages(finalMessage);
6289
6290 if (conversationId != null && input.resolveProxyPath != null) {
6291 const path = input.resolveProxyPath(conversationId);
6292 const detail = await requestBrowserProxy(context, {
6293 action: `${input.platform} conversation read`,
6294 clientId: selection.client.client_id,
6295 method: "GET",
6296 path,
6297 platform: input.platform,
6298 timeoutMs
6299 });
6300
6301 return buildSuccessEnvelope(context.requestId, 200, {
6302 conversation: {
6303 conversation_id: conversationId
6304 },
6305 final_message:
6306 finalMessage == null ? null : serializeBrowserFinalMessageSnapshot(finalMessage),
6307 messages,
6308 page,
6309 platform: input.platform,
6310 proxy: {
6311 path,
6312 request_id: detail.apiResponse.id,
6313 status: detail.apiResponse.status
6314 },
6315 raw: detail.body
6316 });
6317 }
6318
6319 return buildSuccessEnvelope(context.requestId, 200, {
6320 conversation:
6321 conversationId == null
6322 ? null
6323 : {
6324 conversation_id: conversationId
6325 },
6326 final_message:
6327 finalMessage == null ? null : serializeBrowserFinalMessageSnapshot(finalMessage),
6328 messages,
6329 page,
6330 platform: input.platform,
6331 proxy: null,
6332 raw: null
6333 });
6334}
6335
6336async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6337 const body = readBodyObject(context.request, true);
6338 const dispatch = await dispatchBrowserAction(context, {
6339 action: "tab_open",
6340 clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
6341 platform: BROWSER_CLAUDE_PLATFORM
6342 });
6343
6344 return buildSuccessEnvelope(context.requestId, 200, {
6345 ...dispatch,
6346 open_url: BROWSER_CLAUDE_ROOT_URL,
6347 platform: BROWSER_CLAUDE_PLATFORM
6348 });
6349}
6350
6351async function handleBrowserClaudeSend(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6352 const body = readBodyObject(context.request, true);
6353 const responseMode = readBrowserRequestResponseMode(body);
6354 try {
6355 const execution = await executeBrowserRequest(context, {
6356 clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
6357 conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
6358 headers:
6359 readOptionalStringMap(body, "headers")
6360 ?? readOptionalStringMap(body, "request_headers")
6361 ?? undefined,
6362 method: readOptionalStringBodyField(body, "method") ?? "POST",
6363 organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
6364 path: readOptionalStringBodyField(body, "path"),
6365 platform: BROWSER_CLAUDE_PLATFORM,
6366 prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
6367 requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
6368 requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
6369 responseMode,
6370 timeoutMs: readOptionalTimeoutMs(body, context.url)
6371 });
6372
6373 if (responseMode === "sse") {
6374 return buildBrowserSseSuccessResponse(execution, context.request.signal);
6375 }
6376
6377 return buildSuccessEnvelope(context.requestId, 200, {
6378 client_id: execution.client_id,
6379 conversation: serializeClaudeConversationSummary(execution.conversation),
6380 organization: serializeClaudeOrganizationSummary(execution.organization),
6381 platform: BROWSER_CLAUDE_PLATFORM,
6382 policy: serializeBrowserRequestPolicyAdmission(execution.policy),
6383 proxy: {
6384 path: execution.request_path,
6385 request_body: execution.request_body,
6386 request_id: execution.request_id,
6387 status: execution.status
6388 },
6389 response: execution.response
6390 });
6391 } catch (error) {
6392 if (responseMode !== "sse" || !(error instanceof LocalApiHttpError)) {
6393 throw error;
6394 }
6395
6396 return buildBrowserSseErrorResponse({
6397 error: error.error,
6398 message: error.message,
6399 platform: BROWSER_CLAUDE_PLATFORM,
6400 requestId:
6401 readUnknownString(asUnknownRecord(error.details), ["bridge_request_id"])
6402 ?? readOptionalStringBodyField(body, "requestId", "request_id", "id")
6403 ?? context.requestId,
6404 status: error.status
6405 });
6406 }
6407}
6408
6409async function handleBrowserClaudeCurrent(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6410 const timeoutMs = readOptionalTimeoutMs({}, context.url);
6411 const clientId = readOptionalQueryString(context.url, "clientId", "client_id");
6412 const organizationId = readOptionalQueryString(context.url, "organizationId", "organization_id");
6413 const conversationId = readOptionalQueryString(context.url, "conversationId", "conversation_id");
6414 const current = await readClaudeConversationCurrentData(context, {
6415 clientId,
6416 conversationId,
6417 organizationId,
6418 timeoutMs
6419 });
6420
6421 return buildSuccessEnvelope(context.requestId, 200, {
6422 conversation: {
6423 conversation_id: current.conversation.id,
6424 created_at: current.conversation.created_at,
6425 title: current.conversation.title,
6426 updated_at: current.conversation.updated_at
6427 },
6428 messages: collectClaudeMessages(current.detail.body),
6429 organization: {
6430 name: current.organization.name,
6431 organization_id: current.organization.id
6432 },
6433 page: current.page,
6434 platform: BROWSER_CLAUDE_PLATFORM,
6435 proxy: {
6436 path: buildClaudeRequestPath(
6437 BROWSER_CLAUDE_CONVERSATION_PATH,
6438 current.organization.id,
6439 current.conversation.id
6440 ),
6441 request_id: current.detail.apiResponse.id,
6442 status: current.detail.apiResponse.status
6443 },
6444 raw: current.detail.body
6445 });
6446}
6447
6448async function handleBrowserChatgptSend(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6449 return handleBrowserLegacySend(context, {
6450 defaultPath: BROWSER_CHATGPT_CONVERSATION_PATH,
6451 platform: BROWSER_CHATGPT_PLATFORM
6452 });
6453}
6454
6455async function handleBrowserChatgptCurrent(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6456 return handleBrowserLegacyCurrent(context, {
6457 platform: BROWSER_CHATGPT_PLATFORM,
6458 resolveProxyPath: buildChatgptConversationPath
6459 });
6460}
6461
6462async function handleBrowserGeminiSend(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6463 return handleBrowserLegacySend(context, {
6464 defaultPath: BROWSER_GEMINI_STREAM_GENERATE_PATH,
6465 platform: BROWSER_GEMINI_PLATFORM
6466 });
6467}
6468
6469async function handleBrowserGeminiCurrent(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6470 return handleBrowserLegacyCurrent(context, {
6471 platform: BROWSER_GEMINI_PLATFORM
6472 });
6473}
6474
6475async function handleBrowserClaudeReload(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6476 const body = readBodyObject(context.request, true);
6477 const reason = readOptionalStringBodyField(body, "reason") ?? "browser_http_reload";
6478 const dispatch = await dispatchBrowserAction(context, {
6479 action: "tab_reload",
6480 clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
6481 platform: BROWSER_CLAUDE_PLATFORM,
6482 reason
6483 });
6484
6485 return buildSuccessEnvelope(context.requestId, 200, {
6486 ...dispatch,
6487 platform: BROWSER_CLAUDE_PLATFORM,
6488 reason
6489 });
6490}
6491
6492function buildHostOperationsAuthError(
6493 route: LocalApiRouteDefinition,
6494 reason: SharedTokenAuthFailureReason
6495): LocalApiHttpError {
6496 const details: JsonObject = {
6497 auth_scheme: "Bearer",
6498 env_var: "BAA_SHARED_TOKEN",
6499 route_id: route.id
6500 };
6501
6502 switch (reason) {
6503 case "missing_authorization_header":
6504 return new LocalApiHttpError(
6505 401,
6506 "unauthorized",
6507 `${route.method} ${route.pathPattern} requires ${HOST_OPERATIONS_AUTH_HEADER}.`,
6508 {
6509 ...details,
6510 reason
6511 },
6512 {
6513 "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
6514 }
6515 );
6516 case "invalid_authorization_scheme":
6517 return new LocalApiHttpError(
6518 401,
6519 "unauthorized",
6520 `${route.method} ${route.pathPattern} requires a Bearer token in the Authorization header.`,
6521 {
6522 ...details,
6523 reason
6524 },
6525 {
6526 "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
6527 }
6528 );
6529 case "empty_bearer_token":
6530 return new LocalApiHttpError(
6531 401,
6532 "unauthorized",
6533 `${route.method} ${route.pathPattern} requires a non-empty Bearer token.`,
6534 {
6535 ...details,
6536 reason
6537 },
6538 {
6539 "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
6540 }
6541 );
6542 case "invalid_token":
6543 return new LocalApiHttpError(
6544 401,
6545 "unauthorized",
6546 `${route.method} ${route.pathPattern} received an invalid Bearer token.`,
6547 {
6548 ...details,
6549 reason
6550 },
6551 {
6552 "WWW-Authenticate": HOST_OPERATIONS_WWW_AUTHENTICATE
6553 }
6554 );
6555 }
6556}
6557
6558function authorizeRoute(route: LocalApiRouteDefinition, context: LocalApiRequestContext): void {
6559 if (!isHostOperationsRoute(route)) {
6560 return;
6561 }
6562
6563 const sharedToken = normalizeOptionalString(context.sharedToken);
6564
6565 if (sharedToken == null) {
6566 throw new LocalApiHttpError(
6567 503,
6568 "shared_token_not_configured",
6569 `BAA_SHARED_TOKEN is not configured; ${route.method} ${route.pathPattern} is unavailable.`,
6570 {
6571 env_var: "BAA_SHARED_TOKEN",
6572 route_id: route.id
6573 }
6574 );
6575 }
6576
6577 const tokenResult = extractBearerToken(readHeaderValue(context.request, "authorization"));
6578
6579 if (!tokenResult.ok) {
6580 throw buildHostOperationsAuthError(route, tokenResult.reason);
6581 }
6582
6583 if (tokenResult.token !== sharedToken) {
6584 throw buildHostOperationsAuthError(route, "invalid_token");
6585 }
6586}
6587
6588export interface SetAutomationModeInput {
6589 mode: AutomationMode;
6590 reason?: string | null;
6591 requestedBy?: string | null;
6592 source?: string | null;
6593 updatedAt: number;
6594}
6595
6596export async function setAutomationMode(
6597 repository: ControlPlaneRepository,
6598 input: SetAutomationModeInput
6599): Promise<JsonObject> {
6600 const updatedAt = toUnixMilliseconds(input.updatedAt) ?? Date.now();
6601
6602 await repository.putSystemState({
6603 stateKey: AUTOMATION_STATE_KEY,
6604 updatedAt,
6605 valueJson: JSON.stringify({
6606 mode: input.mode,
6607 ...(input.requestedBy ? { requested_by: input.requestedBy } : {}),
6608 ...(input.reason ? { reason: input.reason } : {}),
6609 ...(input.source ? { source: input.source } : {})
6610 })
6611 });
6612
6613 return buildSystemStateData(repository);
6614}
6615
6616async function handleSystemMutation(
6617 context: LocalApiRequestContext,
6618 mode: AutomationMode
6619): Promise<ConductorHttpResponse> {
6620 const repository = requireRepository(context.repository);
6621 const body = readBodyObject(context.request, true);
6622 const requestedBy = readOptionalStringField(body, "requested_by");
6623 const reason = readOptionalStringField(body, "reason");
6624 const source = readOptionalStringField(body, "source");
6625
6626 return buildSuccessEnvelope(
6627 context.requestId,
6628 200,
6629 await setAutomationMode(repository, {
6630 mode,
6631 reason,
6632 requestedBy,
6633 source,
6634 updatedAt: context.now()
6635 })
6636 );
6637}
6638
6639async function handleHostExec(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6640 const body = readBodyObject(context.request, true);
6641 const response = normalizeExecOperationResponse(await executeCommand(buildExecOperationRequest(body)));
6642
6643 return buildSuccessEnvelope(context.requestId, 200, response as unknown as JsonValue);
6644}
6645
6646async function handleHostFileRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6647 const body = readBodyObject(context.request, true);
6648
6649 return buildSuccessEnvelope(
6650 context.requestId,
6651 200,
6652 (await readTextFile(buildFileReadOperationRequest(body))) as unknown as JsonValue
6653 );
6654}
6655
6656async function handleHostFileWrite(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6657 const body = readBodyObject(context.request, true);
6658
6659 return buildSuccessEnvelope(
6660 context.requestId,
6661 200,
6662 (await writeTextFile(buildFileWriteOperationRequest(body))) as unknown as JsonValue
6663 );
6664}
6665
6666async function handleArtifactRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6667 const artifactStore = requireArtifactStore(context.artifactStore);
6668 const scope = context.params.artifact_scope;
6669 const fileName = context.params.artifact_file;
6670
6671 if (!scope || !fileName || !isSafeArtifactPath(scope, fileName)) {
6672 throw new LocalApiHttpError(
6673 404,
6674 "not_found",
6675 `No conductor route matches "${normalizePathname(context.url.pathname)}".`
6676 );
6677 }
6678
6679 try {
6680 return binaryResponse(200, readFileSync(join(artifactStore.getArtifactsDir(), scope, fileName)), {
6681 "content-type": getArtifactContentType(fileName)
6682 });
6683 } catch (error) {
6684 if (isMissingFileError(error)) {
6685 throw new LocalApiHttpError(
6686 404,
6687 "not_found",
6688 `Artifact "${normalizePathname(context.url.pathname)}" was not found.`
6689 );
6690 }
6691
6692 throw error;
6693 }
6694}
6695
6696function buildPlainTextBinaryResponse(status: number, body: string): ConductorHttpResponse {
6697 return binaryResponse(status, Buffer.from(body, "utf8"), {
6698 "content-type": CODE_ROUTE_CONTENT_TYPE
6699 });
6700}
6701
6702function resolveUiDistDir(value: string | null | undefined): string {
6703 return resolve(normalizeOptionalString(value) ?? DEFAULT_CONDUCTOR_UI_DIST_DIR);
6704}
6705
6706function buildUiUnavailableError(uiDistDir: string): LocalApiHttpError {
6707 return new LocalApiHttpError(
6708 503,
6709 "ui_not_available",
6710 `Conductor UI build output "${uiDistDir}" is not available. Run "pnpm -C apps/conductor-ui build" first.`
6711 );
6712}
6713
6714function getUiDistRealPath(uiDistDir: string): string {
6715 try {
6716 return realpathSync(uiDistDir);
6717 } catch (error) {
6718 if (isMissingFileError(error)) {
6719 throw buildUiUnavailableError(uiDistDir);
6720 }
6721
6722 throw error;
6723 }
6724}
6725
6726function readUiEntryFile(uiDistDir: string): Uint8Array {
6727 const uiDistRealPath = getUiDistRealPath(uiDistDir);
6728
6729 try {
6730 return readFileSync(join(uiDistRealPath, CONDUCTOR_UI_ENTRY_FILE));
6731 } catch (error) {
6732 if (isMissingFileError(error)) {
6733 throw buildUiUnavailableError(uiDistDir);
6734 }
6735
6736 throw error;
6737 }
6738}
6739
6740function buildUiAssetNotFoundError(pathname: string): LocalApiHttpError {
6741 return new LocalApiHttpError(404, "not_found", `UI asset "${normalizePathname(pathname)}" was not found.`);
6742}
6743
6744function assertAllowedUiAssetPath(assetPath: string, pathname: string): void {
6745 if (assetPath.includes("\u0000") || assetPath.includes("\\") || assetPath.startsWith("/")) {
6746 throw buildUiAssetNotFoundError(pathname);
6747 }
6748
6749 const segments = assetPath.split("/");
6750
6751 if (segments.some((segment) => segment === "" || segment === "..")) {
6752 throw buildUiAssetNotFoundError(pathname);
6753 }
6754}
6755
6756function resolveUiAssetFilePath(uiDistDir: string, assetPath: string, pathname: string): string {
6757 if (assetPath.trim() === "") {
6758 throw buildUiAssetNotFoundError(pathname);
6759 }
6760
6761 assertAllowedUiAssetPath(assetPath, pathname);
6762 const uiDistRealPath = getUiDistRealPath(uiDistDir);
6763 const assetsRoot = resolve(uiDistRealPath, "assets");
6764
6765 try {
6766 const resolvedPath = realpathSync(resolve(assetsRoot, assetPath));
6767
6768 if (isPathOutsideRoot(uiDistRealPath, resolvedPath)) {
6769 throw buildUiAssetNotFoundError(pathname);
6770 }
6771
6772 return resolvedPath;
6773 } catch (error) {
6774 if (error instanceof LocalApiHttpError) {
6775 throw error;
6776 }
6777
6778 if (isMissingFileError(error)) {
6779 throw buildUiAssetNotFoundError(pathname);
6780 }
6781
6782 throw error;
6783 }
6784}
6785
6786function getUiAssetContentType(filePath: string): string {
6787 return CONDUCTOR_UI_ASSET_CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
6788}
6789
6790async function handleConductorUiEntryRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6791 return binaryResponse(200, readUiEntryFile(context.uiDistDir), {
6792 ...CONDUCTOR_UI_HTML_HEADERS
6793 });
6794}
6795
6796async function handleConductorUiAssetRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6797 const assetPath = context.params.app_asset_path ?? "";
6798 const resolvedPath = resolveUiAssetFilePath(context.uiDistDir, assetPath, context.url.pathname);
6799
6800 return binaryResponse(200, readFileSync(resolvedPath), {
6801 "cache-control": CONDUCTOR_UI_ASSET_CACHE_CONTROL,
6802 "content-type": getUiAssetContentType(resolvedPath)
6803 });
6804}
6805
6806async function handleConductorUiHistoryRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6807 return handleConductorUiEntryRead(context);
6808}
6809
6810function resolveCodeRootDir(value: string | null | undefined): string {
6811 return resolve(normalizeOptionalString(value) ?? DEFAULT_CODE_ROOT_DIR);
6812}
6813
6814function normalizeCodeRelativePath(value: string): string {
6815 return value.split(/[\\/]+/u).filter(Boolean).join("/");
6816}
6817
6818function isBlockedCodePath(relativePath: string): boolean {
6819 const normalized = normalizeCodeRelativePath(relativePath);
6820 const segments = normalized === "" ? [] : normalized.split("/");
6821
6822 if (segments.some((segment) => BLOCKED_CODE_FILE_NAMES.has(segment) || BLOCKED_CODE_PATH_SEGMENTS.has(segment))) {
6823 return true;
6824 }
6825
6826 return false;
6827}
6828
6829function isBinaryCodePath(relativePath: string): boolean {
6830 const normalized = normalizeCodeRelativePath(relativePath);
6831 return normalized !== "" && BLOCKED_CODE_BINARY_EXTENSIONS.has(extname(normalized).toLowerCase());
6832}
6833
6834function isPathOutsideRoot(rootPath: string, targetPath: string): boolean {
6835 const relativePath = relative(rootPath, targetPath);
6836
6837 if (relativePath === "") {
6838 return false;
6839 }
6840
6841 const [firstSegment = ""] = relativePath.split(/[\\/]+/u).filter(Boolean);
6842 return firstSegment === "..";
6843}
6844
6845async function handleUiSessionMe(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6846 const session = context.uiSessionManager.touch(readHeaderValue(context.request, "cookie"));
6847
6848 if (!session && hasUiSessionCookie(context.request)) {
6849 return buildUiSessionSuccessResponse(context, null, {
6850 "set-cookie": buildUiSessionClearCookieHeader(context)
6851 });
6852 }
6853
6854 if (!session) {
6855 return buildUiSessionSuccessResponse(context, null);
6856 }
6857
6858 return buildUiSessionSuccessResponse(context, session, {
6859 "set-cookie": buildUiSessionCookieHeader(context, session)
6860 });
6861}
6862
6863async function handleUiSessionLogin(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6864 if (!context.uiSessionManager.hasConfiguredRoles()) {
6865 throw new LocalApiHttpError(
6866 503,
6867 "ui_session_not_configured",
6868 "No UI session role is configured on this conductor instance."
6869 );
6870 }
6871
6872 const body = readBodyObject(context.request);
6873 const role = readOptionalStringBodyField(body, "role");
6874 const password = readOptionalStringBodyField(body, "password");
6875
6876 if (!role || !isBrowserSessionRole(role)) {
6877 throw new LocalApiHttpError(400, "invalid_request", 'Field "role" must be "browser_admin" or "readonly".', {
6878 field: "role"
6879 });
6880 }
6881
6882 if (!password) {
6883 throw new LocalApiHttpError(400, "invalid_request", 'Field "password" is required.', {
6884 field: "password"
6885 });
6886 }
6887
6888 const result = context.uiSessionManager.login(role, password);
6889
6890 if (!result.ok) {
6891 if (result.reason === "role_not_enabled") {
6892 throw new LocalApiHttpError(
6893 403,
6894 "forbidden",
6895 `UI session role "${role}" is not enabled on this conductor instance.`
6896 );
6897 }
6898
6899 throw new LocalApiHttpError(401, "unauthorized", "Invalid UI session credentials.");
6900 }
6901
6902 return buildUiSessionSuccessResponse(context, result.session, {
6903 "set-cookie": buildUiSessionCookieHeader(context, result.session)
6904 });
6905}
6906
6907async function handleUiSessionLogout(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
6908 context.uiSessionManager.logout(readHeaderValue(context.request, "cookie"));
6909
6910 return buildUiSessionSuccessResponse(context, null, {
6911 "set-cookie": buildUiSessionClearCookieHeader(context)
6912 });
6913}
6914function buildCodeAccessForbiddenError(): LocalApiHttpError {
6915 return new LocalApiHttpError(403, "forbidden", "Requested code path is not allowed.");
6916}
6917
6918function assertAllowedRequestedCodePath(relativePath: string): void {
6919 if (relativePath.includes("\u0000") || relativePath.includes("\\") || relativePath.startsWith("/")) {
6920 throw buildCodeAccessForbiddenError();
6921 }
6922
6923 const segments = relativePath === "" ? [] : relativePath.split("/");
6924
6925 if (segments.some((segment) => segment === "" || segment === "..")) {
6926 throw buildCodeAccessForbiddenError();
6927 }
6928
6929 if (isBlockedCodePath(relativePath)) {
6930 throw buildCodeAccessForbiddenError();
6931 }
6932}
6933
6934function getCodeRootRealPath(codeRootDir: string): string {
6935 try {
6936 return realpathSync(codeRootDir);
6937 } catch (error) {
6938 if (isMissingFileError(error)) {
6939 throw new LocalApiHttpError(
6940 500,
6941 "code_root_not_found",
6942 `Configured code root "${codeRootDir}" does not exist.`
6943 );
6944 }
6945
6946 throw error;
6947 }
6948}
6949
6950function resolveCodeAccessTarget(codeRootDir: string, requestedPath: string): {
6951 codeRootRealPath: string;
6952 resolvedPath: string;
6953 resolvedRelativePath: string;
6954 stats: ReturnType<typeof statSync>;
6955} {
6956 assertAllowedRequestedCodePath(requestedPath);
6957 const codeRootRealPath = getCodeRootRealPath(codeRootDir);
6958 const resolvedPath = realpathSync(resolve(codeRootRealPath, requestedPath === "" ? "." : requestedPath));
6959
6960 if (isPathOutsideRoot(codeRootRealPath, resolvedPath)) {
6961 throw buildCodeAccessForbiddenError();
6962 }
6963
6964 const resolvedRelativePath = normalizeCodeRelativePath(relative(codeRootRealPath, resolvedPath));
6965
6966 if (isBlockedCodePath(resolvedRelativePath)) {
6967 throw buildCodeAccessForbiddenError();
6968 }
6969
6970 return {
6971 codeRootRealPath,
6972 resolvedPath,
6973 resolvedRelativePath,
6974 stats: statSync(resolvedPath)
6975 };
6976}
6977
6978function isVisibleCodeDirectoryEntry(
6979 codeRootRealPath: string,
6980 directoryPath: string,
6981 entryName: string
6982): boolean {
6983 const visibleRelativePath = normalizeCodeRelativePath(relative(codeRootRealPath, resolve(directoryPath, entryName)));
6984
6985 if (isBlockedCodePath(visibleRelativePath)) {
6986 return false;
6987 }
6988
6989 try {
6990 const resolvedPath = realpathSync(resolve(directoryPath, entryName));
6991
6992 if (isPathOutsideRoot(codeRootRealPath, resolvedPath)) {
6993 return false;
6994 }
6995
6996 const resolvedRelativePath = normalizeCodeRelativePath(relative(codeRootRealPath, resolvedPath));
6997 const stats = statSync(resolvedPath);
6998
6999 if (isBlockedCodePath(resolvedRelativePath)) {
7000 return false;
7001 }
7002
7003 if (!stats.isDirectory() && isBinaryCodePath(resolvedRelativePath)) {
7004 return false;
7005 }
7006
7007 return true;
7008 } catch (error) {
7009 if (isMissingFileError(error)) {
7010 return false;
7011 }
7012
7013 throw error;
7014 }
7015}
7016
7017function renderCodeDirectoryListing(codeRootRealPath: string, directoryPath: string): string {
7018 return readdirSync(directoryPath)
7019 .filter((entryName) => isVisibleCodeDirectoryEntry(codeRootRealPath, directoryPath, entryName))
7020 .sort((left, right) => left.localeCompare(right))
7021 .join("\n");
7022}
7023
7024async function handleCodeRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7025 const requestedPath = context.params.code_path ?? "";
7026
7027 try {
7028 const { codeRootRealPath, resolvedPath, resolvedRelativePath, stats } = resolveCodeAccessTarget(
7029 context.codeRootDir,
7030 requestedPath
7031 );
7032
7033 if (stats.isDirectory()) {
7034 return buildPlainTextBinaryResponse(200, renderCodeDirectoryListing(codeRootRealPath, resolvedPath));
7035 }
7036
7037 if (isBinaryCodePath(resolvedRelativePath)) {
7038 throw buildCodeAccessForbiddenError();
7039 }
7040
7041 return buildPlainTextBinaryResponse(200, readFileSync(resolvedPath, "utf8"));
7042 } catch (error) {
7043 if (error instanceof LocalApiHttpError) {
7044 throw error;
7045 }
7046
7047 if (isMissingFileError(error)) {
7048 throw new LocalApiHttpError(
7049 404,
7050 "not_found",
7051 `Code path "${normalizePathname(context.url.pathname)}" was not found.`
7052 );
7053 }
7054
7055 throw error;
7056 }
7057}
7058
7059async function handleRobotsRead(): Promise<ConductorHttpResponse> {
7060 return textResponse(200, ROBOTS_TXT_BODY);
7061}
7062
7063async function handleCodexStatusRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7064 const result = await requestCodexd(context, {
7065 method: "GET",
7066 path: "/v1/codexd/status"
7067 });
7068
7069 return buildSuccessEnvelope(
7070 context.requestId,
7071 200,
7072 buildCodexStatusData(result.data, normalizeOptionalString(context.codexdLocalApiBase) ?? getSnapshotCodexdLocalApiBase(context.snapshotLoader()) ?? "")
7073 );
7074}
7075
7076async function handleCodexSessionsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7077 const result = await requestCodexd(context, {
7078 method: "GET",
7079 path: "/v1/codexd/sessions"
7080 });
7081
7082 return buildSuccessEnvelope(context.requestId, result.status, result.data);
7083}
7084
7085async function handleCodexSessionRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7086 const sessionId = context.params.session_id;
7087
7088 if (!sessionId) {
7089 throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"session_id\" is required.");
7090 }
7091
7092 const result = await requestCodexd(context, {
7093 method: "GET",
7094 path: `/v1/codexd/sessions/${encodeURIComponent(sessionId)}`
7095 });
7096
7097 return buildSuccessEnvelope(context.requestId, result.status, result.data);
7098}
7099
7100async function handleCodexSessionCreate(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7101 const result = await requestCodexd(context, {
7102 body: buildCodexSessionCreateRequest(readBodyObject(context.request, true)),
7103 method: "POST",
7104 path: "/v1/codexd/sessions"
7105 });
7106
7107 return buildSuccessEnvelope(context.requestId, result.status, result.data);
7108}
7109
7110async function handleCodexTurnCreate(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7111 const result = await requestCodexd(context, {
7112 body: buildCodexTurnCreateRequest(readBodyObject(context.request, false)),
7113 method: "POST",
7114 path: "/v1/codexd/turn"
7115 });
7116
7117 return buildSuccessEnvelope(context.requestId, result.status, result.data);
7118}
7119
7120async function handleClaudeCodedStatusRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7121 const result = await requestClaudeCoded(context, {
7122 method: "GET",
7123 path: "/v1/claude-coded/status"
7124 });
7125
7126 return buildSuccessEnvelope(context.requestId, 200, result.data);
7127}
7128
7129async function handleClaudeCodedAsk(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7130 const result = await requestClaudeCoded(context, {
7131 body: readBodyObject(context.request, true) as JsonObject,
7132 method: "POST",
7133 path: "/v1/claude-coded/ask"
7134 });
7135
7136 return buildSuccessEnvelope(context.requestId, result.status, result.data);
7137}
7138
7139async function requestClaudeCoded(
7140 context: LocalApiRequestContext,
7141 input: {
7142 body?: JsonObject;
7143 method: LocalApiRouteMethod;
7144 path: string;
7145 }
7146): Promise<{ data: JsonValue; status: number }> {
7147 const claudeCodedLocalApiBase =
7148 normalizeOptionalString(context.claudeCodedLocalApiBase) ??
7149 getSnapshotClaudeCodedLocalApiBase(context.snapshotLoader());
7150
7151 if (claudeCodedLocalApiBase == null) {
7152 throw new LocalApiHttpError(
7153 503,
7154 "claude_coded_not_configured",
7155 "Independent claude-coded local API is not configured for /v1/claude-coded routes.",
7156 {
7157 env_var: CLAUDE_CODED_LOCAL_API_ENV
7158 }
7159 );
7160 }
7161
7162 let response: Response;
7163
7164 try {
7165 response = await context.fetchImpl(`${claudeCodedLocalApiBase}${input.path}`, {
7166 method: input.method,
7167 headers: input.body
7168 ? {
7169 accept: "application/json",
7170 "content-type": "application/json"
7171 }
7172 : {
7173 accept: "application/json"
7174 },
7175 body: input.body ? JSON.stringify(input.body) : undefined,
7176 signal: context.request.signal
7177 });
7178 } catch (error) {
7179 throw new LocalApiHttpError(
7180 503,
7181 "claude_coded_unavailable",
7182 `Independent claude-coded is unreachable at ${claudeCodedLocalApiBase}: ${error instanceof Error ? error.message : String(error)}.`,
7183 {
7184 target_base_url: claudeCodedLocalApiBase,
7185 upstream_path: input.path
7186 }
7187 );
7188 }
7189
7190 let data: JsonValue;
7191
7192 try {
7193 data = (await response.json()) as JsonValue;
7194 } catch {
7195 if (response.ok) {
7196 return { data: null, status: response.status };
7197 }
7198
7199 throw new LocalApiHttpError(
7200 response.status,
7201 response.status >= 500 ? "claude_coded_unavailable" : "claude_coded_proxy_error",
7202 `Independent claude-coded returned HTTP ${response.status} for ${input.method} ${input.path}.`,
7203 {
7204 target_base_url: claudeCodedLocalApiBase,
7205 upstream_path: input.path
7206 }
7207 );
7208 }
7209
7210 if (!response.ok) {
7211 throw new LocalApiHttpError(
7212 response.status,
7213 response.status >= 500 ? "claude_coded_unavailable" : "claude_coded_proxy_error",
7214 `Independent claude-coded returned HTTP ${response.status} for ${input.method} ${input.path}.`,
7215 {
7216 target_base_url: claudeCodedLocalApiBase,
7217 upstream_path: input.path
7218 }
7219 );
7220 }
7221
7222 return { data, status: response.status };
7223}
7224
7225async function handleControllersList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7226 const repository = requireRepository(context.repository);
7227 const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
7228 const [lease, controllers] = await Promise.all([
7229 repository.getCurrentLease(),
7230 repository.listControllers({
7231 limit
7232 })
7233 ]);
7234
7235 return buildSuccessEnvelope(context.requestId, 200, {
7236 active_controller_id: lease?.holderId ?? null,
7237 count: controllers.length,
7238 limit,
7239 controllers: controllers.map((controller) => summarizeController(controller, lease?.holderId ?? null))
7240 });
7241}
7242
7243async function handleTasksList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7244 const repository = requireRepository(context.repository);
7245 const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
7246 const status = readTaskStatusFilter(context.url);
7247 const tasks = await repository.listTasks({
7248 limit,
7249 status
7250 });
7251
7252 return buildSuccessEnvelope(context.requestId, 200, {
7253 count: tasks.length,
7254 filters: {
7255 limit,
7256 status: status ?? null
7257 },
7258 tasks: tasks.map(summarizeTask)
7259 });
7260}
7261
7262async function handleTaskRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7263 const repository = requireRepository(context.repository);
7264 const taskId = context.params.task_id;
7265
7266 if (!taskId) {
7267 throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"task_id\" is required.");
7268 }
7269
7270 const task = await repository.getTask(taskId);
7271
7272 if (task == null) {
7273 throw new LocalApiHttpError(404, "not_found", `Task "${taskId}" was not found.`, {
7274 resource: "task",
7275 resource_id: taskId
7276 });
7277 }
7278
7279 return buildSuccessEnvelope(context.requestId, 200, summarizeTask(task));
7280}
7281
7282async function handleTaskLogsRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7283 const repository = requireRepository(context.repository);
7284 const taskId = context.params.task_id;
7285
7286 if (!taskId) {
7287 throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"task_id\" is required.");
7288 }
7289
7290 const task = await repository.getTask(taskId);
7291
7292 if (task == null) {
7293 throw new LocalApiHttpError(404, "not_found", `Task "${taskId}" was not found.`, {
7294 resource: "task",
7295 resource_id: taskId
7296 });
7297 }
7298
7299 const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT);
7300 const runId = context.url.searchParams.get("run_id")?.trim() || undefined;
7301 const logs = await repository.listTaskLogs(taskId, {
7302 limit,
7303 runId
7304 });
7305 const uniqueRunIds = [...new Set(logs.map((entry) => entry.runId))];
7306
7307 return buildSuccessEnvelope(context.requestId, 200, {
7308 task_id: taskId,
7309 run_id: uniqueRunIds.length === 1 ? (uniqueRunIds[0] ?? null) : runId ?? null,
7310 count: logs.length,
7311 limit,
7312 entries: logs.map(summarizeTaskLog)
7313 });
7314}
7315
7316async function handleRunsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7317 const repository = requireRepository(context.repository);
7318 const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
7319 const runs = await repository.listRuns({
7320 limit
7321 });
7322
7323 return buildSuccessEnvelope(context.requestId, 200, {
7324 count: runs.length,
7325 limit,
7326 runs: runs.map(summarizeRun)
7327 });
7328}
7329
7330async function handleRunRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7331 const repository = requireRepository(context.repository);
7332 const runId = context.params.run_id;
7333
7334 if (!runId) {
7335 throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"run_id\" is required.");
7336 }
7337
7338 const run = await repository.getRun(runId);
7339
7340 if (run == null) {
7341 throw new LocalApiHttpError(404, "not_found", `Run "${runId}" was not found.`, {
7342 resource: "run",
7343 resource_id: runId
7344 });
7345 }
7346
7347 return buildSuccessEnvelope(context.requestId, 200, summarizeRun(run));
7348}
7349
7350const ARTIFACT_LIST_MAX_LIMIT = 200;
7351const ARTIFACT_DEFAULT_MESSAGE_LIMIT = 50;
7352const ARTIFACT_DEFAULT_EXECUTION_LIMIT = 50;
7353const ARTIFACT_DEFAULT_SESSION_LIMIT = 20;
7354const ARTIFACT_LATEST_SESSION_LIMIT = 10;
7355
7356async function handleArtifactMessagesList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7357 const store = requireArtifactStore(context.artifactStore);
7358 const platform = readOptionalQueryString(context.url, "platform");
7359 const conversationId = readOptionalQueryString(context.url, "conversation_id");
7360 const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_MESSAGE_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
7361 const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
7362 const publicBaseUrl = store.getPublicBaseUrl();
7363
7364 const messages = await store.listMessages({ platform, conversationId, limit, offset });
7365
7366 return buildSuccessEnvelope(context.requestId, 200, {
7367 count: messages.length,
7368 filters: { platform: platform ?? null, conversation_id: conversationId ?? null, limit, offset },
7369 messages: messages.map((m) => ({
7370 id: m.id,
7371 platform: m.platform,
7372 conversation_id: m.conversationId,
7373 role: m.role,
7374 summary: m.summary,
7375 observed_at: m.observedAt,
7376 artifact_url: buildArtifactPublicUrl(publicBaseUrl, m.staticPath)
7377 }))
7378 });
7379}
7380
7381async function handleArtifactMessageRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7382 const store = requireArtifactStore(context.artifactStore);
7383 const messageId = context.params.message_id;
7384
7385 if (!messageId) {
7386 throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"message_id\" is required.");
7387 }
7388
7389 const message = await store.getMessage(messageId);
7390
7391 if (message == null) {
7392 throw new LocalApiHttpError(404, "not_found", `Message "${messageId}" was not found.`, {
7393 resource: "message",
7394 resource_id: messageId
7395 });
7396 }
7397
7398 const publicBaseUrl = store.getPublicBaseUrl();
7399
7400 return buildSuccessEnvelope(context.requestId, 200, {
7401 id: message.id,
7402 platform: message.platform,
7403 conversation_id: message.conversationId,
7404 role: message.role,
7405 raw_text: message.rawText,
7406 summary: message.summary,
7407 observed_at: message.observedAt,
7408 page_url: message.pageUrl,
7409 page_title: message.pageTitle,
7410 organization_id: message.organizationId,
7411 created_at: message.createdAt,
7412 artifact_url: buildArtifactPublicUrl(publicBaseUrl, message.staticPath)
7413 });
7414}
7415
7416async function handleArtifactExecutionsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7417 const store = requireArtifactStore(context.artifactStore);
7418 const messageId = readOptionalQueryString(context.url, "message_id");
7419 const target = readOptionalQueryString(context.url, "target");
7420 const tool = readOptionalQueryString(context.url, "tool");
7421 const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_EXECUTION_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
7422 const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
7423 const publicBaseUrl = store.getPublicBaseUrl();
7424
7425 const executions = await store.listExecutions({ messageId, target, tool, limit, offset });
7426
7427 return buildSuccessEnvelope(context.requestId, 200, {
7428 count: executions.length,
7429 filters: { message_id: messageId ?? null, target: target ?? null, tool: tool ?? null, limit, offset },
7430 executions: executions.map((e) => ({
7431 instruction_id: e.instructionId,
7432 message_id: e.messageId,
7433 target: e.target,
7434 tool: e.tool,
7435 result_ok: e.resultOk,
7436 result_summary: e.resultSummary,
7437 executed_at: e.executedAt,
7438 artifact_url: buildArtifactPublicUrl(publicBaseUrl, e.staticPath)
7439 }))
7440 });
7441}
7442
7443async function handleArtifactExecutionRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7444 const store = requireArtifactStore(context.artifactStore);
7445 const instructionId = context.params.instruction_id;
7446
7447 if (!instructionId) {
7448 throw new LocalApiHttpError(400, "invalid_request", "Route parameter \"instruction_id\" is required.");
7449 }
7450
7451 const execution = await store.getExecution(instructionId);
7452
7453 if (execution == null) {
7454 throw new LocalApiHttpError(404, "not_found", `Execution "${instructionId}" was not found.`, {
7455 resource: "execution",
7456 resource_id: instructionId
7457 });
7458 }
7459
7460 const publicBaseUrl = store.getPublicBaseUrl();
7461
7462 return buildSuccessEnvelope(context.requestId, 200, {
7463 instruction_id: execution.instructionId,
7464 message_id: execution.messageId,
7465 target: execution.target,
7466 tool: execution.tool,
7467 params: tryParseJson(execution.params),
7468 params_kind: execution.paramsKind,
7469 result_ok: execution.resultOk,
7470 result_data: tryParseJson(execution.resultData),
7471 result_summary: execution.resultSummary,
7472 result_error: execution.resultError,
7473 http_status: execution.httpStatus,
7474 executed_at: execution.executedAt,
7475 created_at: execution.createdAt,
7476 artifact_url: buildArtifactPublicUrl(publicBaseUrl, execution.staticPath)
7477 });
7478}
7479
7480async function handleArtifactSessionsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7481 const store = requireArtifactStore(context.artifactStore);
7482 const platform = readOptionalQueryString(context.url, "platform");
7483 const conversationId = readOptionalQueryString(context.url, "conversation_id");
7484 const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_SESSION_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
7485 const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
7486 const publicBaseUrl = store.getPublicBaseUrl();
7487
7488 const sessions = await store.listSessions({ platform, conversationId, limit, offset });
7489
7490 return buildSuccessEnvelope(context.requestId, 200, {
7491 count: sessions.length,
7492 filters: { platform: platform ?? null, conversation_id: conversationId ?? null, limit, offset },
7493 sessions: sessions.map((s) => ({
7494 id: s.id,
7495 platform: s.platform,
7496 conversation_id: s.conversationId,
7497 started_at: s.startedAt,
7498 last_activity_at: s.lastActivityAt,
7499 message_count: s.messageCount,
7500 execution_count: s.executionCount,
7501 summary: s.summary,
7502 artifact_url: buildArtifactPublicUrl(publicBaseUrl, s.staticPath)
7503 }))
7504 });
7505}
7506
7507async function handleArtifactSessionsLatest(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7508 const store = requireArtifactStore(context.artifactStore);
7509 const publicBaseUrl = store.getPublicBaseUrl();
7510
7511 const sessions = await store.getLatestSessions(ARTIFACT_LATEST_SESSION_LIMIT);
7512
7513 return buildSuccessEnvelope(context.requestId, 200, {
7514 count: sessions.length,
7515 sessions: sessions.map((s) => ({
7516 id: s.id,
7517 platform: s.platform,
7518 conversation_id: s.conversationId,
7519 started_at: s.startedAt,
7520 last_activity_at: s.lastActivityAt,
7521 message_count: s.messageCount,
7522 execution_count: s.executionCount,
7523 summary: s.summary,
7524 artifact_url: buildArtifactPublicUrl(publicBaseUrl, s.staticPath)
7525 }))
7526 });
7527}
7528
7529function buildRenewalConversationLinkData(link: {
7530 clientId: string | null;
7531 createdAt: number;
7532 isActive: boolean;
7533 linkId: string;
7534 localConversationId: string;
7535 observedAt: number;
7536 pageTitle: string | null;
7537 pageUrl: string | null;
7538 platform: string;
7539 remoteConversationId: string | null;
7540 routeParams: string | null;
7541 routePath: string | null;
7542 routePattern: string | null;
7543 targetId: string | null;
7544 targetKind: string | null;
7545 targetPayload: string | null;
7546 updatedAt: number;
7547}): JsonObject {
7548 const route =
7549 link.routePath == null && link.routePattern == null && link.routeParams == null
7550 ? null
7551 : compactJsonObject({
7552 path: link.routePath ?? undefined,
7553 pattern: link.routePattern ?? undefined,
7554 params: tryParseJson(link.routeParams) ?? undefined
7555 });
7556 const target =
7557 link.targetKind == null && link.targetId == null && link.targetPayload == null
7558 ? null
7559 : compactJsonObject({
7560 kind: link.targetKind ?? undefined,
7561 id: link.targetId ?? undefined,
7562 payload: tryParseJson(link.targetPayload) ?? undefined
7563 });
7564
7565 return compactJsonObject({
7566 link_id: link.linkId,
7567 local_conversation_id: link.localConversationId,
7568 platform: link.platform,
7569 remote_conversation_id: link.remoteConversationId ?? undefined,
7570 client_id: link.clientId ?? undefined,
7571 page_url: link.pageUrl ?? undefined,
7572 page_title: link.pageTitle ?? undefined,
7573 route,
7574 target,
7575 is_active: link.isActive,
7576 observed_at: link.observedAt,
7577 created_at: link.createdAt,
7578 updated_at: link.updatedAt
7579 });
7580}
7581
7582function buildRenewalConversationData(
7583 detail: NonNullable<Awaited<ReturnType<typeof getRenewalConversationDetail>>>,
7584 includeLinks: boolean
7585): JsonObject {
7586 return compactJsonObject({
7587 local_conversation_id: detail.conversation.localConversationId,
7588 platform: detail.conversation.platform,
7589 automation_status: detail.conversation.automationStatus,
7590 last_non_paused_automation_status: detail.conversation.lastNonPausedAutomationStatus,
7591 pause_reason: detail.conversation.pauseReason ?? undefined,
7592 last_error: detail.conversation.lastError ?? undefined,
7593 execution_state: detail.conversation.executionState,
7594 consecutive_failure_count: detail.conversation.consecutiveFailureCount,
7595 repeated_message_count: detail.conversation.repeatedMessageCount,
7596 repeated_renewal_count: detail.conversation.repeatedRenewalCount,
7597 title: detail.conversation.title ?? undefined,
7598 summary: detail.conversation.summary ?? undefined,
7599 last_message_id: detail.conversation.lastMessageId ?? undefined,
7600 last_message_at: detail.conversation.lastMessageAt ?? undefined,
7601 cooldown_until: detail.conversation.cooldownUntil ?? undefined,
7602 paused_at: detail.conversation.pausedAt ?? undefined,
7603 created_at: detail.conversation.createdAt,
7604 updated_at: detail.conversation.updatedAt,
7605 active_link: detail.activeLink == null ? null : buildRenewalConversationLinkData(detail.activeLink),
7606 links: includeLinks ? detail.links.map((entry) => buildRenewalConversationLinkData(entry)) : undefined
7607 });
7608}
7609
7610function buildRenewalJobData(job: RenewalJobRecord): JsonObject {
7611 const parsedPayload = job.payloadKind === "json" ? tryParseJson(job.payload) : null;
7612 const parsedTargetSnapshot = tryParseJson(job.targetSnapshot);
7613 const payloadText =
7614 typeof parsedPayload === "object"
7615 && parsedPayload != null
7616 && "text" in parsedPayload
7617 && typeof parsedPayload.text === "string"
7618 ? parsedPayload.text
7619 : (job.payloadKind === "text" ? job.payload : undefined);
7620
7621 return compactJsonObject({
7622 job_id: job.jobId,
7623 local_conversation_id: job.localConversationId,
7624 message_id: job.messageId,
7625 status: job.status,
7626 payload_kind: job.payloadKind,
7627 payload: parsedPayload ?? job.payload,
7628 payload_text: payloadText ?? undefined,
7629 target_snapshot: parsedTargetSnapshot ?? job.targetSnapshot ?? undefined,
7630 attempt_count: job.attemptCount,
7631 max_attempts: job.maxAttempts,
7632 next_attempt_at: job.nextAttemptAt ?? undefined,
7633 last_attempt_at: job.lastAttemptAt ?? undefined,
7634 last_error: job.lastError ?? undefined,
7635 log_path: job.logPath ?? undefined,
7636 started_at: job.startedAt ?? undefined,
7637 finished_at: job.finishedAt ?? undefined,
7638 created_at: job.createdAt,
7639 updated_at: job.updatedAt
7640 });
7641}
7642
7643async function handleRenewalConversationsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7644 const store = requireArtifactStore(context.artifactStore);
7645 const platform = readOptionalQueryString(context.url, "platform");
7646 const automationStatus = readOptionalRenewalAutomationStatusQuery(
7647 context.url,
7648 "automation_status",
7649 "status"
7650 );
7651 const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_SESSION_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
7652 const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
7653 const conversations = await listRenewalConversationDetails(store, {
7654 automationStatus,
7655 limit,
7656 offset,
7657 platform
7658 });
7659
7660 return buildSuccessEnvelope(context.requestId, 200, {
7661 count: conversations.length,
7662 filters: compactJsonObject({
7663 automation_status: automationStatus ?? undefined,
7664 limit,
7665 offset,
7666 platform: platform ?? undefined
7667 }),
7668 conversations: conversations.map((entry) => buildRenewalConversationData(entry, false))
7669 });
7670}
7671
7672async function handleRenewalConversationRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7673 const store = requireArtifactStore(context.artifactStore);
7674 const localConversationId = context.params.local_conversation_id;
7675
7676 if (!localConversationId) {
7677 throw new LocalApiHttpError(
7678 400,
7679 "invalid_request",
7680 'Route parameter "local_conversation_id" is required.'
7681 );
7682 }
7683
7684 const detail = await getRenewalConversationDetail(store, localConversationId);
7685
7686 if (detail == null) {
7687 throw new LocalApiHttpError(
7688 404,
7689 "not_found",
7690 `Local conversation "${localConversationId}" was not found.`,
7691 {
7692 resource: "local_conversation",
7693 resource_id: localConversationId
7694 }
7695 );
7696 }
7697
7698 return buildSuccessEnvelope(context.requestId, 200, buildRenewalConversationData(detail, true));
7699}
7700
7701async function handleRenewalLinksList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7702 const store = requireArtifactStore(context.artifactStore);
7703 const clientId = readOptionalQueryString(context.url, "client_id", "clientId");
7704 const localConversationId = readOptionalQueryString(
7705 context.url,
7706 "local_conversation_id",
7707 "localConversationId"
7708 );
7709 const platform = readOptionalQueryString(context.url, "platform");
7710 const remoteConversationId = readOptionalQueryString(
7711 context.url,
7712 "remote_conversation_id",
7713 "conversation_id",
7714 "conversationId"
7715 );
7716 const isActive = readOptionalBooleanQuery(context.url, "is_active", "active");
7717 const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_SESSION_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
7718 const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
7719 const links = await store.listConversationLinks({
7720 clientId,
7721 isActive,
7722 limit,
7723 localConversationId,
7724 offset,
7725 platform,
7726 remoteConversationId
7727 });
7728
7729 return buildSuccessEnvelope(context.requestId, 200, {
7730 count: links.length,
7731 filters: compactJsonObject({
7732 active: isActive,
7733 client_id: clientId ?? undefined,
7734 limit,
7735 local_conversation_id: localConversationId ?? undefined,
7736 offset,
7737 platform: platform ?? undefined,
7738 remote_conversation_id: remoteConversationId ?? undefined
7739 }),
7740 links: links.map((entry) => buildRenewalConversationLinkData(entry))
7741 });
7742}
7743
7744async function handleRenewalJobsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7745 const store = requireArtifactStore(context.artifactStore);
7746 const localConversationId = readOptionalQueryString(
7747 context.url,
7748 "local_conversation_id",
7749 "localConversationId"
7750 );
7751 const messageId = readOptionalQueryString(context.url, "message_id", "messageId");
7752 const status = readOptionalRenewalJobStatusQuery(context.url, "status");
7753 const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_SESSION_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
7754 const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
7755 const jobs = await store.listRenewalJobs({
7756 limit,
7757 localConversationId,
7758 messageId,
7759 offset,
7760 status
7761 });
7762
7763 return buildSuccessEnvelope(context.requestId, 200, {
7764 count: jobs.length,
7765 filters: compactJsonObject({
7766 limit,
7767 local_conversation_id: localConversationId ?? undefined,
7768 message_id: messageId ?? undefined,
7769 offset,
7770 status: status ?? undefined
7771 }),
7772 jobs: jobs.map((entry) => buildRenewalJobData(entry))
7773 });
7774}
7775
7776async function handleRenewalJobRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
7777 const store = requireArtifactStore(context.artifactStore);
7778 const jobId = context.params.job_id;
7779
7780 if (!jobId) {
7781 throw new LocalApiHttpError(
7782 400,
7783 "invalid_request",
7784 'Route parameter "job_id" is required.'
7785 );
7786 }
7787
7788 const job = await store.getRenewalJob(jobId);
7789
7790 if (job == null) {
7791 throw new LocalApiHttpError(
7792 404,
7793 "not_found",
7794 `Renewal job "${jobId}" was not found.`,
7795 {
7796 resource: "renewal_job",
7797 resource_id: jobId
7798 }
7799 );
7800 }
7801
7802 return buildSuccessEnvelope(context.requestId, 200, buildRenewalJobData(job));
7803}
7804
7805async function handleRenewalConversationMutation(
7806 context: LocalApiRequestContext,
7807 automationStatus: ConversationAutomationStatus
7808): Promise<ConductorHttpResponse> {
7809 const store = requireArtifactStore(context.artifactStore);
7810 const localConversationId = context.params.local_conversation_id;
7811
7812 if (!localConversationId) {
7813 throw new LocalApiHttpError(
7814 400,
7815 "invalid_request",
7816 'Route parameter "local_conversation_id" is required.'
7817 );
7818 }
7819
7820 try {
7821 const body = readBodyObject(context.request, true);
7822 const detail = await setRenewalConversationAutomationStatus({
7823 automationStatus,
7824 localConversationId,
7825 pauseReason:
7826 automationStatus === "paused"
7827 ? normalizeRenewalPauseReason(
7828 readOptionalStringBodyField(body, "pause_reason", "reason")
7829 ) ?? "user_pause"
7830 : null,
7831 observedAt: context.now() * 1000,
7832 store
7833 });
7834
7835 return buildSuccessEnvelope(context.requestId, 200, buildRenewalConversationData(detail, true));
7836 } catch (error) {
7837 if (error instanceof RenewalConversationNotFoundError) {
7838 throw new LocalApiHttpError(
7839 404,
7840 "not_found",
7841 error.message,
7842 {
7843 resource: "local_conversation",
7844 resource_id: error.localConversationId
7845 }
7846 );
7847 }
7848
7849 throw error;
7850 }
7851}
7852
7853async function handleCurrentConversationAutomationControl(
7854 context: LocalApiRequestContext
7855): Promise<ConductorHttpResponse> {
7856 const store = requireArtifactStore(context.artifactStore);
7857 const body = readBodyObject(context.request, false);
7858 const action = readOptionalStringField(body, "action");
7859 const scope = readOptionalStringField(body, "scope") ?? "current";
7860
7861 if (scope !== "current") {
7862 throw new LocalApiHttpError(400, "invalid_request", 'Field "scope" only supports "current".', {
7863 field: "scope"
7864 });
7865 }
7866
7867 if (action !== "pause" && action !== "resume" && action !== "mode") {
7868 throw new LocalApiHttpError(400, "invalid_request", 'Field "action" must be pause, resume, or mode.', {
7869 field: "action"
7870 });
7871 }
7872
7873 const assistantMessageId = readOptionalStringBodyField(body, "assistant_message_id", "assistantMessageId");
7874 const sourceConversationId = readOptionalStringBodyField(body, "source_conversation_id", "conversation_id");
7875 const platform = readOptionalStringField(body, "platform");
7876
7877 let conversation = assistantMessageId == null
7878 ? null
7879 : await store.findLocalConversationByLastMessageId(assistantMessageId);
7880
7881 if (conversation == null && platform != null && sourceConversationId != null) {
7882 const link = await store.findConversationLinkByRemoteConversation(platform, sourceConversationId);
7883 conversation = link == null ? null : await store.getLocalConversation(link.localConversationId);
7884 }
7885
7886 if (conversation == null) {
7887 throw new LocalApiHttpError(
7888 404,
7889 "not_found",
7890 "Current local conversation could not be resolved from assistant_message_id / source_conversation_id.",
7891 compactJsonObject({
7892 assistant_message_id: assistantMessageId ?? undefined,
7893 platform: platform ?? undefined,
7894 source_conversation_id: sourceConversationId ?? undefined
7895 })
7896 );
7897 }
7898
7899 const mode = readOptionalStringField(body, "mode");
7900 let nextAutomationStatus: ConversationAutomationStatus;
7901
7902 switch (action) {
7903 case "pause":
7904 nextAutomationStatus = "paused";
7905 break;
7906 case "resume":
7907 nextAutomationStatus =
7908 conversation.lastNonPausedAutomationStatus === "paused"
7909 ? "auto"
7910 : conversation.lastNonPausedAutomationStatus;
7911 break;
7912 case "mode":
7913 if (mode !== "manual" && mode !== "auto" && mode !== "paused") {
7914 throw new LocalApiHttpError(
7915 400,
7916 "invalid_request",
7917 'Field "mode" must be manual, auto, or paused when action is "mode".',
7918 {
7919 field: "mode"
7920 }
7921 );
7922 }
7923 nextAutomationStatus = mode;
7924 break;
7925 }
7926
7927 const detail = await setRenewalConversationAutomationStatus({
7928 automationStatus: nextAutomationStatus,
7929 localConversationId: conversation.localConversationId,
7930 observedAt: context.now() * 1000,
7931 pauseReason:
7932 nextAutomationStatus === "paused"
7933 ? normalizeRenewalPauseReason(readOptionalStringBodyField(body, "reason", "pause_reason")) ?? "ai_pause"
7934 : null,
7935 store
7936 });
7937
7938 return buildSuccessEnvelope(context.requestId, 200, buildRenewalConversationData(detail, true));
7939}
7940
7941function tryParseJson(value: string | null): JsonValue | null {
7942 if (value == null) {
7943 return null;
7944 }
7945
7946 try {
7947 return JSON.parse(value) as JsonValue;
7948 } catch {
7949 return value;
7950 }
7951}
7952
7953function readNonNegativeIntegerQuery(url: URL, fieldName: string, defaultValue: number): number {
7954 const rawValue = url.searchParams.get(fieldName);
7955
7956 if (rawValue == null || rawValue.trim() === "") {
7957 return defaultValue;
7958 }
7959
7960 const numeric = Number(rawValue);
7961
7962 if (!Number.isInteger(numeric) || numeric < 0) {
7963 throw new LocalApiHttpError(
7964 400,
7965 "invalid_request",
7966 `Query parameter "${fieldName}" must be a non-negative integer.`,
7967 { field: fieldName }
7968 );
7969 }
7970
7971 return numeric;
7972}
7973
7974async function dispatchBusinessRoute(
7975 routeId: string,
7976 context: LocalApiRequestContext,
7977 version: string
7978): Promise<ConductorHttpResponse> {
7979 switch (routeId) {
7980 case "service.describe":
7981 return handleDescribeRead(context, version);
7982 case "service.describe.business":
7983 return handleScopedDescribeRead(context, version, "business");
7984 case "service.describe.control":
7985 return handleScopedDescribeRead(context, version, "control");
7986 case "service.robots":
7987 return handleRobotsRead();
7988 case "ui.session.me":
7989 return handleUiSessionMe(context);
7990 case "ui.session.login":
7991 return handleUiSessionLogin(context);
7992 case "ui.session.logout":
7993 return handleUiSessionLogout(context);
7994 case "service.artifact.read":
7995 return handleArtifactRead(context);
7996 case "service.app.shell":
7997 return handleConductorUiEntryRead(context);
7998 case "service.app.assets":
7999 return handleConductorUiAssetRead(context);
8000 case "service.app.history":
8001 return handleConductorUiHistoryRead(context);
8002 case "service.code.read":
8003 return handleCodeRead(context);
8004 case "service.health":
8005 return handleHealthRead(context, version);
8006 case "service.version":
8007 return handleVersionRead(context.requestId, version);
8008 case "system.capabilities":
8009 return handleCapabilitiesRead(context, version);
8010 case "browser.status":
8011 return handleBrowserStatusRead(context);
8012 case "browser.actions":
8013 return handleBrowserActions(context);
8014 case "browser.request":
8015 return handleBrowserRequest(context);
8016 case "browser.request.cancel":
8017 return handleBrowserRequestCancel(context);
8018 case "browser.claude.open":
8019 return handleBrowserClaudeOpen(context);
8020 case "browser.claude.send":
8021 return handleBrowserClaudeSend(context);
8022 case "browser.claude.current":
8023 return handleBrowserClaudeCurrent(context);
8024 case "browser.chatgpt.send":
8025 return handleBrowserChatgptSend(context);
8026 case "browser.chatgpt.current":
8027 return handleBrowserChatgptCurrent(context);
8028 case "browser.gemini.send":
8029 return handleBrowserGeminiSend(context);
8030 case "browser.gemini.current":
8031 return handleBrowserGeminiCurrent(context);
8032 case "browser.claude.reload":
8033 return handleBrowserClaudeReload(context);
8034 case "codex.status":
8035 return handleCodexStatusRead(context);
8036 case "codex.sessions.list":
8037 return handleCodexSessionsList(context);
8038 case "codex.sessions.read":
8039 return handleCodexSessionRead(context);
8040 case "codex.sessions.create":
8041 return handleCodexSessionCreate(context);
8042 case "codex.turn.create":
8043 return handleCodexTurnCreate(context);
8044 case "claude-coded.status":
8045 return handleClaudeCodedStatusRead(context);
8046 case "claude-coded.ask":
8047 return handleClaudeCodedAsk(context);
8048 case "system.state":
8049 return handleSystemStateRead(context);
8050 case "status.view.json":
8051 return handleStatusViewJsonRead(context);
8052 case "status.view.ui":
8053 return handleStatusViewUiRead(context);
8054 case "system.pause":
8055 return handleSystemMutation(context, "paused");
8056 case "system.resume":
8057 return handleSystemMutation(context, "running");
8058 case "system.drain":
8059 return handleSystemMutation(context, "draining");
8060 case "host.exec":
8061 return handleHostExec(context);
8062 case "host.files.read":
8063 return handleHostFileRead(context);
8064 case "host.files.write":
8065 return handleHostFileWrite(context);
8066 case "controllers.list":
8067 return handleControllersList(context);
8068 case "tasks.list":
8069 return handleTasksList(context);
8070 case "tasks.read":
8071 return handleTaskRead(context);
8072 case "tasks.logs.read":
8073 return handleTaskLogsRead(context);
8074 case "runs.list":
8075 return handleRunsList(context);
8076 case "runs.read":
8077 return handleRunRead(context);
8078 case "artifact.messages.list":
8079 return handleArtifactMessagesList(context);
8080 case "artifact.messages.read":
8081 return handleArtifactMessageRead(context);
8082 case "artifact.executions.list":
8083 return handleArtifactExecutionsList(context);
8084 case "artifact.executions.read":
8085 return handleArtifactExecutionRead(context);
8086 case "artifact.sessions.list":
8087 return handleArtifactSessionsList(context);
8088 case "artifact.sessions.latest":
8089 return handleArtifactSessionsLatest(context);
8090 case "renewal.conversations.list":
8091 return handleRenewalConversationsList(context);
8092 case "renewal.conversations.read":
8093 return handleRenewalConversationRead(context);
8094 case "renewal.links.list":
8095 return handleRenewalLinksList(context);
8096 case "renewal.jobs.list":
8097 return handleRenewalJobsList(context);
8098 case "renewal.jobs.read":
8099 return handleRenewalJobRead(context);
8100 case "renewal.conversations.manual":
8101 return handleRenewalConversationMutation(context, "manual");
8102 case "renewal.conversations.auto":
8103 return handleRenewalConversationMutation(context, "auto");
8104 case "renewal.conversations.paused":
8105 return handleRenewalConversationMutation(context, "paused");
8106 case "automation.conversations.control":
8107 return handleCurrentConversationAutomationControl(context);
8108 default:
8109 throw new LocalApiHttpError(404, "not_found", `No local route matches "${context.url.pathname}".`);
8110 }
8111}
8112
8113async function dispatchRoute(
8114 matchedRoute: LocalApiRouteMatch,
8115 context: LocalApiRequestContext,
8116 version: string
8117): Promise<ConductorHttpResponse> {
8118 switch (matchedRoute.route.id) {
8119 case "probe.healthz":
8120 return textResponse(200, "ok");
8121 case "probe.readyz": {
8122 const ready = isRuntimeReady(context.snapshotLoader());
8123 return textResponse(ready ? 200 : 503, ready ? "ready" : "not_ready");
8124 }
8125 case "probe.rolez":
8126 return textResponse(200, resolveLeadershipRole(context.snapshotLoader()));
8127 case "probe.runtime":
8128 return buildSuccessEnvelope(context.requestId, 200, context.snapshotLoader() as unknown as JsonValue);
8129 default:
8130 authorizeRoute(matchedRoute.route, context);
8131 return dispatchBusinessRoute(matchedRoute.route.id, context, version);
8132 }
8133}
8134
8135function matchRoute(method: string, pathname: string): LocalApiRouteMatch | null {
8136 const normalizedPath = normalizePathname(pathname);
8137
8138 for (const route of LOCAL_API_ROUTES) {
8139 if (route.method !== method) {
8140 continue;
8141 }
8142
8143 const params = matchPathPattern(route.pathPattern, normalizedPath);
8144
8145 if (params) {
8146 return {
8147 params,
8148 route
8149 };
8150 }
8151 }
8152
8153 return null;
8154}
8155
8156function findAllowedMethods(pathname: string): LocalApiRouteMethod[] {
8157 const normalizedPath = normalizePathname(pathname);
8158 const methods = new Set<LocalApiRouteMethod>();
8159
8160 for (const route of LOCAL_API_ROUTES) {
8161 if (matchPathPattern(route.pathPattern, normalizedPath)) {
8162 methods.add(route.method);
8163 }
8164 }
8165
8166 return [...methods];
8167}
8168
8169function matchPathPattern(pathPattern: string, pathname: string): Record<string, string> | null {
8170 const patternSegments = normalizePathname(pathPattern).split("/");
8171 const pathSegments = normalizePathname(pathname).split("/");
8172 const wildcardPatternSegment = patternSegments[patternSegments.length - 1];
8173
8174 // When pattern ends with "*" or a trailing named wildcard like ":path*",
8175 // allow any number of trailing segments.
8176 const hasWildcard =
8177 wildcardPatternSegment === "*" ||
8178 (wildcardPatternSegment?.startsWith(":") === true && wildcardPatternSegment.endsWith("*"));
8179
8180 if (hasWildcard) {
8181 if (pathSegments.length < patternSegments.length - 1) {
8182 return null;
8183 }
8184 } else if (patternSegments.length !== pathSegments.length) {
8185 return null;
8186 }
8187
8188 const params: Record<string, string> = {};
8189 const limit = hasWildcard ? patternSegments.length - 1 : patternSegments.length;
8190
8191 for (let index = 0; index < limit; index += 1) {
8192 const patternSegment = patternSegments[index];
8193 const pathSegment = pathSegments[index];
8194
8195 if (patternSegment == null || pathSegment == null) {
8196 return null;
8197 }
8198
8199 if (patternSegment.startsWith(":")) {
8200 params[patternSegment.slice(1)] = decodeURIComponent(pathSegment);
8201 continue;
8202 }
8203
8204 if (patternSegment !== pathSegment) {
8205 return null;
8206 }
8207 }
8208
8209 if (hasWildcard) {
8210 const wildcardValue = pathSegments
8211 .slice(patternSegments.length - 1)
8212 .map((segment) => decodeURIComponent(segment))
8213 .join("/");
8214
8215 if (wildcardPatternSegment === "*") {
8216 params["*"] = wildcardValue;
8217 } else if (wildcardPatternSegment?.startsWith(":")) {
8218 params[wildcardPatternSegment.slice(1, -1)] = wildcardValue;
8219 params["*"] = wildcardValue;
8220 }
8221 }
8222
8223 return params;
8224}
8225
8226function isMissingFileError(error: unknown): boolean {
8227 return typeof error === "object" && error != null && "code" in error && error.code === "ENOENT";
8228}
8229
8230function isSafeArtifactPath(scope: string, fileName: string): boolean {
8231 return (
8232 ALLOWED_ARTIFACT_SCOPES.has(scope) &&
8233 ARTIFACT_FILE_SEGMENT_PATTERN.test(fileName) &&
8234 !fileName.includes("..")
8235 );
8236}
8237
8238export async function handleConductorHttpRequest(
8239 request: ConductorHttpRequest,
8240 context: ConductorLocalApiContext
8241): Promise<ConductorHttpResponse> {
8242 const baseUrl = context.snapshotLoader().controlApi.localApiBase ?? "http://conductor.local";
8243 const url = new URL(request.path || "/", baseUrl);
8244 const matchedRoute = matchRoute(request.method.toUpperCase(), url.pathname);
8245 const requestId = crypto.randomUUID();
8246 const version = context.version?.trim() || "dev";
8247 const now = context.now ?? (() => Math.floor(Date.now() / 1000));
8248 const uiSessionManager =
8249 context.uiSessionManager ??
8250 new UiSessionManager({
8251 browserAdminPassword: null,
8252 now: () => now() * 1000,
8253 readonlyPassword: null
8254 });
8255
8256 if (!matchedRoute) {
8257 const allowedMethods = findAllowedMethods(url.pathname);
8258
8259 if (allowedMethods.length > 0) {
8260 return buildErrorEnvelope(
8261 requestId,
8262 new LocalApiHttpError(
8263 405,
8264 "method_not_allowed",
8265 `Method ${request.method.toUpperCase()} is not allowed for ${normalizePathname(url.pathname)}.`,
8266 {
8267 allow: allowedMethods
8268 },
8269 {
8270 Allow: allowedMethods.join(", ")
8271 }
8272 )
8273 );
8274 }
8275
8276 return buildErrorEnvelope(
8277 requestId,
8278 new LocalApiHttpError(
8279 404,
8280 "not_found",
8281 `No conductor route matches "${normalizePathname(url.pathname)}".`
8282 )
8283 );
8284 }
8285
8286 try {
8287 return await dispatchRoute(
8288 matchedRoute,
8289 {
8290 artifactStore: context.artifactStore ?? null,
8291 deliveryBridge: context.deliveryBridge ?? null,
8292 browserBridge: context.browserBridge ?? null,
8293 browserRequestPolicy: context.browserRequestPolicy ?? null,
8294 browserStateLoader: context.browserStateLoader ?? (() => null),
8295 claudeCodedLocalApiBase:
8296 normalizeOptionalString(context.claudeCodedLocalApiBase) ??
8297 getSnapshotClaudeCodedLocalApiBase(context.snapshotLoader()),
8298 codeRootDir: resolveCodeRootDir(context.codeRootDir),
8299 codexdLocalApiBase:
8300 normalizeOptionalString(context.codexdLocalApiBase) ?? getSnapshotCodexdLocalApiBase(context.snapshotLoader()),
8301 fetchImpl: context.fetchImpl ?? globalThis.fetch,
8302 now,
8303 params: matchedRoute.params,
8304 repository: context.repository,
8305 request,
8306 requestId,
8307 sharedToken: normalizeOptionalString(context.sharedToken),
8308 snapshotLoader: context.snapshotLoader,
8309 uiDistDir: resolveUiDistDir(context.uiDistDir),
8310 uiSessionManager,
8311 url
8312 },
8313 version
8314 );
8315 } catch (error) {
8316 if (error instanceof LocalApiHttpError) {
8317 return buildErrorEnvelope(requestId, error);
8318 }
8319
8320 throw error;
8321 }
8322}