Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
type FastWire,
type ProviderCostOverlay,
} from "./types";
import type { OcxRuntimeRole } from "./types/config";
import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
import { modelAutoCompactTokenLimitsConfigError } from "./providers/auto-compact-budget";
import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
Expand Down Expand Up @@ -861,8 +862,13 @@ const agentTaskRecoverySchema = z.object({
cacheEntries: z.number().int().min(1).max(512).optional(),
}).strict();

const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]);

const configSchema = z.object({
port: z.number().int().min(0).max(65535).default(10100),
// A malformed hand edit must disable only remote-role behavior, not discard
// providers or data-plane keys. Live writes are rejected explicitly below.
runtimeRole: runtimeRoleSchema.optional().catch(undefined),
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
// Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
upstreamHostCircuitThreshold: z.number().int()
Expand Down Expand Up @@ -1706,6 +1712,18 @@ function warnDegradedAgentTaskRecovery(rawParsed: unknown): void {
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
}

function malformedRuntimeRoleWarning(rawParsed: unknown): string | null {
const raw = rawConfigRecord(rawParsed);
if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null;
if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null;
return 'runtimeRole ignored: expected "standalone", "hub", or "client"; falling back to "standalone"';
}

function warnDegradedRuntimeRole(rawParsed: unknown): void {
const warning = malformedRuntimeRoleWarning(rawParsed);
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
}

type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";

function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
Expand Down Expand Up @@ -1859,6 +1877,7 @@ export function loadConfig(): OcxConfig {
warnDegradedCodexAccountPicker(parsed);
warnDegradedUpstreamHostCircuitThreshold(parsed);
warnDegradedAgentTaskRecovery(parsed);
warnDegradedRuntimeRole(parsed);
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
}
// Schema validation failed — merge defaults into the raw object instead of
Expand All @@ -1883,6 +1902,7 @@ export function loadConfig(): OcxConfig {
warnDegradedCodexAccountPicker(parsed);
warnDegradedUpstreamHostCircuitThreshold(parsed);
warnDegradedAgentTaskRecovery(parsed);
warnDegradedRuntimeRole(parsed);
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
}
// Still failing, but if every complaint is about one or more named entries
Expand All @@ -1903,6 +1923,7 @@ export function loadConfig(): OcxConfig {
warnDegradedCodexAccountPicker(parsed);
warnDegradedUpstreamHostCircuitThreshold(parsed);
warnDegradedAgentTaskRecovery(parsed);
warnDegradedRuntimeRole(parsed);
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
}
}
Expand Down Expand Up @@ -2003,6 +2024,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
if (hostCircuitWarning) warnings.push(hostCircuitWarning);
const recoveryWarning = malformedAgentTaskRecoveryWarning(rawParsed);
if (recoveryWarning) warnings.push(recoveryWarning);
const runtimeRoleWarning = malformedRuntimeRoleWarning(rawParsed);
if (runtimeRoleWarning) warnings.push(runtimeRoleWarning);
if (syncDisabledReason) {
warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
}
Expand Down Expand Up @@ -2094,6 +2117,13 @@ function agentTaskRecoveryError(value: unknown): string | null {
return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
}

function runtimeRoleError(value: unknown): string | null {
const raw = rawConfigRecord(value);
if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null;
if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null;
return 'schema_invalid: runtimeRole: must be one of "standalone", "hub", or "client"';
}

/**
* Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
* malformed selection-order map to undefined, which on a write would drop every entry the
Expand Down Expand Up @@ -2211,6 +2241,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
?? codexAccountPickerEnabledError(value)
?? emptyCompletionRetryError(value)
?? oauthOpenBrowserError(value)
?? runtimeRoleError(value)
?? loopbackListenerPortError(value);
if (boundaryError) return { ok: false, error: boundaryError };
const result = configSchema.safeParse(value);
Expand Down Expand Up @@ -3123,6 +3154,10 @@ export function multiAgentGuidanceEnabled(
return config.multiAgentGuidanceEnabled !== false;
}

export function runtimeRole(config: Pick<OcxConfig, "runtimeRole">): OcxRuntimeRole {
return config.runtimeRole ?? "standalone";
}

export function getDefaultConfig(): OcxConfig {
// Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
// gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
Expand Down
98 changes: 98 additions & 0 deletions src/remote/protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { OcxConfig } from "../types/config";

export const REMOTE_HUB_PROTOCOL = 1;
export const MINIMUM_REMOTE_CLIENT_PROTOCOL = 1;

export interface RemoteReadyMetadata {
protocol: number;
minimumClientProtocol: number;
managementUrl: string;
}

export type RemoteProtocolCompatibility =
| { ok: true; metadata: RemoteReadyMetadata }
| { ok: false; reason: "invalid" | "hub-too-new" | "hub-too-old"; message: string };

const INVALID_REMOTE_PROTOCOL_MESSAGE =
"OpenCodex hub returned invalid remote protocol metadata; upgrade or repair ocx on the hub.";

function positiveSafeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
}

function managementOrigin(value: unknown): string | null {
if (typeof value !== "string") return null;
try {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
return parsed.origin;
} catch {
return null;
}
}

function observedManagementOrigin(req: Request): string | null {
try {
const requestUrl = new URL(req.url);
const host = req.headers.get("Host") ?? requestUrl.host;
return managementOrigin(`${requestUrl.protocol}//${host}`);
} catch {
return null;
}
}

export function readyProtocolMetadata(config: OcxConfig, req: Request): RemoteReadyMetadata {
// Phase 2 will consult config.hub.managementPublicOrigin here. Keeping the
// parameter now fixes the consumer signature without changing Phase 1 behavior.
void config;
const managementUrl = observedManagementOrigin(req);
if (!managementUrl) throw new Error("Readiness request does not have an HTTP(S) management origin");
return {
protocol: REMOTE_HUB_PROTOCOL,
minimumClientProtocol: MINIMUM_REMOTE_CLIENT_PROTOCOL,
managementUrl,
};
}

export function parseRemoteReadyMetadata(value: unknown): RemoteReadyMetadata | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
if (!positiveSafeInteger(raw.protocol) || !positiveSafeInteger(raw.minimumClientProtocol)) return null;
if (raw.minimumClientProtocol > raw.protocol) return null;
const parsedManagementOrigin = managementOrigin(raw.managementUrl);
if (!parsedManagementOrigin) return null;
return {
protocol: raw.protocol,
minimumClientProtocol: raw.minimumClientProtocol,
managementUrl: parsedManagementOrigin,
};
}

export function checkRemoteProtocolCompatibility(
value: unknown,
client: { protocol: number; minimumHubProtocol: number } = {
protocol: REMOTE_HUB_PROTOCOL,
minimumHubProtocol: MINIMUM_REMOTE_CLIENT_PROTOCOL,
},
): RemoteProtocolCompatibility {
const metadata = parseRemoteReadyMetadata(value);
if (!metadata || !positiveSafeInteger(client.protocol) || !positiveSafeInteger(client.minimumHubProtocol)) {
return { ok: false, reason: "invalid", message: INVALID_REMOTE_PROTOCOL_MESSAGE };
}
if (client.protocol < metadata.minimumClientProtocol) {
return {
ok: false,
reason: "hub-too-new",
message: `OpenCodex hub requires remote protocol ${metadata.minimumClientProtocol}; this client supports protocol ${client.protocol}. Upgrade ocx on this client.`,
};
}
if (metadata.protocol < client.minimumHubProtocol) {
return {
ok: false,
reason: "hub-too-old",
message: `OpenCodex hub provides remote protocol ${metadata.protocol}; this client requires at least ${client.minimumHubProtocol}. Upgrade ocx on the hub.`,
};
}
return { ok: true, metadata };
}
65 changes: 53 additions & 12 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ import {
resolveApiAuth,
resolveResponsesApiAuth,
requestPolicyView,
type DataPlaneAdmission,
type RequestPolicyView,
safeConfigDTO,
setCorsOrigin,
Expand Down Expand Up @@ -210,9 +211,37 @@ import {
type PackageTreeIntegrityGuard,
} from "../lib/package-tree-integrity";
import { detectInstall } from "../update/index";
import { readyProtocolMetadata } from "../remote/protocol";

export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;

// Header-safe by construction: a key id reaches a response header, so anything outside this
// class could inject a header break or a control character into a response we control.
const REMOTE_CATALOG_KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;

/**
* Name WHICH configured credential was admitted, so a multi-key operator can attribute a
* catalog read.
*
* Scoped to configured keys on purpose: an environment token or a loopback bind has no key
* to name, and emitting one anyway would invent an attribution that does not exist. 200 only
* — this route emits no validator and therefore never answers 304.
*
* An id that fails the header-safe pattern is omitted rather than sanitized, with one warning
* that does NOT repeat the id: logging the offending value is how a malformed id becomes a
* log-injection vector instead of a dropped header.
*/
function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmission): Response {
if (response.status !== 200 || admission.kind !== "configured") return response;
if (!REMOTE_CATALOG_KEY_ID_PATTERN.test(admission.keyId)) {
console.warn("[remote-catalog] configured API key id is not header-safe; omitting x-opencodex-key-id");
return response;
}
response.headers.set("x-opencodex-key-id", admission.keyId);
return response;
}

const LIVE_SIDEBAND_PENDING_MAX = 32;
const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024;
const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000;
Expand Down Expand Up @@ -1041,6 +1070,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
pid: process.pid,
port: boundPort ?? listenPort,
status,
...readyProtocolMetadata(config, req),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the expanded readiness response

Update the readiness documentation alongside this response change: docs-site/src/content/docs/reference/cli/lifecycle.md:158-160 still describes the sanitized identity as exactly {service, version, uptime, pid, port, status}, and the translated lifecycle pages repeat that obsolete shape. Operators and API consumers therefore cannot discover the new protocol-negotiation fields from the public docs; document protocol, minimumClientProtocol, and managementUrl in the English source and synchronize the locales.

AGENTS.md reference: src/AGENTS.md:L28-L28

Useful? React with 👍 / 👎.

};
if (status === "ready") {
return jsonResponse(body, 200, req, policy);
Expand Down Expand Up @@ -1121,23 +1151,34 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
}
const headers: Record<string, string> = {
"content-type": "application/json",
// Identity-varying content behind a credential: never let a shared cache keep it.
"cache-control": "private, no-cache",
// Identity-varying content behind a credential: never let a shared cache keep it,
// and never hand out a validator it could revalidate with. `no-cache` alone does
// not prevent storage — it forces revalidation, and the revalidation is exactly
// what would cross identities here, because this body varies by key type and key
// id while the ETag would be derived from bytes alone. A store keyed on URL plus
// validator could then serve one credential's representation to another. Proving
// an identity-partitioned cache key across every intermediary in the path is a
// much larger commitment than the bandwidth a 304 saves on this payload, so this
// route declines the trade: no-store, no ETag, no 304.
//
// GET /api/catalog keeps its validator. That route is management-authenticated
// and loopback-scoped, and its representation does not vary by data-key identity.
"cache-control": "no-store",
};
if (serialized.etag) headers.ETag = serialized.etag;
const version = await persistedCodexVersion();
if (version) headers["x-opencodex-codex-version"] = version;
// Conditional GET: a client that already holds these bytes re-validates cheaply.
const ifNoneMatch = req.headers.get("if-none-match")?.trim();
if (serialized.etag && ifNoneMatch && ifNoneMatch === serialized.etag) {
return withCors(new Response(null, { status: 304, headers }), req, policy);
}
// No conditional handling: with no validator emitted, an If-None-Match on this route
// can only have been guessed or copied from elsewhere, and honoring it would
// reintroduce the cross-identity path above. Every request gets the full body.
if (serialized.bytes !== undefined) headers["content-length"] = String(serialized.bytes);
// HEAD returns identical status and headers with no body.
return withCors(
new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }),
req,
policy,
return withRemoteCatalogKeyId(
withCors(
new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }),
req,
policy,
),
admission,
);
}

Expand Down
6 changes: 6 additions & 0 deletions src/server/proxy-liveness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,12 @@ interface ReadyzBody {
pid?: unknown;
port?: unknown;
status?: unknown;
// Remote protocol metadata is intentionally additive here. Ordinary
// readiness remains compatible with legacy standalone servers; `ocx connect`
// validates these fields separately in src/remote/protocol.ts.
protocol?: unknown;
minimumClientProtocol?: unknown;
managementUrl?: unknown;
}

export interface ReadinessProbeResult {
Expand Down
4 changes: 4 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,12 @@ export interface OcxConfigRebaseProvenance {
deletedTopLevelKeys: string[];
}

export type OcxRuntimeRole = "standalone" | "hub" | "client";

export interface OcxConfig {
port: number;
/** Runtime topology role. Absence preserves the historical standalone behavior. */
runtimeRole?: OcxRuntimeRole;
/** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */
emptyCompletionRetry?: boolean;
/**
Expand Down
Loading
Loading