"use strict"; const crypto = require("node:crypto"); const fs = require("node:fs"); const http = require("node:http"); const net = require("node:net"); const path = require("node:path"); const { spawn } = require("node:child_process"); const { URL } = require("node:url"); const { WebSocket, WebSocketServer } = require("ws"); const PACKAGE_ROOT = path.resolve(__dirname, ".."); const VUE_PUBLIC_ROOT = path.join(PACKAGE_ROOT, "web", "dist"); const PUBLIC_ROOT = fs.existsSync(path.join(VUE_PUBLIC_ROOT, "index.html")) ? VUE_PUBLIC_ROOT : path.join(PACKAGE_ROOT, "public"); const MAX_JSON_BODY = 1024 * 1024; // Complete attached-session snapshots include the structured message/tool // projection and can easily exceed 256 KiB. This remains bounded to protect // the local relay while allowing long Codex conversations to hydrate. const MAX_WS_PAYLOAD = 16 * 1024 * 1024; const DEFAULT_EVENT_LIMIT = 2_000; // Transcript state is represented once in the authoritative control snapshot. // Keep replay and socket buffering smaller than an unbounded count of maximum // sized frames so one long conversation cannot amplify into gigabytes. const DEFAULT_EVENT_BYTE_LIMIT = 16 * 1024 * 1024; const DEFAULT_REPLAY_BYTE_LIMIT = 2 * 1024 * 1024; const DEFAULT_CLIENT_BUFFERED_BYTE_LIMIT = MAX_WS_PAYLOAD + 2 * 1024 * 1024; const MAX_REPLAY_TEXT_BYTES = 64 * 1024; const MUTATING_METHODS = new Set([ "control/mode/set", "thread/start", "session/new", "thread/settings/update", "session/select", "turn/start", "turn/steer", "turn/interrupt", ]); const ALLOWED_METHODS = new Set(["initialize", "control/mode/get", "session/list", ...MUTATING_METHODS]); const SERVER_REQUEST_METHODS = new Set([ "item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval", "item/tool/requestUserInput", "mcpServer/elicitation/request", "applyPatchApproval", "execCommandApproval", ]); const REMOTE_RESPONSE_METHODS = new Set([ "approval.respond", "input.respond", "server.request.respond", ]); const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000; function randomToken() { return crypto.randomBytes(24).toString("base64url"); } function randomId(prefix) { return `${prefix}_${crypto.randomBytes(9).toString("base64url")}`; } // JSON-RPC treats numeric and string ids as distinct values. Keep that // distinction in in-memory maps while retaining a small compatibility bridge // for older browser clients that stringify numeric ids before responding. function jsonRpcIdKey(id) { if (typeof id === "number") { return `number:${Object.is(id, -0) ? "-0" : String(id)}`; } if (typeof id === "string") return `string:${id}`; if (id === null) return "null:"; return `${typeof id}:${String(id)}`; } function isJsonRpcId(id) { return typeof id === "string" || typeof id === "number"; } function findTypedMapKey(map, id, valueId = (value) => value?.appId, allowLegacyStringified = false) { const exact = jsonRpcIdKey(id); if (map.has(exact)) return exact; // Before protocol v1, the browser console sent every request id as text. // Allow that form only when it maps to one unambiguous pending id. If both // `1` and `"1"` are pending, the exact typed key above wins and no cross-talk // is possible. if (!allowLegacyStringified || !isJsonRpcId(id)) return exact; const text = String(id); const candidates = []; for (const [key, value] of map) { const candidateId = valueId(value); if (isJsonRpcId(candidateId) && String(candidateId) === text) candidates.push(key); } return candidates.length === 1 ? candidates[0] : exact; } function sameJsonRpcId(left, right) { return typeof left === typeof right && String(left) === String(right) && (typeof left === "string" || typeof left === "number"); } function responseCommandId(requestId, pendingServerRequests, pendingHostCommands) { for (const [commandId, pending] of pendingHostCommands) { if (pending.kind === "server-response" && (sameJsonRpcId(pending.requestId, requestId) || sameJsonRpcId(pending.responseRequestId, requestId))) return commandId; } const base = `response-${String(requestId)}`; let oppositeKey; if (typeof requestId === "number") { oppositeKey = jsonRpcIdKey(String(requestId)); } else if (typeof requestId === "string") { const numeric = Number(requestId); if (Number.isFinite(numeric)) oppositeKey = jsonRpcIdKey(numeric); } if (oppositeKey && pendingServerRequests.has(oppositeKey)) return `${base}-${typeof requestId}`; const typed = `response-${jsonRpcIdKey(requestId)}`; const existingBase = pendingHostCommands.get(base); if (!existingBase || sameJsonRpcId(existingBase.requestId, requestId)) return base; const existingTyped = pendingHostCommands.get(typed); if (!existingTyped || sameJsonRpcId(existingTyped.requestId, requestId)) return typed; // This is only reachable if a caller has manually occupied both stable // names. Keep the command id deterministic and bounded while avoiding an // accidental overwrite. return `${typed}-${crypto.createHash("sha256").update(String(requestId)).digest("hex").slice(0, 8)}`; } function secureEqual(left, right) { const a = Buffer.from(String(left || "")); const b = Buffer.from(String(right || "")); return a.length === b.length && crypto.timingSafeEqual(a, b); } function jsonResponse(response, statusCode, body, extraHeaders = {}) { const payload = Buffer.from(JSON.stringify(body)); response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Content-Length": payload.length, "Cache-Control": "no-store", ...extraHeaders, }); response.end(payload); } function readJson(request) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; request.on("data", (chunk) => { size += chunk.length; if (size > MAX_JSON_BODY) { reject(Object.assign(new Error("request body is too large"), { statusCode: 413 })); request.destroy(); return; } chunks.push(chunk); }); request.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); } catch { reject(Object.assign(new Error("invalid JSON body"), { statusCode: 400 })); } }); request.on("error", reject); }); } function redactString(value) { return String(value) .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, "[REDACTED_API_KEY]") .replace(/\b(Bearer\s+)[A-Za-z0-9._~+\/-]{12,}/gi, "$1[REDACTED]") .replace(/\b(gh[pousr]_[A-Za-z0-9]{20,})\b/g, "[REDACTED_GITHUB_TOKEN]") .replace(/([?&](?:token|key|secret)=)[^&\s]+/gi, "$1[REDACTED]"); } function redact(value, depth = 0) { if (depth > 8) return "[TRUNCATED]"; if (typeof value === "string") return redactString(value); if (Array.isArray(value)) return value.map((entry) => redact(entry, depth + 1)); if (value && typeof value === "object") { const result = {}; for (const [key, entry] of Object.entries(value)) { if (/token|authorization|cookie|private.?key|secret/i.test(key)) { result[key] = "[REDACTED]"; } else { result[key] = redact(entry, depth + 1); } } return result; } return value; } function applyStructuredMessagesPatch(current, patch) { if (!Array.isArray(current) || !patch || typeof patch !== "object" || Array.isArray(patch)) return null; const start = Number(patch.start); const deleteCount = Number(patch.deleteCount); if (!Number.isInteger(start) || start < 0 || start > current.length || !Number.isInteger(deleteCount) || deleteCount < 0 || start + deleteCount > current.length || !Array.isArray(patch.messages)) return null; return [ ...current.slice(0, start), ...patch.messages, ...current.slice(start + deleteCount), ]; } function positiveByteLimit(value, fallback) { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; } function jsonByteLength(value) { return Buffer.byteLength(JSON.stringify(value), "utf8"); } function boundedReplayText(value) { const encoded = Buffer.from(String(value), "utf8"); if (encoded.length <= MAX_REPLAY_TEXT_BYTES) return String(value); // A cut through a multi-byte code point can add one replacement character, // which is harmless for this best-effort replay hint. The following control // snapshot carries the exact authoritative transcript. return encoded.subarray(encoded.length - MAX_REPLAY_TEXT_BYTES).toString("utf8"); } // Every subscriber receives an authoritative control snapshot after replay. // Keep transcript-bearing live events rich, but store only their lightweight // form in the replay ring so streaming a long session cannot retain hundreds // of duplicate full-history projections. function compactTranscriptEventForReplay(event) { if (!event || !["session.snapshot", "output.snapshot", "output.chunk"].includes(event.type)) return event; const source = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload) ? event.payload : {}; // Use an allow-list rather than deleting known large fields. In particular, // current attach adapters send `messagesPatch` instead of `messages`, and a // suffix replacement can itself be nearly as large as the full transcript. const payload = { projectionInControlSnapshot: true }; for (const key of [ "threadId", "turnId", "requestId", "source", "sourceSeq", "stream", "encoding", "state", "structureChanged", ]) { const value = source[key]; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { payload[key] = value; } } if (event.type === "output.chunk" && typeof source.text === "string" && source.text) { payload.text = boundedReplayText(source.text); } return { ...event, payload }; } // Token usage is telemetry, not an authentication credential. The generic // redactor intentionally treats any key containing "token" as sensitive, so // preserve only the numeric usage projection after redacting the rest of a // session metadata envelope. const SAFE_USAGE_FIELDS = [ "totalTokens", "total_tokens", "inputTokens", "input_tokens", "cachedInputTokens", "cached_input_tokens", "cacheWriteInputTokens", "cache_write_input_tokens", "outputTokens", "output_tokens", "reasoningOutputTokens", "reasoning_output_tokens", ]; function safeUsageNumber(value) { if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : undefined; if (typeof value === "string" && /^\d+(?:\.\d+)?$/.test(value.trim())) { const number = Number(value); return Number.isFinite(number) && number >= 0 ? number : undefined; } return undefined; } function safeUsageBreakdown(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const result = {}; for (const field of SAFE_USAGE_FIELDS) { const number = safeUsageNumber(value[field]); if (number !== undefined) result[field.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())] = number; } return Object.keys(result).length ? result : undefined; } function safeTokenUsage(value) { if (value === null) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const source = value.info && typeof value.info === "object" ? value.info : value.tokenUsage && typeof value.tokenUsage === "object" ? value.tokenUsage : value.token_usage && typeof value.token_usage === "object" ? value.token_usage : value; const result = {}; const total = safeUsageBreakdown(source.total ?? source.total_token_usage ?? source.totalTokenUsage); const last = safeUsageBreakdown(source.last ?? source.last_token_usage ?? source.lastTokenUsage); const context = safeUsageNumber(source.modelContextWindow ?? source.model_context_window ?? source.contextWindow ?? source.context_window); if (total) result.total = total; if (last) result.last = last; if (context !== undefined) result.modelContextWindow = context; return Object.keys(result).length ? result : undefined; } function redactSessionMetadata(value) { const redacted = redact(value); if (!redacted || typeof redacted !== "object" || Array.isArray(redacted) || !value || typeof value !== "object") return redacted; for (const key of ["tokenUsage", "latestTokenUsageInfo"]) { if (!Object.prototype.hasOwnProperty.call(value, key)) continue; const usage = safeTokenUsage(value[key]); if (usage !== undefined) redacted[key] = usage; } return redacted; } function normalizeError(error) { return { code: error && error.code ? String(error.code) : "relay_error", message: redactString(error && error.message ? error.message : String(error)), retryable: Boolean(error && error.retryable), }; } function parsePort(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed >= 0 && parsed < 65536 ? parsed : fallback; } /** Parse an optional auth switch without treating an invalid value as false. */ function parseAuthRequired(value) { if (typeof value === "boolean") return value; if (value === 1) return true; if (value === 0) return false; if (typeof value !== "string" || value.trim() === "") return undefined; const normalized = value.trim().toLowerCase(); if (["1", "true", "yes", "on", "required", "enabled"].includes(normalized)) return true; if (["0", "false", "no", "off", "none", "disabled", "local"].includes(normalized)) return false; return undefined; } function hasConfiguredToken(value) { return typeof value === "string" && value.length > 0; } /** Whether the configured listen address is restricted to this machine. */ function isLoopbackIPv4(value) { if (net.isIP(value) !== 4) return false; const octets = value.split(".").map(Number); return octets.length === 4 && octets[0] === 127; } function isLoopbackHost(host) { const normalized = String(host || "").trim().toLowerCase().replace(/^\[|\]$/g, ""); if (normalized === "localhost" || normalized === "::1") return true; if (isLoopbackIPv4(normalized)) return true; return normalized.startsWith("::ffff:") && isLoopbackIPv4(normalized.slice("::ffff:".length)); } /** Node reports IPv4 loopback peers as both 127.x and ::ffff:127.x. */ function isLoopbackAddress(address) { const normalized = String(address || "").trim().toLowerCase(); if (normalized === "::1") return true; if (isLoopbackIPv4(normalized)) return true; return normalized.startsWith("::ffff:") && isLoopbackIPv4(normalized.slice("::ffff:".length)); } function isLoopbackRequestHost(request) { const rawHost = String(request.headers.host || "").trim(); if (!rawHost) return false; try { return isLoopbackHost(new URL(`http://${rawHost}`).hostname); } catch { return false; } } /** Allow browser writes only from the relay's own origin; CLI requests omit Origin. */ function isAllowedHttpOrigin(request) { const origin = request.headers.origin; if (!origin) return true; const requestHost = String(request.headers.host || "").trim().toLowerCase(); if (!requestHost) return false; try { return new URL(origin).host.toLowerCase() === requestHost; } catch { return false; } } function contentType(filePath) { const extension = path.extname(filePath).toLowerCase(); return ( { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", }[extension] || "application/octet-stream" ); } function outputText(method, params) { if (!params || typeof params !== "object") return ""; const candidates = [params.delta, params.text, params.output, params.chunk, params.message]; if (params.item && typeof params.item === "object") { candidates.push(params.item.text, params.item.content); } const text = candidates.find((candidate) => typeof candidate === "string"); if (text) return redactString(text); if (/outputDelta|agentMessage\/delta|plan\/delta|reasoning\/.+Delta/.test(method)) { return redactString(JSON.stringify(params)); } return ""; } function eventTypeForAppMessage(message) { if (message.id !== undefined && message.method) { if (message.method === "item/tool/requestUserInput" || message.method === "mcpServer/elicitation/request") { return "input.requested"; } if (SERVER_REQUEST_METHODS.has(message.method)) return "approval.requested"; return "server.requested"; } if (message.id !== undefined) return "app.response"; const method = message.method || "unknown"; if (/outputDelta|agentMessage\/delta|plan\/delta|reasoning\/.+Delta/.test(method)) return "output.delta"; if (method === "turn/started") return "turn.started"; if (method === "turn/completed") return "turn.completed"; if (method === "thread/started") return "thread.started"; if (method === "error") return "app.error"; return "app.notification"; } function approvalDecisionForResult(result) { if (result && typeof result === "object" && !Array.isArray(result)) { const decision = result.decision; if (typeof decision === "string") { if (["acceptForSession", "accept", "approved", "approved_for_session", "approved_mcp_policy_amendment"].includes(decision)) return "allow"; if (["cancel", "abort"].includes(decision)) return "cancel"; if (["decline", "denied", "timed_out"].includes(decision)) return "deny"; } // App-server v2 encodes policy amendments as tagged objects. Require one // known tag with its documented shape; unknown or mixed objects fail // closed instead of being interpreted as an approval. const decisionKind = approvalDecisionKind(decision); if (decisionKind) return decisionKind; } if (result && typeof result === "object" && !Array.isArray(result)) { if (result.action === "accept") return "allow"; if (result.action === "cancel") return "cancel"; if (result.action === "decline") return "deny"; if (result.permissions && typeof result.permissions === "object") { return Object.keys(result.permissions).length ? "allow" : "deny"; } } // A custom response is still sent to the bridge; this value is only the // local policy hint used by RelayHost when it needs a canonical decision. return "deny"; } function approvalDecisionKind(value) { if (typeof value === "string") { if (["allow", "accept", "acceptForSession", "approved", "approved_for_session", "approved_mcp_policy_amendment"].includes(value)) return "allow"; if (["deny", "decline", "denied", "timed_out"].includes(value)) return "deny"; if (["cancel", "abort"].includes(value)) return "cancel"; return undefined; } const key = knownDecisionObjectKey(value); if (key) return key === "denied" ? "deny" : "allow"; return undefined; } function responseErrorMessage(error) { if (typeof error === "string") return redactString(error).slice(0, 1_000); if (error && typeof error === "object" && typeof error.message === "string") return redactString(error.message).slice(0, 1_000); return "remote response rejected"; } function defaultServerResponse(method, reason = "request timed out") { if (method === "item/permissions/requestApproval") return { permissions: {}, scope: "turn" }; if (method === "item/tool/requestUserInput") return { answers: {} }; if (method === "mcpServer/elicitation/request") return { action: "decline", content: null, _meta: null }; if (method === "applyPatchApproval" || method === "execCommandApproval") { return { decision: { denied: { rejection: reason } } }; } return { decision: "decline" }; } function normalizeServerResponseForApp(method, response) { response = normalizeApprovalResponseForApp(method, response); if (method === "item/permissions/requestApproval") { const source = isObjectPayload(response) ? response : {}; const requested = isObjectPayload(source.permissions) ? source.permissions : {}; const permissions = {}; for (const [key, value] of Object.entries(requested)) { if (value !== null && value !== undefined && isObjectPayload(value)) permissions[key] = value; } const normalized = { permissions, scope: source.scope === "session" ? "session" : "turn" }; if (typeof source.strictAutoReview === "boolean") normalized.strictAutoReview = source.strictAutoReview; return normalized; } if (method === "item/tool/requestUserInput") { if (isObjectPayload(response) && Object.prototype.hasOwnProperty.call(response, "answers")) return response; return { answers: isObjectPayload(response) ? response : {} }; } return response; } const LEGACY_APPROVAL_METHODS = new Set(["applyPatchApproval", "execCommandApproval"]); function normalizeApprovalResponseForApp(method, response) { const v2Approval = new Set([ "item/commandExecution/requestApproval", "item/fileChange/requestApproval", ]); if (!LEGACY_APPROVAL_METHODS.has(method) && !v2Approval.has(method)) return response; if (!isObjectPayload(response) || !Object.prototype.hasOwnProperty.call(response, "decision")) return response; const decision = response.decision; const legacy = LEGACY_APPROVAL_METHODS.has(method); let normalized = decision; if (typeof decision === "string") { if (legacy) { if (["allow", "accept", "approved"].includes(decision)) normalized = "approved"; else if (["acceptForSession", "approved_for_session"].includes(decision)) normalized = "approved_for_session"; else if (["deny", "decline", "denied"].includes(decision)) { normalized = { denied: { rejection: "Denied remotely" } }; } else if (["cancel", "abort"].includes(decision)) normalized = "abort"; else if (decision === "timed_out") normalized = "timed_out"; } else { if (["allow", "accept", "approved"].includes(decision)) normalized = "accept"; else if (["acceptForSession", "approved_for_session"].includes(decision)) normalized = "acceptForSession"; else if (["deny", "decline", "denied", "timed_out"].includes(decision)) normalized = "decline"; else if (["cancel", "abort"].includes(decision)) normalized = "cancel"; else if (decision === "approved_mcp_policy_amendment") normalized = "accept"; } } else if (knownDecisionObjectKey(decision)) { const decisionKey = knownDecisionObjectKey(decision); if (legacy && decisionKey === "acceptWithExecpolicyAmendment") { const value = decision.acceptWithExecpolicyAmendment; normalized = { approved_execpolicy_amendment: { proposed_execpolicy_amendment: value?.execpolicy_amendment ?? value } }; } else if (legacy && decisionKey === "applyNetworkPolicyAmendment") { const value = decision.applyNetworkPolicyAmendment; normalized = { network_policy_amendment: { network_policy_amendment: value?.network_policy_amendment ?? value } }; } else if (!legacy && decisionKey === "approved_execpolicy_amendment") { const value = decision.approved_execpolicy_amendment; normalized = { acceptWithExecpolicyAmendment: { execpolicy_amendment: value?.proposed_execpolicy_amendment ?? value } }; } else if (!legacy && decisionKey === "network_policy_amendment") { const value = decision.network_policy_amendment; normalized = { applyNetworkPolicyAmendment: { network_policy_amendment: value?.network_policy_amendment ?? value } }; } else if (!legacy && decisionKey === "denied") { normalized = "decline"; } } return normalized === decision ? response : { ...response, decision: normalized }; } function isValidApprovalResponse(method, response) { const legacy = LEGACY_APPROVAL_METHODS.has(method); const v2 = method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval"; if (!legacy && !v2) return true; if (!isObjectPayload(response) || !Object.prototype.hasOwnProperty.call(response, "decision")) return false; const decision = response.decision; if (typeof decision === "string") { return legacy ? ["approved", "approved_for_session", "approved_mcp_policy_amendment", "timed_out", "abort"].includes(decision) : ["accept", "acceptForSession", "decline", "cancel"].includes(decision); } if (!decision || typeof decision !== "object" || Array.isArray(decision)) return false; const key = knownDecisionObjectKey(decision); return legacy ? key === "approved_execpolicy_amendment" || key === "network_policy_amendment" || key === "denied" : key === "acceptWithExecpolicyAmendment" || key === "applyNetworkPolicyAmendment"; } function knownDecisionObjectKey(value) { if (!isObjectPayload(value)) return undefined; const keys = Object.keys(value); if (keys.length !== 1) return undefined; const key = keys[0]; const nested = value[key]; if (key === "acceptWithExecpolicyAmendment") { return isObjectPayload(nested) && Object.keys(nested).every((field) => field === "execpolicy_amendment") && isStringArray(nested.execpolicy_amendment) ? key : undefined; } if (key === "approved_execpolicy_amendment") { return isObjectPayload(nested) && Object.keys(nested).every((field) => field === "proposed_execpolicy_amendment") && isStringArray(nested.proposed_execpolicy_amendment) ? key : undefined; } if (key === "applyNetworkPolicyAmendment") { return isObjectPayload(nested) && Object.keys(nested).every((field) => field === "network_policy_amendment") && isNetworkPolicyAmendment(nested.network_policy_amendment) ? key : undefined; } if (key === "network_policy_amendment") { return isObjectPayload(nested) && Object.keys(nested).every((field) => field === "network_policy_amendment") && isNetworkPolicyAmendment(nested.network_policy_amendment) ? key : undefined; } if (key === "denied") { return isObjectPayload(nested) && Object.keys(nested).every((field) => field === "rejection") && typeof nested.rejection === "string" ? key : undefined; } return undefined; } function isStringArray(value) { return Array.isArray(value) && value.every((entry) => typeof entry === "string"); } function isNetworkPolicyAmendment(value) { return isObjectPayload(value) && Object.keys(value).every((field) => field === "host" || field === "action") && typeof value.host === "string" && (value.action === "allow" || value.action === "deny"); } class CodexRelay { constructor(options = {}) { this.host = options.host || process.env.HOST || "127.0.0.1"; this.port = parsePort(options.port ?? process.env.PORT, 8787); this.eventLimit = positiveByteLimit( options.eventLimit ?? process.env.CODEX_REMOTE_EVENT_LIMIT, DEFAULT_EVENT_LIMIT, ); this.eventByteLimit = positiveByteLimit( options.eventByteLimit ?? process.env.CODEX_REMOTE_EVENT_BYTE_LIMIT, DEFAULT_EVENT_BYTE_LIMIT, ); this.replayByteLimit = positiveByteLimit( options.replayByteLimit ?? process.env.CODEX_REMOTE_REPLAY_BYTE_LIMIT, DEFAULT_REPLAY_BYTE_LIMIT, ); this.clientBufferedByteLimit = positiveByteLimit( options.clientBufferedByteLimit ?? process.env.CODEX_REMOTE_CLIENT_BUFFERED_BYTE_LIMIT, DEFAULT_CLIENT_BUFFERED_BYTE_LIMIT, ); const tokenConfigured = hasConfiguredToken(options.operatorToken) || hasConfiguredToken(options.viewerToken) || hasConfiguredToken(options.hostToken) || hasConfiguredToken(process.env.CODEX_REMOTE_TOKEN) || hasConfiguredToken(process.env.CODEX_REMOTE_VIEW_TOKEN) || hasConfiguredToken(process.env.CODEX_REMOTE_HOST_TOKEN); const explicitAuthRequired = Object.prototype.hasOwnProperty.call(options, "authRequired") ? parseAuthRequired(options.authRequired) : parseAuthRequired(process.env.CODEX_REMOTE_AUTH) ?? parseAuthRequired(process.env.CODEX_REMOTE_AUTH_REQUIRED); // A loopback relay is a local development tool by default. The moment a // token is configured, or the relay binds a non-loopback address, retain // the authenticated behavior. `authRequired` can explicitly enable auth // for a local relay; disabling it is intentionally limited to loopback. this.authRequired = explicitAuthRequired ?? (tokenConfigured || !isLoopbackHost(this.host)); if (!this.authRequired && !isLoopbackHost(this.host)) this.authRequired = true; this.operatorToken = options.operatorToken || process.env.CODEX_REMOTE_TOKEN || randomToken(); this.viewerToken = options.viewerToken || process.env.CODEX_REMOTE_VIEW_TOKEN || randomToken(); this.hostToken = options.hostToken || process.env.CODEX_REMOTE_HOST_TOKEN || this.operatorToken; const configuredApprovalTimeout = Number(options.approvalTimeoutMs ?? process.env.CODEX_REMOTE_APPROVAL_TIMEOUT_MS); this.approvalTimeoutMs = Number.isFinite(configuredApprovalTimeout) && configuredApprovalTimeout >= 0 ? configuredApprovalTimeout : DEFAULT_APPROVAL_TIMEOUT_MS; this.generatedOperatorToken = !options.operatorToken && !process.env.CODEX_REMOTE_TOKEN; this.generatedViewerToken = !options.viewerToken && !process.env.CODEX_REMOTE_VIEW_TOKEN; this.codexCommand = options.codexCommand || process.env.CODEX_BIN || "codex"; this.codexArgs = options.codexArgs || this.readCodexArgs(); this.codexCwd = options.codexCwd || process.env.CODEX_CWD || process.cwd(); const spawnConfigured = options.spawnCodex === true || process.env.CODEX_SPAWN === "true" || process.env.CODEX_SPAWN === "1"; // Attaching to the already-open VS Code Codex session is the safe default. // Keep the standalone app-server path available only when it is explicit. this.mode = options.mode || process.env.CODEX_REMOTE_MODE || (options.spawnCodex === false || process.env.CODEX_SPAWN === "false" ? "host" : spawnConfigured ? "embedded" : "host"); this.spawnCodex = this.mode === "host" ? false : options.spawnCodex !== undefined ? options.spawnCodex !== false : process.env.CODEX_SPAWN !== "false"; this.events = []; this.eventSizes = []; this.eventBytes = 0; this.audit = []; this.clients = new Set(); // At most one outbound VS Code host is active for this MVP session. A // host is optional: when absent, the relay can run its embedded stdio // app-server. When present, browser commands are proxied to the host. this.hostClient = null; this.pendingHostCommands = new Map(); this.pendingAppRequests = new Map(); this.pendingServerRequests = new Map(); this.commandResults = new Map(); // Command idempotency is scoped to the embedded app or to one stable host // session. Keep the scope metadata separate so it never crosses the wire. this.commandResultScopes = new Map(); this.hostCommandScope = null; this.nextSeq = 0; this.appRequestCounter = 0; this.appBuffer = ""; this.appProcess = null; this.appGeneration = 0; this.appTerminalGeneration = 0; this.initializedResult = null; this.state = { app: this.spawnCodex ? "starting" : "waiting_for_host", initialized: false, activeThreadId: null, activeTurnId: null, cwd: this.codexCwd, outputTail: "", messages: [], subagents: [], // Keep non-sensitive session metadata available for browsers that join // after the adapter's original session.snapshot event was replayed. sessionMetadata: null, // Typed turn/activity projection from the attached VS Code host. Keep // this in the relay snapshot so a browser that connects after the last // event still knows whether the conversation is thinking, editing, or // waiting for approval. executionStatus: null, lastError: null, mode: this.mode, authRequired: this.authRequired, hostConnected: false, hostSessionId: null, }; } readCodexArgs() { if (!process.env.CODEX_ARGS_JSON) return ["app-server", "--stdio"]; try { const args = JSON.parse(process.env.CODEX_ARGS_JSON); if (!Array.isArray(args) || !args.every((entry) => typeof entry === "string")) throw new Error(); return args; } catch { throw new Error("CODEX_ARGS_JSON must be a JSON array of strings"); } } async start() { this.httpServer = http.createServer((request, response) => this.handleHttp(request, response)); this.wsServer = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD }); this.wsServer.on("connection", (socket, request) => this.handleConnection(socket, request)); this.httpServer.on("upgrade", (request, socket, head) => this.handleUpgrade(request, socket, head)); await new Promise((resolve, reject) => { const onError = (error) => reject(error); this.httpServer.once("error", onError); this.httpServer.listen(this.port, this.host, () => { this.httpServer.off("error", onError); resolve(); }); }); if (this.spawnCodex) this.startCodex(); return this.address(); } address() { const address = this.httpServer.address(); if (!address || typeof address === "string") return { host: this.host, port: this.port }; return { host: address.address, port: address.port }; } async stop() { for (const client of this.clients) client.socket.close(1001, "relay shutting down"); this.clients.clear(); this.hostClient = null; this.pendingHostCommands.clear(); this.pendingAppRequests.clear(); this.commandResults.clear(); this.commandResultScopes.clear(); this.hostCommandScope = null; for (const pending of this.pendingServerRequests.values()) { if (pending.timer) clearTimeout(pending.timer); } this.pendingServerRequests.clear(); if (this.appProcess && !this.appProcess.killed) this.appProcess.kill("SIGTERM"); if (this.wsServer) await new Promise((resolve) => this.wsServer.close(() => resolve())); if (this.httpServer) await new Promise((resolve) => this.httpServer.close(() => resolve())); } startCodex() { if (this.appProcess && this.appProcess.exitCode === null && !this.appProcess.killed) return; this.state.app = "starting"; const child = spawn(this.codexCommand, this.codexArgs, { cwd: this.codexCwd, env: process.env, stdio: ["pipe", "pipe", "pipe"], }); const generation = ++this.appGeneration; this.appProcess = child; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { if (this.appProcess !== child || this.appGeneration !== generation) return; this.consumeAppOutput(chunk); }); child.stderr.on("data", (chunk) => { if (this.appProcess !== child || this.appGeneration !== generation) return; const text = redactString(chunk).slice(0, 32_000); this.recordEvent("app.stderr", { stream: "stderr", text }); }); child.stdin.on("error", (error) => { if (this.appProcess !== child || this.appGeneration !== generation) return; this.handleAppProcessExit(child, generation, { error }); }); child.on("error", (error) => { this.handleAppProcessExit(child, generation, { error }); }); child.on("exit", (code, signal) => { this.handleAppProcessExit(child, generation, { code, signal }); }); this.state.app = "initializing"; const initId = this.nextAppRequestId("initialize"); this.pendingAppRequests.set(jsonRpcIdKey(initId), { kind: "initialize", method: "initialize" }); try { this.sendToApp({ method: "initialize", id: initId, params: { clientInfo: { name: "codex-remote-collab", title: "Codex Remote Collab", version: "0.1.0", }, capabilities: { experimentalApi: true, requestAttestation: false, }, }, }); } catch (error) { this.handleAppProcessExit(child, generation, { error }); } } handleAppProcessExit(child, generation, details = {}) { if (this.appProcess !== child || this.appGeneration !== generation) return; // ChildProcess can emit both `error` and `exit`; process one terminal // transition so pending commands/requests are settled exactly once. if (this.appTerminalGeneration === generation) return; this.appTerminalGeneration = generation; this.appProcess = null; this.appBuffer = ""; const previousThreadId = this.state.activeThreadId; const error = details.error; const code = details.code; const signal = details.signal; const reason = error?.message || `Codex app-server exited (code=${String(code)}, signal=${String(signal)})`; const terminalError = { code: error?.code ? String(error.code) : "app_exited", message: redactString(reason), retryable: true, }; this.state.app = "offline"; this.state.initialized = false; this.state.activeThreadId = null; this.state.activeTurnId = null; this.state.lastError = terminalError; // Results from a dead app-server cannot safely be reused after a restart: // a command may have applied side effects before the process crashed. this.clearCommandResults(); this.recordEvent("app.exited", error ? { error: terminalError } : { code, signal, error: terminalError }); const pendingCommands = [...this.pendingAppRequests.values()]; this.pendingAppRequests.clear(); for (const pending of pendingCommands) { if (!pending.commandId) continue; const payload = { commandId: pending.commandId, method: pending.method || null, ok: false, uncertain: true, retryable: true, error: terminalError, }; this.cacheCommandResult(pending.commandId, payload, "embedded"); const event = this.recordEvent("command.result", payload); if (pending.client) { this.sendControl(pending.client, { type: "command.result", ...payload, seq: event.seq }); } } const pendingRequests = [...this.pendingServerRequests.values()]; for (const pending of pendingRequests) { this.clearPendingServerRequest(pending.appId); const isInput = pending.method === "item/tool/requestUserInput" || pending.method === "mcpServer/elicitation/request"; this.recordEvent(isInput ? "input.expired" : "approval.expired", { requestId: pending.appId, method: pending.method, reason: "Codex app-server exited", error: terminalError, }, { sessionId: previousThreadId || undefined }); } } nextAppRequestId(label) { this.appRequestCounter += 1; return `relay-${label}-${this.appRequestCounter}`; } consumeAppOutput(chunk) { this.appBuffer += chunk; while (true) { const newline = this.appBuffer.indexOf("\n"); if (newline < 0) break; const line = this.appBuffer.slice(0, newline).trim(); this.appBuffer = this.appBuffer.slice(newline + 1); if (!line) continue; try { this.handleAppMessage(JSON.parse(line)); } catch (error) { this.recordEvent("app.parse_error", { error: normalizeError(error), line: redactString(line).slice(0, 2_000), }); } } } handleAppMessage(rawMessage) { const message = redact(rawMessage); if (message.id !== undefined && message.method) { const requestId = rawMessage.id; this.clearPendingServerRequest(requestId); const pending = { appId: requestId, method: message.method, params: message.params || {}, createdAt: Date.now(), }; this.scheduleServerRequestExpiry(requestId, pending); this.pendingServerRequests.set(jsonRpcIdKey(requestId), pending); this.recordEvent(eventTypeForAppMessage(message), { requestId, method: message.method, params: message.params || {}, }); return; } if (message.id !== undefined) { const key = jsonRpcIdKey(rawMessage.id); const pending = this.pendingAppRequests.get(key); this.pendingAppRequests.delete(key); if (pending && pending.kind === "initialize") { if (message.result) { this.initializedResult = message.result; this.state.app = "ready"; this.state.initialized = true; // The app-server handshake is ordered: initialized follows the // successful initialize response. Some versions reject an early // notification or process it before capabilities are established. try { this.sendToApp({ method: "initialized", params: {} }); } catch (error) { this.state.app = "error"; this.state.initialized = false; this.state.lastError = normalizeError(error); } this.recordEvent("app.ready", { result: message.result }); } else { this.state.app = "error"; this.state.lastError = message.error || { message: "Codex initialization failed" }; this.recordEvent("app.error", { error: this.state.lastError }); } return; } if (pending && pending.method === "thread/start" && message.result?.thread?.id) { this.state.activeThreadId = message.result.thread.id; this.state.cwd = message.result.cwd || this.state.cwd; } if (pending && pending.method === "turn/start" && message.result?.turn?.id) { this.state.activeTurnId = message.result.turn.id; } const commandPayload = { commandId: pending?.commandId || null, method: pending?.method || null, ok: !message.error, result: message.result, error: message.error, }; if (pending?.commandId) { this.cacheCommandResult(pending.commandId, commandPayload, "embedded"); } const event = this.recordEvent("command.result", commandPayload); if (pending?.client) this.sendControl(pending.client, { ...commandPayload, type: "command.result", seq: event.seq }); return; } this.updateStateFromNotification(message); this.recordEvent(eventTypeForAppMessage(message), { method: message.method || "unknown", params: message.params || {}, text: outputText(message.method || "", message.params), emittedAtMs: message.emittedAtMs, }); } updateStateFromNotification(message) { const params = message.params || {}; if (message.method === "thread/started" && params.thread?.id) this.state.activeThreadId = params.thread.id; if (message.method === "turn/started" && params.turn?.id) this.state.activeTurnId = params.turn.id; if (message.method === "turn/completed" && (!params.turn?.id || params.turn.id === this.state.activeTurnId)) { this.state.activeTurnId = null; } } sendToApp(message) { if (!this.appProcess || this.appProcess.exitCode !== null || this.appProcess.killed || !this.appProcess.stdin || this.appProcess.stdin.destroyed || !this.appProcess.stdin.writable) { throw Object.assign(new Error("Codex app-server is offline"), { code: "app_offline", retryable: true, }); } this.appProcess.stdin.write(`${JSON.stringify(message)}\n`); } recordEvent(type, payload, options = {}) { const event = { v: 1, kind: "event", id: randomId("evt"), seq: ++this.nextSeq, ts: new Date().toISOString(), type, sessionId: options.sessionId || this.state.activeThreadId, payload: redact(payload), }; const replayEvent = compactTranscriptEventForReplay(event); const replayBytes = jsonByteLength(replayEvent); // An individual event that exceeds the entire replay budget is still // delivered live and represented by the authoritative state snapshot. Do // not let it make the in-memory ring exceed its configured hard bound. if (replayBytes <= this.eventByteLimit) { this.events.push(replayEvent); this.eventSizes.push(replayBytes); this.eventBytes += replayBytes; } while (this.events.length > this.eventLimit || this.eventBytes > this.eventByteLimit) { this.events.shift(); this.eventBytes -= this.eventSizes.shift() || 0; } for (const client of this.clients) { if (client.authenticated && client.subscribed && client !== options.excludeClient) this.sendControl(client, event); } return event; } auditAction(client, action, details, outcome) { this.audit.push({ id: randomId("audit"), ts: new Date().toISOString(), actor: client?.id || "http", role: client?.role || "unknown", action, details: redact(details), outcome, }); if (this.audit.length > 500) this.audit.splice(0, this.audit.length - 500); } handleUpgrade(request, socket, head) { let requestUrl; try { requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`); } catch { socket.destroy(); return; } if (requestUrl.pathname !== "/ws" && requestUrl.pathname !== "/v1/connect") { socket.destroy(); return; } if (!this.authRequired && !isLoopbackRequestHost(request)) { socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); socket.destroy(); return; } const origin = request.headers.origin; if (origin) { try { if (new URL(origin).host.toLowerCase() !== String(request.headers.host || "").toLowerCase()) { socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); socket.destroy(); return; } } catch { socket.destroy(); return; } } this.wsServer.handleUpgrade(request, socket, head, (webSocket) => { this.wsServer.emit("connection", webSocket, request); }); } handleConnection(socket, request) { const client = { id: randomId("client"), socket, role: null, authenticated: false, subscribed: false, clientType: null, sessionId: null, commandScope: null, lastSeq: 0, remoteAddress: request.socket.remoteAddress, }; this.clients.add(client); const authTimer = setTimeout(() => { if (!client.authenticated) socket.close(1008, "authentication required"); }, 10_000); authTimer.unref(); socket.on("message", (data, isBinary) => { if (isBinary) { socket.close(1003, "JSON text frames only"); return; } let message; try { message = JSON.parse(data.toString("utf8")); } catch { this.sendControl(client, { type: "error", code: "invalid_json", message: "Invalid JSON frame" }); return; } if (!message || typeof message !== "object" || Array.isArray(message)) { this.sendControl(client, { type: "error", code: "invalid_frame", message: "JSON frame must be an object" }); return; } try { this.handleClientMessage(client, message); } catch (error) { this.recordEvent("relay.error", { clientId: client.id, error: normalizeError(error) }); this.sendControl(client, { type: "error", ...normalizeError(error) }); } }); socket.on("close", () => { clearTimeout(authTimer); this.clients.delete(client); if (this.hostClient === client) { this.hostClient = null; this.state.hostConnected = false; this.state.hostSessionId = null; this.state.app = "offline"; this.state.initialized = false; this.state.activeThreadId = null; this.state.activeTurnId = null; this.state.lastError = { code: "host_disconnected", message: "VS Code host disconnected" }; this.recordEvent("host.disconnected", { clientId: client.id }, { excludeClient: client }); // Commands waiting on a host cannot be completed after its socket is // gone. Keep their ids reserved briefly so retries get a clear error. for (const [commandId, pending] of this.pendingHostCommands) { this.pendingHostCommands.delete(commandId); this.cacheCommandResult(commandId, { commandId, method: pending.method, ok: false, uncertain: true, error: { code: "host_disconnected", message: "VS Code host disconnected" }, }, pending.commandScope || client.commandScope || this.hostCommandScope); if (pending.client) { if (pending.kind === "server-response") { this.sendControl(pending.client, { type: "response.rejected", requestId: pending.responseRequestId ?? pending.requestId, code: "host_disconnected", message: "VS Code host disconnected", retryable: true, }); } else { this.sendControl(pending.client, { type: "command.result", commandId, method: pending.method, ok: false, uncertain: true, error: { code: "host_disconnected", message: "VS Code host disconnected" }, }); } } } for (const pending of this.pendingServerRequests.values()) { if (pending.source !== "host" || pending.hostClient !== client) continue; this.clearPendingServerRequest(pending.appId); this.recordEvent( pending.method === "item/tool/requestUserInput" || pending.method === "mcpServer/elicitation/request" ? "input.expired" : "approval.expired", { requestId: pending.appId, method: pending.method, reason: "VS Code host disconnected" }, { sessionId: client.sessionId || undefined }, ); } } if (client.authenticated) this.recordEvent("presence.changed", { clientId: client.id, state: "offline" }); }); socket.on("error", () => {}); } roleForToken(token) { if (secureEqual(token, this.operatorToken)) return "operator"; if (secureEqual(token, this.viewerToken)) return "viewer"; return null; } handleClientMessage(client, message) { if (!message || typeof message !== "object" || Array.isArray(message)) { this.sendControl(client, { type: "error", code: "invalid_frame", message: "JSON frame must be an object" }); return; } if (!client.authenticated) { // The VS Code bridge sends a hello frame before its auth frame. Keep the // hello unauthenticated but remember the client kind and resume cursor. const localConnection = !this.authRequired && isLoopbackAddress(client.remoteAddress); const localNoAuthHandshake = localConnection && (message.kind === "hello" || message.kind === "auth" || message.type === "auth"); let token = null; if (message.kind === "hello") { if (message.protocol !== undefined && Number(message.protocol) !== 1) { this.sendControl(client, { type: "error", code: "unsupported_protocol", message: "Only protocol 1 is supported" }); client.socket.close(1002, "unsupported protocol"); return; } client.clientType = message.clientType === "host" ? "host" : "web"; client.sessionId = typeof message.sessionId === "string" ? message.sessionId : null; client.lastSeq = Number.isFinite(Number(message.lastSeq)) ? Number(message.lastSeq) : 0; token = typeof message.token === "string" ? message.token : typeof message.accessToken === "string" ? message.accessToken : null; if (!token && !localConnection) return; } else { token = message.type === "auth" && typeof message.token === "string" ? message.token : message.kind === "auth" && typeof message.accessToken === "string" ? message.accessToken : message.kind === "auth" && typeof message.token === "string" ? message.token : null; } if (!token && !localNoAuthHandshake) { client.socket.close(1008, "authentication required"); return; } const isHost = client.clientType === "host"; const role = localNoAuthHandshake ? (isHost ? "host" : "operator") : (isHost && secureEqual(token, this.hostToken) ? "operator" : this.roleForToken(token)); if (!role || (!localNoAuthHandshake && isHost && !secureEqual(token, this.hostToken))) { this.auditAction(client, "authenticate", {}, "denied"); client.socket.close(1008, "invalid token"); return; } client.authenticated = true; client.clientType = client.clientType || "web"; client.role = isHost ? "host" : role; if (client.clientType === "host") { if (this.mode === "embedded") { this.sendControl(client, { type: "error", code: "host_mode_disabled", message: "Start relay with CODEX_REMOTE_MODE=host (or CODEX_SPAWN=false) for a VS Code host" }); client.socket.close(1008, "host mode disabled"); return; } if (this.hostClient && this.hostClient !== client) { this.sendControl(client, { type: "error", code: "host_already_connected", message: "A VS Code host is already connected" }); client.socket.close(1008, "host already connected"); return; } const hostSessionId = typeof client.sessionId === "string" && client.sessionId.length > 0 ? client.sessionId : null; const commandScope = hostSessionId ? `session:${hostSessionId}` : `connection:${client.id}`; // A command id is only idempotent within the same host session. A // reconnect with the same stable session id may reuse the cache; // another session must never inherit old results (including uncertain // disconnect results). if (this.hostCommandScope !== null && this.hostCommandScope !== commandScope) { this.clearCommandResults(); } client.commandScope = commandScope; this.hostCommandScope = commandScope; if (this.state.hostSessionId && this.state.hostSessionId !== client.sessionId) { this.state.activeThreadId = null; this.state.activeTurnId = null; } this.hostClient = client; this.state.hostConnected = true; this.state.hostSessionId = client.sessionId; this.recordEvent("host.connected", { clientId: client.id, sessionId: client.sessionId }, { excludeClient: client }); } this.auditAction(client, "authenticate", {}, "accepted"); this.sendControl(client, { type: "auth.ok", clientId: client.id, role: client.role, clientType: client.clientType, protocol: 1, authRequired: this.authRequired, latestSeq: this.nextSeq, }); return; } // Host frames use the versioned relay contract; browser frames use the // compact `type` contract. Host events are ingested and re-sequenced here // instead of echoed back to the host. if (message.kind === "hello") { const announcedType = message.clientType === "host" ? "host" : "web"; if (announcedType !== client.clientType) { this.sendControl(client, { type: "error", code: "client_type_immutable", message: "clientType cannot change after authentication" }); return; } client.sessionId = typeof message.sessionId === "string" ? message.sessionId : client.sessionId; return; } if (message.kind === "auth" || message.type === "auth") { return; } if (message.kind === "ack") return; if (message.kind === "event" && client.clientType === "host") { this.ingestHostEvent(client, message); return; } if (message.type === "subscribe") { this.subscribe(client, Number(message.fromSeq || 0)); return; } if (message.type === "ping") { this.sendControl(client, { type: "pong", ts: new Date().toISOString(), latestSeq: this.nextSeq }); return; } // Browser clients historically used compact `{type:"command", method, // params}` / `{type:"respond", requestId, result}` frames. The bridge // contract is versioned and uses `{kind:"command", type, payload}` (and // approval/input response command names). Normalize both forms at this // boundary; host clients are event producers and must not issue relay // commands back into themselves. if (client.clientType !== "host") { const command = normalizeBrowserCommand(message); if (command) { if (REMOTE_RESPONSE_METHODS.has(command.method)) { this.dispatchServerResponse(client, normalizeBrowserResponse(message, command.method)); } else { this.dispatchCommand(client, command); } return; } const response = normalizeBrowserResponse(message); if (response) { this.dispatchServerResponse(client, response); return; } } this.sendControl(client, { type: "error", code: "unknown_frame", message: "Unknown frame type" }); } subscribe(client, fromSeq) { const firstAvailable = this.events.length ? this.events[0].seq : this.nextSeq + 1; const replayEvents = []; let replayBytes = 0; let replayTooLarge = false; if (fromSeq + 1 >= firstAvailable) { for (const event of this.events) { if (event.seq <= fromSeq) continue; const size = jsonByteLength(event); if (replayBytes + size > this.replayByteLimit) { replayTooLarge = true; break; } replayEvents.push(event); replayBytes += size; } } if (fromSeq + 1 < firstAvailable || replayTooLarge) { this.sendControl(client, { type: "resync.required", requestedFromSeq: fromSeq, firstAvailableSeq: firstAvailable, ...(replayTooLarge ? { reason: "replay_too_large" } : {}), }); } else { for (const event of replayEvents) this.sendControl(client, event); } client.subscribed = true; // Other subscribers need the presence transition, while the joining // client receives the same fact in the clients list of its snapshot. Add // it first so `latestSeq` covers every authoritative state transition. this.recordEvent("presence.changed", { clientId: client.id, role: client.role, state: "online" }, { excludeClient: client }); this.sendControl(client, { type: "session.snapshot", snapshot: this.snapshot() }); } ingestHostEvent(client, frame) { const sourceType = typeof frame.type === "string" ? frame.type : "app.notification"; const sourcePayload = frame.payload && typeof frame.payload === "object" ? frame.payload : {}; const sourceSeq = Number.isFinite(Number(frame.seq)) ? Number(frame.seq) : undefined; const sessionId = client.sessionId || frame.sessionId || this.state.hostSessionId || undefined; const executionStatus = sourcePayload.executionStatus && typeof sourcePayload.executionStatus === "object" ? sourcePayload.executionStatus : frame.status && typeof frame.status === "object" ? frame.status : sourcePayload.status && typeof sourcePayload.status === "object" ? sourcePayload.status : null; if (executionStatus) this.state.executionStatus = redact(executionStatus); // Keep the browser snapshot useful even though the host deliberately uses // a normalized event vocabulary instead of raw app-server notifications. if (sourceType === "session.created" && sourcePayload.thread && typeof sourcePayload.thread === "object") { const id = sourcePayload.thread.id; if (typeof id === "string") this.state.activeThreadId = id; } if (sourceType === "session.snapshot") { const threadId = sourcePayload.threadId || sourcePayload.thread?.id; const turnId = sourcePayload.turnId || sourcePayload.turn?.id; if (typeof threadId === "string") this.state.activeThreadId = threadId; if (typeof turnId === "string") this.state.activeTurnId = turnId; if (sourcePayload.threadId === null || sourcePayload.thread === null) this.state.activeThreadId = null; if (sourcePayload.turnId === null || sourcePayload.turn === null) this.state.activeTurnId = null; this.state.app = "ready"; this.state.initialized = true; this.state.lastError = null; if (typeof sourcePayload.outputTail === "string") this.state.outputTail = sourcePayload.outputTail; if (Array.isArray(sourcePayload.messages)) this.state.messages = sourcePayload.messages; if (sourcePayload.metadata && typeof sourcePayload.metadata === "object" && !Array.isArray(sourcePayload.metadata)) { const metadata = sourcePayload.metadata; this.state.sessionMetadata = redactSessionMetadata({ ...(typeof metadata.title === "string" ? { title: metadata.title } : {}), ...(typeof metadata.name === "string" ? { name: metadata.name } : {}), ...(typeof metadata.cwd === "string" ? { cwd: metadata.cwd } : {}), ...(typeof metadata.mode === "string" ? { mode: metadata.mode } : {}), ...(metadata.controlMode === "sync" || metadata.controlMode === "async" ? { controlMode: metadata.controlMode } : {}), ...(Number.isSafeInteger(metadata.modeEpoch) && metadata.modeEpoch >= 0 ? { modeEpoch: metadata.modeEpoch } : {}), ...(metadata.capabilities && typeof metadata.capabilities === "object" && !Array.isArray(metadata.capabilities) ? { capabilities: metadata.capabilities } : {}), ...(typeof metadata.source === "string" ? { source: metadata.source } : {}), ...(typeof metadata.historyComplete === "boolean" ? { historyComplete: metadata.historyComplete } : {}), ...(typeof metadata.waitingForSession === "boolean" ? { waitingForSession: metadata.waitingForSession } : {}), ...(typeof metadata.attachReady === "boolean" ? { attachReady: metadata.attachReady } : {}), ...(typeof metadata.model === "string" ? { model: metadata.model } : {}), ...(typeof metadata.latestModel === "string" ? { latestModel: metadata.latestModel } : {}), ...(typeof metadata.effort === "string" || metadata.effort === null ? { effort: metadata.effort } : {}), ...(typeof metadata.latestReasoningEffort === "string" || metadata.latestReasoningEffort === null ? { latestReasoningEffort: metadata.latestReasoningEffort } : {}), ...(typeof metadata.modelName === "string" ? { modelName: metadata.modelName } : {}), ...(typeof metadata.modelProvider === "string" ? { modelProvider: metadata.modelProvider } : {}), ...(typeof metadata.approvalPolicy === "string" ? { approvalPolicy: metadata.approvalPolicy } : {}), ...(typeof metadata.approvalsReviewer === "string" ? { approvalsReviewer: metadata.approvalsReviewer } : {}), ...(typeof metadata.sandboxPolicy === "string" ? { sandboxPolicy: metadata.sandboxPolicy } : {}), ...(metadata.approvalPolicy && typeof metadata.approvalPolicy === "object" ? { approvalPolicy: metadata.approvalPolicy } : {}), ...(metadata.approvalsReviewer === null ? { approvalsReviewer: null } : {}), ...(metadata.sandboxPolicy && typeof metadata.sandboxPolicy === "object" ? { sandboxPolicy: metadata.sandboxPolicy } : {}), ...(typeof metadata.permissions === "string" || (metadata.permissions && typeof metadata.permissions === "object") || metadata.permissions === null ? { permissions: metadata.permissions } : {}), ...(typeof metadata.currentPermissions === "string" || (metadata.currentPermissions && typeof metadata.currentPermissions === "object") || metadata.currentPermissions === null ? { currentPermissions: metadata.currentPermissions } : {}), ...(Array.isArray(metadata.runtimeWorkspaceRoots) ? { runtimeWorkspaceRoots: metadata.runtimeWorkspaceRoots } : {}), ...(typeof metadata.workedDurationMs === "number" ? { workedDurationMs: metadata.workedDurationMs } : {}), ...(typeof metadata.firstTurnWorkItemStartedAtMs === "number" ? { firstTurnWorkItemStartedAtMs: metadata.firstTurnWorkItemStartedAtMs } : {}), ...(typeof metadata.finalAssistantStartedAtMs === "number" ? { finalAssistantStartedAtMs: metadata.finalAssistantStartedAtMs } : {}), ...(metadata.tokenUsage && typeof metadata.tokenUsage === "object" ? { tokenUsage: metadata.tokenUsage } : metadata.tokenUsage === null ? { tokenUsage: null } : {}), ...(metadata.latestTokenUsageInfo && typeof metadata.latestTokenUsageInfo === "object" ? { latestTokenUsageInfo: metadata.latestTokenUsageInfo } : metadata.latestTokenUsageInfo === null ? { latestTokenUsageInfo: null } : {}), ...(metadata.threadSettings && typeof metadata.threadSettings === "object" ? { threadSettings: metadata.threadSettings } : {}), ...(Array.isArray(metadata.availableModels) ? { availableModels: metadata.availableModels } : {}), ...(Array.isArray(metadata.models) ? { models: metadata.models } : {}), ...(Array.isArray(metadata.subagents) ? { subagents: metadata.subagents } : {}), ...(typeof metadata.parentThreadId === "string" ? { parentThreadId: metadata.parentThreadId } : {}), ...(typeof metadata.agentNickname === "string" ? { agentNickname: metadata.agentNickname } : {}), ...(typeof metadata.agentRole === "string" ? { agentRole: metadata.agentRole } : {}), }); } if (Array.isArray(sourcePayload.subagents)) this.state.subagents = redact(sourcePayload.subagents); else if (Array.isArray(sourcePayload.metadata?.subagents)) this.state.subagents = redact(sourcePayload.metadata.subagents); for (const request of Array.isArray(sourcePayload.pendingRequests) ? sourcePayload.pendingRequests : []) { if (!request || typeof request !== "object" || request.requestId === undefined) continue; const requestId = request.requestId; this.clearPendingServerRequest(requestId); const pending = { appId: requestId, method: typeof request.method === "string" ? request.method : "server.request", params: request.params && typeof request.params === "object" ? request.params : {}, ...(typeof request.risk === "string" ? { risk: request.risk } : {}), ...(typeof request.summary === "string" ? { summary: request.summary } : {}), createdAt: Number.isFinite(Number(request.createdAt)) ? Number(request.createdAt) : Date.now(), ...(Number.isFinite(Number(request.expiresAt)) ? { expiresAt: Number(request.expiresAt) } : {}), source: "host", hostClient: client, commandHash: typeof request.commandHash === "string" ? request.commandHash : undefined, }; if (pending.expiresAt && pending.expiresAt <= Date.now()) continue; this.pendingServerRequests.set(jsonRpcIdKey(requestId), pending); } } if (sourceType === "session.switching") { const targetThreadId = sourcePayload.targetThreadId || sourcePayload.threadId; if (typeof targetThreadId === "string") this.state.activeThreadId = targetThreadId; // The old transcript belongs to the previous thread. Clear it before // the target's authoritative snapshot arrives so a remote picker never // briefly renders messages from two sessions together. this.state.activeTurnId = null; this.state.outputTail = ""; this.state.messages = []; this.state.subagents = []; this.state.sessionMetadata = null; this.state.executionStatus = null; } if (sourceType === "session.selected") { const selectedThreadId = sourcePayload.threadId || sourcePayload.activeThreadId; if (typeof selectedThreadId === "string") this.state.activeThreadId = selectedThreadId; } if (sourceType === "output.snapshot") { if (typeof sourcePayload.text === "string") this.state.outputTail = sourcePayload.text; if (Array.isArray(sourcePayload.messages)) this.state.messages = sourcePayload.messages; if (Array.isArray(sourcePayload.subagents)) this.state.subagents = redact(sourcePayload.subagents); if (sourcePayload.metadata && typeof sourcePayload.metadata === "object" && !Array.isArray(sourcePayload.metadata)) { // Output snapshots from older hosts occasionally carry the metadata // projection instead of a separate session.snapshot event. Preserve // the safe projection so model, permission, and usage controls remain // available after reconnect. this.state.sessionMetadata = redactSessionMetadata(sourcePayload.metadata); } } else if (sourceType === "output.chunk") { // New attach adapters carry the complete role-aware projection alongside // the append-only delta. Preserve both so reconnects do not flatten // reasoning, tools, edits, or Markdown into one assistant transcript. if (typeof sourcePayload.outputTail === "string") this.state.outputTail = sourcePayload.outputTail; else if (typeof sourcePayload.text === "string") this.state.outputTail = `${this.state.outputTail || ""}${sourcePayload.text}`.slice(-32_000); if (Array.isArray(sourcePayload.messages)) this.state.messages = sourcePayload.messages; else { const patchedMessages = applyStructuredMessagesPatch(this.state.messages, sourcePayload.messagesPatch); if (patchedMessages) this.state.messages = patchedMessages; } if (Array.isArray(sourcePayload.subagents)) this.state.subagents = redact(sourcePayload.subagents); if (sourcePayload.metadata && typeof sourcePayload.metadata === "object" && !Array.isArray(sourcePayload.metadata)) { this.state.sessionMetadata = redactSessionMetadata(sourcePayload.metadata); } } if (sourceType === "task.started") { const id = sourcePayload.turnId || (sourcePayload.turn && sourcePayload.turn.id); if (typeof id === "string") this.state.activeTurnId = id; if (typeof sourcePayload.threadId === "string") this.state.activeThreadId = sourcePayload.threadId; } if (sourceType === "task.finished" || sourceType === "task.cancelled") { this.state.activeTurnId = null; } if (sourceType === "connection.opened") { this.state.app = "ready"; this.state.initialized = true; this.state.hostConnected = true; this.state.lastError = null; } else if (sourceType === "connection.closed") { // A replaced host socket can have one frame already queued in the // transport. A stale close must not mark the newly connected host // offline; acknowledge it so the old bridge does not retry forever. if (this.hostClient !== client) { if (sourceSeq !== undefined) { this.sendControl(client, { v: 1, kind: "ack", sessionId: sessionId || "", seq: sourceSeq }); } return; } this.state.app = "offline"; this.state.initialized = false; this.state.activeTurnId = null; this.state.outputTail = ""; this.state.messages = []; this.state.subagents = []; this.state.sessionMetadata = null; this.state.executionStatus = null; this.state.lastError = { code: "app_unavailable", message: typeof sourcePayload.message === "string" ? redactString(sourcePayload.message) : "VS Code host app-server disconnected", retryable: true, }; // This event is emitted by the authenticated host bridge when its local // app-server exits. The relay socket remains usable, so clean only the // app-scoped pending work here; transport close has its own handler. this.handleHostAppUnavailable(client, sessionId, this.state.lastError.message); } // RelayHost emits normalized approval/input events and keeps the original // app-server request id in payload. Store it centrally so exactly one // browser response can be routed back to that host. if (sourceType === "approval.requested" || sourceType === "input.requested" || sourceType === "server.requested" || sourceType === "server.request") { const requestId = sourcePayload.requestId; if (requestId !== undefined) { const key = jsonRpcIdKey(requestId); this.clearPendingServerRequest(requestId); const pending = { appId: requestId, method: typeof sourcePayload.method === "string" ? sourcePayload.method : sourceType, params: sourcePayload.params || sourcePayload, commandHash: typeof sourcePayload.commandHash === "string" ? sourcePayload.commandHash : undefined, risk: typeof sourcePayload.risk === "string" ? sourcePayload.risk : undefined, summary: typeof sourcePayload.summary === "string" ? sourcePayload.summary : undefined, expiresAt: Number.isFinite(Number(sourcePayload.expiresAt)) ? Number(sourcePayload.expiresAt) : undefined, createdAt: Date.now(), source: "host", hostClient: client, }; // The VS Code adapter owns its local approval timer. Keeping a second // timer in the relay would race the adapter's JSON-RPC response. this.pendingServerRequests.set(key, pending); } } if (sourceType === "approval.expired" || sourceType === "input.expired" || sourceType === "server.expired") { const requestId = sourcePayload.requestId; if (requestId !== undefined) { const key = jsonRpcIdKey(requestId); const pending = this.pendingServerRequests.get(key); if (pending?.source === "host" && pending.hostClient === client) { this.clearPendingServerRequest(pending.appId); } } } if (sourceType === "approval.resolved" || sourceType === "input.resolved" || sourceType === "server.responded" || sourceType === "server.resolved") { const requestId = sourcePayload.requestId; if (requestId !== undefined) this.clearPendingServerRequest(requestId); } if (sourceType === "command.accepted" || sourceType === "command.rejected" || sourceType === "command.result") { const commandId = sourcePayload.commandId; const pending = commandId ? this.pendingHostCommands.get(String(commandId)) : undefined; const ok = sourceType === "command.accepted" ? sourcePayload.ok !== false : sourcePayload.ok === true; const resultPayload = { commandId: commandId || null, method: sourcePayload.method || null, ok, result: sourcePayload.result, error: sourcePayload.error, sourceSeq, }; if (resultPayload.result && typeof resultPayload.result === "object") { const result = resultPayload.result; if (result.thread && typeof result.thread.id === "string") this.state.activeThreadId = result.thread.id; if (typeof result.threadId === "string") this.state.activeThreadId = result.threadId; if (typeof result.activeThreadId === "string") this.state.activeThreadId = result.activeThreadId; if (typeof result.selectedThreadId === "string") this.state.activeThreadId = result.selectedThreadId; if (result.turn && typeof result.turn.id === "string") this.state.activeTurnId = result.turn.id; } if (commandId) { this.pendingHostCommands.delete(String(commandId)); // A late terminal frame from an app that already reported // connection.closed must not repopulate the cache we just invalidated. if (pending || this.state.app !== "offline") { this.cacheCommandResult( String(commandId), resultPayload, pending?.commandScope || client.commandScope || this.hostCommandScope, ); } } const event = this.recordEvent("command.result", resultPayload, { sessionId }); if (pending?.kind === "server-response") { const requestId = pending.responseRequestId ?? pending.requestId; if (resultPayload.ok) { this.clearPendingServerRequest(pending.requestId); const responseEvent = this.recordEvent("server.responded", { requestId, method: pending.method, ok: true, }, { sessionId }); this.sendControl(pending.client, { type: "response.accepted", requestId, seq: responseEvent.seq }); } else { this.sendControl(pending.client, { type: "response.rejected", requestId, code: "host_rejected", message: responseErrorMessage(resultPayload.error || "VS Code host rejected the response"), retryable: true, }); } } else if (pending?.client) { this.sendControl(pending.client, { type: "command.result", ...resultPayload, seq: event.seq }); } return; } const payload = { ...sourcePayload, ...((sourceType === "approval.requested" || sourceType === "input.requested" || sourceType === "server.requested" || sourceType === "server.request") && !sourcePayload.params ? { params: sourcePayload } : {}), source: "vscode-host", ...(sourceSeq !== undefined ? { sourceSeq } : {}), ...(frame.raw !== undefined ? { raw: redact(frame.raw) } : {}), }; const event = this.recordEvent(sourceType, payload, { sessionId }); // RelayHost sends event frames to its own relay transport and expects an // ack. Acknowledge only after the frame has been accepted into our ring. if (sourceSeq !== undefined) { this.sendControl(client, { v: 1, kind: "ack", sessionId: sessionId || "", seq: sourceSeq }); } return event; } handleHostAppUnavailable(client, sessionId, reason = "VS Code host app-server unavailable") { // A delayed frame from an older host socket must never tear down the // pending work or cache belonging to the currently authenticated host. if (this.hostClient !== client) return; const terminalError = { code: "app_unavailable", message: redactString(reason), retryable: true, }; // A local app-server crash invalidates both completed cache entries and // in-flight host commands. Report in-flight commands as uncertain to the // originating browser, but do not cache them: a retry after recovery must // be explicit rather than silently replaying an unknown operation. this.clearCommandResults(); for (const [commandId, pending] of [...this.pendingHostCommands]) { if (pending.hostClient && pending.hostClient !== client) continue; if (!pending.hostClient && pending.commandScope && pending.commandScope !== client.commandScope) continue; this.pendingHostCommands.delete(commandId); if (pending.kind === "server-response") { this.sendControl(pending.client, { type: "response.rejected", requestId: pending.responseRequestId ?? pending.requestId, code: terminalError.code, message: terminalError.message, retryable: true, }); continue; } const payload = { commandId, method: pending.method || null, ok: false, uncertain: true, retryable: true, error: terminalError, }; const event = this.recordEvent("command.result", payload, { sessionId }); if (pending.client) this.sendControl(pending.client, { type: "command.result", ...payload, seq: event.seq }); } // Host approval/input requests are owned by the adapter, so the relay does // not run a second expiry timer. Once the adapter reports its app process // unavailable, remove every request tied to this host immediately. for (const pending of [...this.pendingServerRequests.values()]) { if (pending.source !== "host" || pending.hostClient !== client) continue; this.clearPendingServerRequest(pending.appId); const isInput = pending.method === "item/tool/requestUserInput" || pending.method === "mcpServer/elicitation/request"; this.recordEvent(isInput ? "input.expired" : "approval.expired", { requestId: pending.appId, method: pending.method, reason: terminalError.message, error: terminalError, }, { sessionId }); } } dispatchCommand(client, message) { const commandId = String(message.commandId || ""); const method = String(message.method || ""); if (!commandId || commandId.length > 128) { return this.commandRejected(client, commandId, "invalid_command_id", "commandId is required"); } if (!ALLOWED_METHODS.has(method)) { return this.commandRejected(client, commandId, "method_not_allowed", `Method ${method || "(empty)"} is not allowed`); } if (MUTATING_METHODS.has(method) && client.role !== "operator") { this.auditAction(client, method, { commandId }, "denied"); return this.commandRejected(client, commandId, "forbidden", "Operator token required"); } const cached = this.getCachedCommandResult(commandId); if (cached) { this.sendControl(client, { type: "command.result", ...cached, cached: true }); return { accepted: true, cached: true }; } for (const pending of this.pendingHostCommands.values()) { if (pending.commandId === commandId) { this.sendControl(client, { type: "command.accepted", commandId, method, duplicate: true }); return { accepted: true, duplicate: true }; } } for (const pending of this.pendingAppRequests.values()) { if (pending.commandId === commandId) { this.sendControl(client, { type: "command.accepted", commandId, method, duplicate: true }); return { accepted: true, duplicate: true }; } } if (method === "initialize") { if (!this.state.initialized) { return this.commandRejected(client, commandId, "app_initializing", "Codex is still initializing", true); } const result = { commandId, method, ok: true, result: this.mode === "host" ? { protocol: 1, mode: "host", hostConnected: this.state.hostConnected, sessionId: this.state.hostSessionId } : this.initializedResult, cachedAt: Date.now(), }; this.cacheCommandResult(commandId, result, this.mode === "host" ? this.hostCommandScope : "embedded"); this.sendControl(client, { type: "command.result", ...result, cached: true }); return { accepted: true, cached: true }; } if (!this.state.initialized) { return this.commandRejected(client, commandId, "app_not_ready", "Codex app-server is not ready", true); } if (!message.params || typeof message.params !== "object" || Array.isArray(message.params)) { return this.commandRejected(client, commandId, "invalid_params", "params must be an object"); } const validationError = this.validateCommand(method, message.params); if (validationError) return this.commandRejected(client, commandId, "invalid_params", validationError); // This MVP exposes one active Codex turn per relay session. Keeping the // check at the relay boundary prevents two browser operators from racing // a turn start or steering an outdated turn id. const turnStartPending = [...this.pendingAppRequests.values()].some((pending) => pending.method === "turn/start") || [...this.pendingHostCommands.values()].some((pending) => pending.method === "turn/start"); if (method === "turn/start" && (this.state.activeTurnId || turnStartPending)) { return this.commandRejected(client, commandId, "turn_active", "A Codex turn is already active", true); } if (method === "session/select" || method === "control/mode/set") { const pendingMethod = method === "session/select" ? "session/select" : "control/mode/set"; const sessionSwitchPending = [...this.pendingHostCommands.values()].some((pending) => pending.method === pendingMethod) || [...this.pendingAppRequests.values()].some((pending) => pending.method === pendingMethod); if (sessionSwitchPending) { return this.commandRejected(client, commandId, method === "session/select" ? "session_switch_pending" : "mode_switch_pending", method === "session/select" ? "A session switch is already in progress" : "A control mode switch is already in progress", true); } if (this.state.activeTurnId || this.pendingServerRequests.size) { return this.commandRejected(client, commandId, method === "session/select" ? "session_busy" : "mode_busy", "The active session has a running turn or pending request", true); } } if (method === "turn/steer" && this.state.activeTurnId && message.params.expectedTurnId !== this.state.activeTurnId) { return this.commandRejected(client, commandId, "stale_turn", "expectedTurnId does not match the active turn", true); } if (method === "turn/interrupt" && this.state.activeTurnId && message.params.turnId !== this.state.activeTurnId) { return this.commandRejected(client, commandId, "stale_turn", "turnId does not match the active turn", true); } // A connected VS Code bridge is the source of truth for the session. The // relay never runs a second app-server request for the same command. if (this.hostClient && this.hostClient.socket.readyState === WebSocket.OPEN) { const hostFrame = { v: 1, kind: "command", type: method, commandId, sessionId: this.hostClient.sessionId || undefined, actor: { id: client.id || "web", role: client.role }, payload: message.params, }; this.pendingHostCommands.set(commandId, { commandId, method, client, hostClient: this.hostClient, commandScope: this.hostCommandScope || this.hostClient.commandScope || null, createdAt: Date.now(), }); try { this.hostClient.socket.send(JSON.stringify(hostFrame)); this.auditAction(client, method, { commandId, params: message.params, target: "vscode-host" }, "forwarded"); this.sendControl(client, { type: "command.accepted", commandId, method, target: "vscode-host" }); return { accepted: true, commandId, method, target: "vscode-host" }; } catch (error) { this.pendingHostCommands.delete(commandId); return this.commandRejected(client, commandId, "host_unavailable", error.message, true); } } const appId = this.nextAppRequestId("command"); try { this.pendingAppRequests.set(jsonRpcIdKey(appId), { kind: "command", commandId, method, client, createdAt: Date.now(), }); this.sendToApp({ method, id: appId, params: message.params }); this.auditAction(client, method, { commandId, params: message.params }, "forwarded"); this.sendControl(client, { type: "command.accepted", commandId, method }); return { accepted: true, commandId, method }; } catch (error) { this.pendingAppRequests.delete(jsonRpcIdKey(appId)); return this.commandRejected(client, commandId, error.code || "app_offline", error.message, error.retryable); } } validateCommand(method, params) { if (method === "control/mode/get") { if (Object.keys(params).length > 0) return "control/mode/get does not accept parameters"; } if (method === "control/mode/set") { if (params.mode !== "sync" && params.mode !== "async") return "mode must be sync or async"; } if (method === "session/list") { if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1 || params.limit > 100)) { return "limit must be an integer between 1 and 100"; } } if (method === "session/select") { const threadId = params.threadId ?? params.conversationId; if (typeof threadId !== "string" || !threadId.trim()) return "threadId is required"; if (threadId.length > 256) return "threadId is too long"; } if (method === "thread/start") { const allowedSandboxes = new Set(["read-only", "workspace-write", "danger-full-access"]); if (params.sandbox != null && !allowedSandboxes.has(params.sandbox)) { return "sandbox must be read-only, workspace-write, or danger-full-access"; } if (params.cwd != null && typeof params.cwd !== "string") return "cwd must be a string"; } if (method === "turn/start") { if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required"; if (!Array.isArray(params.input) || params.input.length === 0) return "input must be a non-empty array"; } if (method === "thread/settings/update") { if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required"; const settings = params.threadSettings ?? params.settings; if (!settings || typeof settings !== "object" || Array.isArray(settings)) return "threadSettings must be an object"; if (settings.model !== undefined && typeof settings.model !== "string") return "threadSettings.model must be a string"; // `null` is the official value for clearing a model's reasoning effort // (some models do not expose a selectable effort). Preserve it through // the relay instead of rejecting a valid next-turn update. if (settings.effort !== undefined && settings.effort !== null && typeof settings.effort !== "string") return "threadSettings.effort must be a string or null"; for (const key of ["sandboxPolicy", "approvalPolicy"]) { const value = settings[key]; if (value !== undefined && value !== null && typeof value !== "string" && (typeof value !== "object" || Array.isArray(value))) { return `threadSettings.${key} must be a string, object, or null`; } } if (settings.approvalsReviewer !== undefined && settings.approvalsReviewer !== null && typeof settings.approvalsReviewer !== "string") { return "threadSettings.approvalsReviewer must be a string or null"; } if (settings.runtimeWorkspaceRoots !== undefined && settings.runtimeWorkspaceRoots !== null && (!Array.isArray(settings.runtimeWorkspaceRoots) || !settings.runtimeWorkspaceRoots.every((entry) => typeof entry === "string"))) { return "threadSettings.runtimeWorkspaceRoots must be an array of strings or null"; } if (settings.permissions !== undefined && settings.permissions !== null && (typeof settings.permissions !== "string" && (typeof settings.permissions !== "object" || Array.isArray(settings.permissions)))) { return "threadSettings.permissions must be a string, object, or null"; } } if (method === "turn/steer") { if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required"; if (typeof params.expectedTurnId !== "string" || !params.expectedTurnId) return "expectedTurnId is required"; if (!Array.isArray(params.input) || params.input.length === 0) return "input must be a non-empty array"; } if (method === "turn/interrupt") { if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required"; if (typeof params.turnId !== "string" || !params.turnId) return "turnId is required"; } return null; } commandRejected(client, commandId, code, message, retryable = false) { const payload = { type: "command.rejected", commandId: commandId || null, code, message, retryable: Boolean(retryable) }; this.sendControl(client, payload); return { accepted: false, ...payload }; } scheduleServerRequestExpiry(requestId, pending) { if (this.approvalTimeoutMs <= 0) return; pending.timer = setTimeout(() => this.expireServerRequest(requestId), this.approvalTimeoutMs); pending.timer.unref?.(); } expireServerRequest(requestId) { const pending = this.clearPendingServerRequest(requestId); if (!pending) return; const canonicalRequestId = pending.appId; const isInput = pending.method === "item/tool/requestUserInput" || pending.method === "mcpServer/elicitation/request"; const reason = "Remote approval timed out"; this.recordEvent(isInput ? "input.expired" : "approval.expired", { requestId: canonicalRequestId, method: pending.method, reason, }, { sessionId: pending.hostClient?.sessionId || undefined }); this.auditAction({ id: "relay", role: "system" }, pending.method, { requestId: canonicalRequestId }, "expired"); const result = defaultServerResponse(pending.method, reason); if (pending.source === "host") { if (!pending.hostClient || pending.hostClient.socket.readyState !== WebSocket.OPEN) return; const commandMethod = isInput ? "server.request.respond" : "approval.respond"; try { pending.hostClient.socket.send(JSON.stringify({ v: 1, kind: "command", type: commandMethod, commandId: randomId("timeout"), sessionId: pending.hostClient.sessionId || undefined, actor: { id: "relay", role: "system" }, payload: { requestId: pending.appId, decision: "deny", response: result, reason, }, })); } catch { // The local adapter also has its own expiry deny; no retry is needed. } return; } try { this.sendToApp({ id: pending.appId, result }); } catch { // The process may have exited while the approval was pending. } } clearPendingServerRequest(requestId) { const key = jsonRpcIdKey(requestId); const pending = this.pendingServerRequests.get(key); if (pending?.timer) clearTimeout(pending.timer); this.pendingServerRequests.delete(key); return pending; } dispatchServerResponse(client, message) { const requestId = message.requestId ?? message.id ?? ""; const requestKey = findTypedMapKey(this.pendingServerRequests, requestId, (value) => value?.appId, true); if (client.role !== "operator") { this.auditAction(client, "server-response", { requestId }, "denied"); this.sendControl(client, { type: "response.rejected", requestId, code: "forbidden", message: "Operator token required" }); return { accepted: false, code: "forbidden" }; } const pending = this.pendingServerRequests.get(requestKey); if (!pending) { this.sendControl(client, { type: "response.rejected", requestId, code: "unknown_request", message: "Request is no longer pending" }); return { accepted: false, code: "unknown_request" }; } if (!("result" in message) && !("error" in message)) { this.sendControl(client, { type: "response.rejected", requestId, code: "invalid_response", message: "result or error is required" }); return { accepted: false, code: "invalid_response" }; } const remoteResponseAllowed = SERVER_REQUEST_METHODS.has(pending.method) || pending.method === "item/tool/requestUserInput" || pending.method === "mcpServer/elicitation/request"; if (!remoteResponseAllowed) { this.sendControl(client, { type: "response.rejected", requestId, code: "unsupported_request", message: "This app-server request must be handled by the host", }); return { accepted: false, code: "unsupported_request" }; } const normalizedResult = Object.prototype.hasOwnProperty.call(message, "result") ? normalizeServerResponseForApp(pending.method, message.result) : undefined; if (Object.prototype.hasOwnProperty.call(message, "result") && !isValidApprovalResponse(pending.method, normalizedResult)) { this.sendControl(client, { type: "response.rejected", requestId, code: "invalid_response", message: "Unsupported or malformed approval decision", }); return { accepted: false, code: "invalid_response" }; } if (Object.prototype.hasOwnProperty.call(message, "requestedDecision") && (pending.method === "item/commandExecution/requestApproval" || pending.method === "item/fileChange/requestApproval" || pending.method === "applyPatchApproval" || pending.method === "execCommandApproval")) { const requested = approvalDecisionKind(message.requestedDecision); const actual = approvalDecisionForResult(normalizedResult); if (!requested || requested !== actual) { this.sendControl(client, { type: "response.rejected", requestId, code: "decision_mismatch", message: "Outer approval decision does not match the response", }); return { accepted: false, code: "decision_mismatch" }; } } // Host-proxy mode uses the bridge's normalized command contract. Keep the // original app-server request id in the payload, but do not forward an // arbitrary JSON-RPC response as a relay command. if (pending.source === "host") { const result = normalizedResult; const error = Object.prototype.hasOwnProperty.call(message, "error") ? message.error : undefined; const method = pending.method || ""; const isInput = method === "item/tool/requestUserInput" || method === "mcpServer/elicitation/request"; const commandMethod = isInput ? "server.request.respond" : "approval.respond"; const decision = error ? "deny" : isInput ? "allow" : approvalDecisionForResult(result); // Generate the host command from the canonical app-server id. This // preserves the legacy `response-77` shape when a browser merely // stringified a numeric id, while still suffixing ids when both typed // variants are pending concurrently. const commandId = responseCommandId(pending.appId, this.pendingServerRequests, this.pendingHostCommands); const hostFrame = { v: 1, kind: "command", type: commandMethod, commandId, sessionId: pending.hostClient?.sessionId || undefined, actor: { id: client.id || "web", role: client.role }, payload: { requestId: pending.appId, decision, ...(pending.commandHash ? { commandHash: pending.commandHash } : {}), ...(result !== undefined ? { response: result } : {}), ...(error !== undefined ? { reason: responseErrorMessage(error) } : {}), }, }; const existing = this.pendingHostCommands.get(commandId); if (existing?.kind === "server-response") { this.sendControl(client, { type: "response.pending", requestId, commandId }); return { accepted: true, pending: true, requestId, commandId }; } if (!pending.hostClient || pending.hostClient.socket.readyState !== WebSocket.OPEN) { this.clearPendingServerRequest(pending.appId); this.sendControl(client, { type: "response.rejected", requestId, code: "host_unavailable", message: "VS Code host is disconnected" }); return { accepted: false, code: "host_unavailable" }; } try { this.pendingHostCommands.set(commandId, { kind: "server-response", commandId, // Keep the original app-server id for exact map cleanup. The // browser-facing id may be a legacy stringified form of that id. requestId: pending.appId, responseRequestId: requestId, method: pending.method, client, commandScope: this.hostCommandScope || pending.hostClient?.commandScope || null, createdAt: Date.now(), }); pending.hostClient.socket.send(JSON.stringify(hostFrame)); this.auditAction(client, pending.method, { requestId, target: "vscode-host", result, error }, "forwarded"); this.sendControl(client, { type: "response.pending", requestId, commandId }); return { accepted: true, pending: true, requestId, commandId }; } catch (sendError) { this.pendingHostCommands.delete(commandId); this.sendControl(client, { type: "response.rejected", requestId, code: "host_unavailable", message: sendError.message, retryable: true }); return { accepted: false, code: "host_unavailable" }; } } const appMessage = { id: pending.appId }; if ("result" in message) appMessage.result = normalizedResult; else appMessage.error = message.error; try { this.sendToApp(appMessage); this.clearPendingServerRequest(pending.appId); this.auditAction(client, pending.method, { requestId, result: message.result, error: message.error }, "responded"); const event = this.recordEvent("server.responded", { requestId, method: pending.method, ok: !message.error, }); this.sendControl(client, { type: "response.accepted", requestId, seq: event.seq }); return { accepted: true, requestId }; } catch (error) { this.sendControl(client, { type: "response.rejected", requestId, ...normalizeError(error) }); return { accepted: false, ...normalizeError(error) }; } } cacheCommandResult(commandId, payload, sessionScope) { const key = String(commandId); const scope = this.mode === "host" ? (sessionScope || this.hostCommandScope || null) : "embedded"; this.commandResults.set(key, { ...payload, cachedAt: Date.now() }); this.commandResultScopes.set(key, scope); this.pruneCommandResults(); } getCachedCommandResult(commandId) { const key = String(commandId); const cached = this.commandResults.get(key); if (!cached) return null; // A disconnect leaves the outcome unknown. Never replay that marker as a // completed result; remove it so a retry can be forwarded to a reconnected // host (or receive the normal offline error). if (cached.uncertain) { this.commandResults.delete(key); this.commandResultScopes.delete(key); return null; } const activeScope = this.mode === "host" ? (this.hostClient && this.state.hostConnected ? this.hostCommandScope : null) : "embedded"; // Host results are never replayed while disconnected. This also prevents // an old result from leaking across a session-id change. if (!activeScope || this.commandResultScopes.get(key) !== activeScope) return null; return cached; } clearCommandResults() { this.commandResults.clear(); this.commandResultScopes.clear(); } pruneCommandResults() { const cutoff = Date.now() - 15 * 60 * 1000; for (const [key, value] of this.commandResults) { if (value.cachedAt < cutoff) { this.commandResults.delete(key); this.commandResultScopes.delete(key); } } while (this.commandResults.size > 1_000) { const [firstKey] = this.commandResults.keys(); this.commandResults.delete(firstKey); this.commandResultScopes.delete(firstKey); } } sendControl(client, message) { if (client.capture) client.capture.push(message); if (!client.socket || client.socket.readyState !== WebSocket.OPEN) return; const serialized = JSON.stringify(message); const frameBytes = Buffer.byteLength(serialized, "utf8"); if (frameBytes > MAX_WS_PAYLOAD) { client.socket.close(1009, "relay frame is too large"); return; } const bufferedBytes = Number(client.socket.bufferedAmount) || 0; if (bufferedBytes + frameBytes > this.clientBufferedByteLimit) { client.socket.close(1013, "client is too slow"); return; } client.socket.send(serialized); } snapshot() { // These projections used to be serialized both inside `state` and again // at the top level, nearly doubling every long-history control frame. // Keep the compact lifecycle state nested and one authoritative transcript // projection at the stable top-level protocol fields. const { outputTail, messages, subagents, sessionMetadata, executionStatus, ...state } = this.state; return { protocol: 1, latestSeq: this.nextSeq, state, clients: [...this.clients] .filter((client) => client.authenticated) .map((client) => ({ id: client.id, role: client.role })), pendingRequests: [...this.pendingServerRequests.values()].map((request) => ({ requestId: request.appId, method: request.method, params: request.params, ...(request.commandHash ? { commandHash: request.commandHash } : {}), ...(request.risk ? { risk: request.risk } : {}), ...(request.summary ? { summary: request.summary } : {}), ...(request.expiresAt ? { expiresAt: request.expiresAt } : {}), createdAt: request.createdAt, })), outputTail: outputTail || "", messages: Array.isArray(messages) ? messages : [], subagents: Array.isArray(subagents) ? subagents : [], ...(sessionMetadata ? { metadata: sessionMetadata } : {}), status: executionStatus, executionStatus, }; } tokenFromRequest(request) { const authorization = request.headers.authorization || ""; if (/^Bearer\s+/i.test(authorization)) return authorization.replace(/^Bearer\s+/i, ""); return request.headers["x-codex-token"] || ""; } authenticateHttp(request) { const role = this.roleForToken(this.tokenFromRequest(request)); if (role) return role; if (!this.authRequired && isLoopbackAddress(request.socket?.remoteAddress)) return "operator"; return null; } async handleHttp(request, response) { const base = `http://${request.headers.host || "localhost"}`; let requestUrl; try { requestUrl = new URL(request.url, base); } catch { jsonResponse(response, 400, { error: "invalid_url" }); return; } if (!this.authRequired && !isLoopbackRequestHost(request)) { jsonResponse(response, 403, { error: "loopback_host_required" }); return; } if (request.method === "GET" && requestUrl.pathname === "/api/health") { jsonResponse(response, this.state.app === "offline" ? 503 : 200, { ok: this.state.app !== "offline", app: this.state.app, initialized: this.state.initialized, authRequired: this.authRequired, latestSeq: this.nextSeq, }); return; } if (requestUrl.pathname.startsWith("/api/")) { const role = this.authenticateHttp(request); if (!role) { jsonResponse(response, 401, { error: "unauthorized" }, { "WWW-Authenticate": "Bearer" }); return; } if (request.method === "POST" && !isAllowedHttpOrigin(request)) { jsonResponse(response, 403, { error: "origin_not_allowed" }); return; } if (request.method === "GET" && requestUrl.pathname === "/api/state") { jsonResponse(response, 200, { role, ...this.snapshot(), audit: this.audit.slice(-50) }); return; } if (request.method === "GET" && requestUrl.pathname === "/api/events") { const fromSeq = Number(requestUrl.searchParams.get("fromSeq") || 0); jsonResponse(response, 200, { latestSeq: this.nextSeq, events: this.events.filter((event) => event.seq > fromSeq), }); return; } if (request.method === "POST" && requestUrl.pathname === "/api/command") { try { const body = await readJson(request); const client = { id: "http", role, authenticated: true, capture: [] }; const result = this.dispatchCommand(client, { type: "command", ...body }); jsonResponse(response, result.accepted ? 202 : 400, { ...result, messages: client.capture }); } catch (error) { jsonResponse(response, error.statusCode || 400, { error: normalizeError(error) }); } return; } if (request.method === "POST" && requestUrl.pathname === "/api/respond") { try { const body = await readJson(request); const client = { id: "http", role, authenticated: true, capture: [] }; const result = this.dispatchServerResponse(client, { type: "respond", ...body }); jsonResponse(response, result.accepted ? 202 : 400, { ...result, messages: client.capture }); } catch (error) { jsonResponse(response, error.statusCode || 400, { error: normalizeError(error) }); } return; } jsonResponse(response, 404, { error: "not_found" }); return; } this.serveStatic(request, response, requestUrl.pathname); } serveStatic(request, response, pathname) { if (request.method !== "GET" && request.method !== "HEAD") { response.writeHead(405, { Allow: "GET, HEAD" }); response.end(); return; } let relativePath; try { relativePath = pathname === "/" ? "index.html" : decodeURIComponent(pathname).replace(/^\/+/, ""); } catch { // A malformed percent escape must be an ordinary client error, not an // uncaught exception from the HTTP request handler. response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }); response.end("Invalid URL"); return; } const filePath = path.resolve(PUBLIC_ROOT, relativePath); if (!filePath.startsWith(`${PUBLIC_ROOT}${path.sep}`) && filePath !== path.join(PUBLIC_ROOT, "index.html")) { response.writeHead(403); response.end("Forbidden"); return; } fs.stat(filePath, (error, stat) => { if (error || !stat.isFile()) { response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); response.end("Not found"); return; } response.writeHead(200, { "Content-Type": contentType(filePath), "Content-Length": stat.size, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'self'", }); if (request.method === "HEAD") response.end(); else fs.createReadStream(filePath).pipe(response); }); } } function isObjectPayload(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function firstObject(...values) { return values.find((value) => isObjectPayload(value)) || null; } function commandMethodFromFrame(frame) { const nested = isObjectPayload(frame.command) ? frame.command : null; if (typeof frame.method === "string" && frame.method.trim()) return frame.method.trim(); if (typeof nested?.type === "string" && nested.type.trim()) return nested.type.trim(); if (typeof frame.kind === "string" && (frame.kind === "command" || frame.kind === "response") && typeof frame.type === "string") { if (frame.type !== "command" && frame.type !== "respond" && frame.type !== "server-response") return frame.type.trim(); } if (typeof frame.type === "string" && frame.type !== "command" && frame.type !== "respond" && frame.type !== "server-response") { return frame.type.trim(); } return ""; } function normalizeWireMethod(method) { const value = String(method || "").trim(); const aliases = { "control.mode.get": "control/mode/get", "controlmode.get": "control/mode/get", "mode.get": "control/mode/get", "control.mode.set": "control/mode/set", "controlmode.set": "control/mode/set", "mode.set": "control/mode/set", "thread.start": "thread/start", "session.new": "session/new", "sessionnew": "session/new", "thread.new": "session/new", "threadnew": "session/new", "session/new": "session/new", "thread/new": "session/new", "thread.settings.update": "thread/settings/update", "threadsettings.update": "thread/settings/update", "session.list": "session/list", "thread.list": "session/list", "session.select": "session/select", "session.switch": "session/select", "thread.select": "session/select", "thread.attach": "session/select", "turn.start": "turn/start", "turn.steer": "turn/steer", "turn.interrupt": "turn/interrupt", }; return aliases[value.toLowerCase()] || value; } function commandIdFromFrame(frame) { const nested = isObjectPayload(frame.command) ? frame.command : null; const value = frame.commandId ?? nested?.commandId ?? (typeof frame.id === "string" ? frame.id : undefined); return value === undefined || value === null ? "" : String(value); } function normalizeBrowserCommand(frame) { const method = normalizeWireMethod(commandMethodFromFrame(frame)); const type = frame.type; const hasCommandEnvelope = frame.kind === "command" || type === "command" || (frame.kind === undefined && (ALLOWED_METHODS.has(method) || REMOTE_RESPONSE_METHODS.has(method))); if (!hasCommandEnvelope || !method) return null; const nested = isObjectPayload(frame.command) ? frame.command : null; const params = firstObject(frame.params, frame.payload, nested?.params, nested?.payload) || {}; return { type: "command", commandId: commandIdFromFrame(frame), method, params, }; } function approvalWireDecision(decision) { // Keep the caller's decision intact until dispatch knows the target // app-server method. Legacy approval methods use `approved`/`abort`, while // v2 methods use `accept`/`cancel`; method-aware normalization handles the // conversion without discarding amendment tags. return decision; } function normalizeBrowserResponse(frame, hintedMethod) { const type = typeof frame.type === "string" ? frame.type : ""; const method = hintedMethod || commandMethodFromFrame(frame); const isLegacy = type === "respond" || type === "server-response"; const isResponse = isLegacy || frame.kind === "response" || REMOTE_RESPONSE_METHODS.has(method); if (!isResponse) return null; const nested = isObjectPayload(frame.command) ? frame.command : null; const payload = firstObject(frame.payload, frame.params, nested?.payload, nested?.params) || (isLegacy ? {} : frame); const requestId = frame.requestId ?? payload.requestId ?? (typeof frame.id === "number" || typeof frame.id === "string" ? frame.id : ""); const response = { type: "respond", requestId }; if (payload.decision !== undefined) response.requestedDecision = payload.decision; if (Object.prototype.hasOwnProperty.call(frame, "result")) { response.result = frame.result; } else if (Object.prototype.hasOwnProperty.call(frame, "error")) { response.error = frame.error; } else if (Object.prototype.hasOwnProperty.call(payload, "result")) { response.result = payload.result; } else if (Object.prototype.hasOwnProperty.call(payload, "error")) { response.error = payload.error; } else if (payload.response !== undefined) { response.result = payload.response; } else if (method === "input.respond" || method === "server.request.respond") { if (payload.answers !== undefined) response.result = { answers: payload.answers }; else { const custom = {}; for (const [key, value] of Object.entries(payload)) { if (!["v", "kind", "type", "method", "commandId", "id", "sessionId", "actor", "requestId", "reason", "params", "payload", "command"].includes(key)) custom[key] = value; } if (Object.keys(custom).length) response.result = custom; } } else if (payload.decision !== undefined) { response.result = { decision: approvalWireDecision(payload.decision) }; } return response; } async function main() { const relay = new CodexRelay(); const address = await relay.start(); const displayHost = address.host === "::" || address.host === "0.0.0.0" ? "127.0.0.1" : address.host; process.stdout.write(`Codex Remote Collab: http://${displayHost}:${address.port}\n`); if (relay.authRequired) { process.stdout.write(`Host token: ${relay.hostToken}\n`); process.stdout.write(`Operator token: ${relay.operatorToken}\n`); process.stdout.write(`Viewer token: ${relay.viewerToken}\n`); process.stdout.write("Keep these tokens private. Use TLS before exposing this relay outside a trusted network.\n"); } else { process.stdout.write("Authentication: disabled for loopback connections (set CODEX_REMOTE_AUTH=required to enable tokens).\n"); } const shutdown = async () => { await relay.stop(); process.exit(0); }; process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); } if (require.main === module) { main().catch((error) => { process.stderr.write(`${error.stack || error}\n`); process.exitCode = 1; }); } module.exports = { ALLOWED_METHODS, CodexRelay, SERVER_REQUEST_METHODS, redact, };