baa-conductor


baa-conductor / apps / conductor-daemon / src
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}