Files
Aether/aether-vscodex/relay/server.js
T

2614 lines
115 KiB
JavaScript

"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,
};