diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 64d62ce8a..7962c0667 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -10,14 +10,13 @@ import { accessSync, constants, existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntimePort, resolveEnvValue } from "../config"; -import { findLiveProxy } from "../server/proxy-liveness"; -import { gracefulStopHost } from "../lib/process-control"; +import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, resolveEnvValue } from "../config"; +import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; import { BUN_RUNTIME_SOURCES } from "../lib/bun-runtime"; import type { BunRuntimeSource } from "../lib/bun-runtime"; import { maskAccountId } from "../lib/privacy"; import { PROXY_ENV_KEYS, proxyEnvPresent } from "../lib/proxy-env"; -import { configuredAdminToken } from "../lib/admin-secrets"; +import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability"; import { readCodexTokens } from "../codex/auth-collision"; import { withNativeMainSharedClaim } from "../codex/native-main-claim"; import { probeNativeProfileRecoveryState, resolveNativeProfileContext } from "../codex/native-profile-store"; @@ -42,6 +41,10 @@ import { } from "../codex/runtime"; import { CODEX_REAUTH_ACTION, collectOAuthHealthEntriesForCli, MASKED_ACCOUNT_FALLBACK, type OAuthHealthEntry } from "../oauth/health"; import { getAuthRefreshIntentLockPath, getAuthStorePath } from "../oauth/store"; +import { + fetchBoundLocalManagementRead, + type LocalManagementReadDeps, +} from "../server/local-management-read-client"; export { resolveCodexHomeDir } from "../codex/home"; export type OAuthDoctorCheck = { level: "OK" | "WARN"; message: string }; @@ -610,20 +613,28 @@ function observedMemory(data: { rss: number; external?: number; arrayBuffers?: n } export async function fetchServiceMemory( - host: string, - port: number, - token: string | null, - fetchImpl: typeof fetch = fetch, + target: LiveProxy, + deps: LocalManagementReadDeps = {}, ): Promise { try { - const res = await fetchImpl(`http://${host}:${port}/api/system/memory`, { - headers: token ? { "x-opencodex-api-key": token } : {}, - signal: AbortSignal.timeout(SERVICE_MEMORY_TIMEOUT_MS), + const read = await fetchBoundLocalManagementRead(target, LOCAL_MANAGEMENT_READ_PATHS.systemMemory, { + ...deps, + timeoutMs: SERVICE_MEMORY_TIMEOUT_MS, }); + if (read.kind === "unavailable") { + return read.reason === "transport" + ? { status: "unreachable", error: "fetch failed" } + : { status: "unauthorized" }; + } + const { response: res, targetPid } = read; if (res.status === 401 || res.status === 403) return { status: "unauthorized" }; if (!res.ok) return { status: "unreachable", error: `http ${res.status}` }; const body = await res.json() as Partial; - if (typeof body.pid !== "number" || typeof body.bunVersion !== "string" || typeof body.rss !== "number") { + if ( + body.pid !== targetPid + || typeof body.bunVersion !== "string" + || typeof body.rss !== "number" + ) { return { status: "unreachable", error: "malformed response" }; } return { @@ -672,7 +683,7 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] const lines: string[] = []; lines.push(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`); if (report.status === "unauthorized") { - lines.push(" -- proxy reachable but rejected the request — set OPENCODEX_ADMIN_AUTH_TOKEN to match the service"); + lines.push(" -- local diagnostic capability unavailable — restart the running proxy with this OpenCodex version"); return lines; } if (report.status === "unreachable") { @@ -844,10 +855,6 @@ export async function runDoctor(args: string[] = []): Promise { const live = await findLiveProxy({ configFn: () => ({ port: doctorConfig.port, hostname: doctorConfig.hostname }), }); - const livePid = live ? live.pid : readPid(); - const liveRuntime = live - ? { pid: live.pid ?? 0, port: live.port, hostname: live.hostname } - : (livePid ? readRuntimePort(livePid) : null); const currentProxyEnv = collectProxyEnv(); const configuredProxy = collectConfiguredProxy(); @@ -887,13 +894,11 @@ export async function runDoctor(args: string[] = []): Promise { console.log("\nMemory / runtime"); { - const runtime = liveRuntime; - if (!runtime || !live) { + if (!live) { console.log(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`); console.log(" -- no running ocx proxy found (no live pid/runtime record)"); } else { - const token = configuredAdminToken(); - const report = await fetchServiceMemory(gracefulStopHost(runtime.hostname), runtime.port, token); + const report = await fetchServiceMemory(live); for (const line of formatServiceMemoryLines(report)) console.log(line); } } diff --git a/src/cli/status.ts b/src/cli/status.ts index 3d0f21086..784f0fc55 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -2,6 +2,7 @@ import { durableBunRuntime } from "../lib/bun-runtime"; import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics, readPid, readRuntimePort, type RuntimePortState } from "../config"; import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor"; import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; +import { directLocalHttpFetch } from "../server/direct-local-http"; import type { OcxConfig } from "../types"; import { diagnoseService, serviceLogPath } from "../service"; import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health"; @@ -115,7 +116,7 @@ async function checkProxyHealth(target: ListenTarget): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 800); try { - const response = await fetch(url, { signal: controller.signal }); + const response = await directLocalHttpFetch(url, { signal: controller.signal }); if (!response.ok) { const message = `returned HTTP ${response.status}`; return { ok: false, url, message, label: `${url} ${message}` }; @@ -130,7 +131,9 @@ async function checkProxyHealth(target: ListenTarget): Promise { const message = `ok${version}${uptime}`; return { ok: true, url, message, label: `${url} ${message}` }; } catch (error) { - const reason = error instanceof Error && error.name === "AbortError" ? "timed out" : "unreachable"; + const reason = controller.signal.aborted || (error instanceof Error && error.name === "AbortError") + ? "timed out" + : "unreachable"; return { ok: false, url, message: reason, label: `${url} ${reason}` }; } finally { clearTimeout(timer); diff --git a/src/lib/local-management-capability.ts b/src/lib/local-management-capability.ts new file mode 100644 index 000000000..2da0d0cc5 --- /dev/null +++ b/src/lib/local-management-capability.ts @@ -0,0 +1,100 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { isLocalAttestationSecret } from "./local-management-attestation"; + +export const LOCAL_MANAGEMENT_EXPECTED_PID_HEADER = "x-opencodex-local-expected-pid"; +export const LOCAL_MANAGEMENT_NONCE_HEADER = "x-opencodex-local-nonce"; +export const LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER = "x-opencodex-local-expires-at"; +export const LOCAL_MANAGEMENT_CAPABILITY_HEADER = "x-opencodex-local-capability"; +export const LOCAL_MANAGEMENT_CAPABILITY_TTL_MS = 10_000; + +export const LOCAL_MANAGEMENT_READ_PATHS = { + codexAccounts: "/api/codex-auth/accounts", + systemMemory: "/api/system/memory", +} as const; + +export type LocalManagementReadPath = + typeof LOCAL_MANAGEMENT_READ_PATHS[keyof typeof LOCAL_MANAGEMENT_READ_PATHS]; + +const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/; +const LOCAL_READ_METHOD = "GET"; + +export type ExpectedLocalManagementPid = + | { kind: "absent" } + | { kind: "invalid" } + | { kind: "present"; pid: number }; + +export function parseExpectedLocalManagementPid(value: string | null): ExpectedLocalManagementPid { + if (value === null) return { kind: "absent" }; + if (!/^[1-9]\d*$/.test(value)) return { kind: "invalid" }; + const pid = Number(value); + return Number.isSafeInteger(pid) ? { kind: "present", pid } : { kind: "invalid" }; +} + +function isLocalManagementReadPath(path: string): path is LocalManagementReadPath { + return path === LOCAL_MANAGEMENT_READ_PATHS.codexAccounts + || path === LOCAL_MANAGEMENT_READ_PATHS.systemMemory; +} + +function localReadCapabilityPayload( + nonce: string, + method: string, + path: string, + pid: number, + port: number, + expiresAt: number, +): string | null { + if (!BASE64URL_256.test(nonce)) return null; + if (method !== LOCAL_READ_METHOD || !isLocalManagementReadPath(path)) return null; + if (!Number.isSafeInteger(pid) || pid <= 0) return null; + if (!Number.isInteger(port) || port <= 0 || port > 65535) return null; + if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null; + return `opencodex-local-management-read-v1\n${nonce}\n${method}\n${path}\n${pid}\n${port}\n${expiresAt}`; +} + +/** Process-scoped authorization for one allowlisted local management GET. */ +export function createLocalManagementReadCapability( + secret: string, + nonce: string, + method: string, + path: string, + pid: number, + port: number, + expiresAt: number, +): string | null { + if (!isLocalAttestationSecret(secret)) return null; + const payload = localReadCapabilityPayload(nonce, method, path, pid, port, expiresAt); + if (!payload) return null; + return createHmac("sha256", secret).update(payload).digest("base64url"); +} + +export function verifyLocalManagementReadCapability( + secret: string, + nonce: string | null, + method: string, + path: string, + pid: number, + port: number, + expiresAt: number, + capability: string | null, + now = Date.now(), +): boolean { + if (!nonce || !capability || !BASE64URL_256.test(capability)) return false; + if ( + !Number.isSafeInteger(now) + || expiresAt <= now + || expiresAt > now + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS + ) return false; + const expected = createLocalManagementReadCapability( + secret, + nonce, + method, + path, + pid, + port, + expiresAt, + ); + if (!expected) return false; + const expectedBytes = Buffer.from(expected); + const actualBytes = Buffer.from(capability); + return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes); +} diff --git a/src/oauth/health.ts b/src/oauth/health.ts index 9b7da74e7..e0cf82556 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -3,16 +3,11 @@ import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing"; import { isAccountNeedsReauth } from "../codex/account-runtime-state"; import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; -import { configuredAdminToken } from "../lib/admin-secrets"; import { readRuntimePort } from "../config"; -import { - LOCAL_ATTESTATION_CHALLENGE_HEADER, - LOCAL_ATTESTATION_PROOF_HEADER, - createLocalAttestationChallenge, - verifyLocalAttestationProof, -} from "../lib/local-management-attestation"; +import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability"; import { maskAccountId } from "../lib/privacy"; -import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; +import { findLiveProxy } from "../server/proxy-liveness"; +import { fetchBoundLocalManagementRead } from "../server/local-management-read-client"; import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store"; import type { ProviderAccount } from "./types"; @@ -333,53 +328,22 @@ type LiveProxyCodexHealthResult = { }; async function fetchCodexHealthFromLiveProxy( - fetchImpl: typeof fetch = fetch, + fetchImpl: typeof fetch | undefined = undefined, findLiveProxyImpl: typeof findLiveProxy = findLiveProxy, readRuntimePortImpl: typeof readRuntimePort = readRuntimePort, ): Promise { const live = await findLiveProxyImpl(); if (!live) return { source: "unavailable", entries: null }; - // This is a management-plane endpoint. A data-plane service token is intentionally not - // interchangeable with the admin credential even on loopback. - const token = configuredAdminToken(); - const headers: Record = {}; try { - if (token) { - // Public /healthz identity is intentionally forgeable enough for liveness, not - // strong enough to receive a bearer. Prove the listener knows the per-process - // secret stored in the protected runtime record before attaching the admin token. - if (live.source !== "runtime" || live.pid === null) { - return { source: "management-api-unavailable", entries: null }; - } - const attestedPid = live.pid; - const runtime = readRuntimePortImpl(attestedPid); - if (!runtime?.attestationSecret || runtime.port !== live.port) { - return { source: "management-api-unavailable", entries: null }; - } - const challenge = createLocalAttestationChallenge(); - const proofResponse = await fetchImpl( - `http://${probeHostname(live.hostname)}:${live.port}/healthz`, - { - headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, - signal: AbortSignal.timeout(4000), - }, - ); - const proof = proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER); - if (!proofResponse.ok || !verifyLocalAttestationProof( - runtime.attestationSecret, - challenge, - attestedPid, - live.port, - proof, - )) { - return { source: "management-api-unavailable", entries: null }; - } - headers.Authorization = `Bearer ${token}`; - } - const res = await fetchImpl( - `http://${probeHostname(live.hostname)}:${live.port}/api/codex-auth/accounts`, - { headers, signal: AbortSignal.timeout(4000) }, + const read = await fetchBoundLocalManagementRead( + live, + LOCAL_MANAGEMENT_READ_PATHS.codexAccounts, + { fetchImpl, readRuntime: readRuntimePortImpl, timeoutMs: 4_000 }, ); + if (read.kind === "unavailable") { + return { source: "management-api-unavailable", entries: null }; + } + const res = read.response; if (res.status === 401 || res.status === 403) { return { source: "management-auth-failed", entries: null }; } diff --git a/src/server/direct-local-http.ts b/src/server/direct-local-http.ts new file mode 100644 index 000000000..fdf310384 --- /dev/null +++ b/src/server/direct-local-http.ts @@ -0,0 +1,298 @@ +import net, { type Socket } from "node:net"; + +const DIRECT_LOCAL_HTTP_MAX_BYTES = 8 * 1024 * 1024; + +function abortReason(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason; + const error = new Error("direct local HTTP request aborted"); + error.name = "AbortError"; + return error; +} + +function headerBoundary(bytes: Buffer): number { + return bytes.indexOf("\r\n\r\n"); +} + +function decodeChunkedBody(body: Buffer): Buffer { + const chunks: Buffer[] = []; + let offset = 0; + for (;;) { + const lineEnd = body.indexOf("\r\n", offset); + if (lineEnd < 0) throw new Error("direct local HTTP response has a truncated chunk header"); + const rawSize = body.subarray(offset, lineEnd).toString("ascii").split(";", 1)[0]?.trim() ?? ""; + if (!/^[0-9a-f]+$/i.test(rawSize)) throw new Error("direct local HTTP response has an invalid chunk size"); + const size = Number.parseInt(rawSize, 16); + offset = lineEnd + 2; + if (size === 0) { + if (body.length < offset + 2) throw new Error("direct local HTTP response has a truncated chunk trailer"); + if (body[offset] !== 13 || body[offset + 1] !== 10) { + if (body.indexOf("\r\n\r\n", offset) < 0) { + throw new Error("direct local HTTP response has a truncated chunk trailer"); + } + } + return Buffer.concat(chunks); + } + if (!Number.isSafeInteger(size) || offset + size + 2 > body.length) { + throw new Error("direct local HTTP response has a truncated chunk body"); + } + chunks.push(body.subarray(offset, offset + size)); + offset += size; + if (body[offset] !== 13 || body[offset + 1] !== 10) { + throw new Error("direct local HTTP response has an invalid chunk terminator"); + } + offset += 2; + } +} + +type ResponseFraming = + | { kind: "head"; searchFrom: number } + | { kind: "content-length"; totalBytes: number } + | { kind: "chunk-size"; offset: number; searchFrom: number } + | { kind: "chunk-body"; offset: number; size: number } + | { kind: "trailers"; offset: number; searchFrom: number } + | { kind: "close" } + | { kind: "complete" }; + +function parseResponseHead(bytes: Buffer, boundary: number): { + status: number; + statusText: string; + headers: Headers; +} { + const lines = bytes.subarray(0, boundary).toString("latin1").split("\r\n"); + const statusLine = lines.shift() ?? ""; + const match = /^HTTP\/1\.[01] ([0-9]{3})(?: (.*))?$/.exec(statusLine); + if (!match) throw new Error("direct local HTTP response has an invalid status line"); + const status = Number(match[1]); + if (status < 200 || status > 599) throw new Error("direct local HTTP response has an unsupported status"); + const headers = new Headers(); + for (const line of lines) { + const colon = line.indexOf(":"); + if (colon <= 0) throw new Error("direct local HTTP response has an invalid header"); + headers.append(line.slice(0, colon).trim(), line.slice(colon + 1).trim()); + } + return { status, statusText: match[2] ?? "", headers }; +} + +function advanceResponseFraming(bytes: Buffer, initial: ResponseFraming): ResponseFraming { + let framing = initial; + for (;;) { + if (framing.kind === "complete" || framing.kind === "close") return framing; + if (framing.kind === "head") { + const boundary = bytes.indexOf("\r\n\r\n", framing.searchFrom); + if (boundary < 0) { + if (bytes.length > 64 * 1024) throw new Error("direct local HTTP response headers exceed the byte cap"); + return { kind: "head", searchFrom: Math.max(0, bytes.length - 3) }; + } + const { status, headers } = parseResponseHead(bytes, boundary); + const bodyStart = boundary + 4; + if (status === 204 || status === 205 || status === 304) return { kind: "complete" }; + if (/\bchunked\b/i.test(headers.get("transfer-encoding") ?? "")) { + framing = { kind: "chunk-size", offset: bodyStart, searchFrom: bodyStart }; + continue; + } + const rawLength = headers.get("content-length"); + if (rawLength === null) return { kind: "close" }; + if (!/^[0-9]+$/.test(rawLength)) throw new Error("direct local HTTP response has an invalid content length"); + const length = Number(rawLength); + const totalBytes = bodyStart + length; + if (!Number.isSafeInteger(length) || totalBytes > DIRECT_LOCAL_HTTP_MAX_BYTES) { + throw new Error("direct local HTTP response exceeds the byte cap"); + } + framing = { kind: "content-length", totalBytes }; + continue; + } + if (framing.kind === "content-length") { + return bytes.length >= framing.totalBytes ? { kind: "complete" } : framing; + } + if (framing.kind === "chunk-size") { + const lineEnd = bytes.indexOf("\r\n", framing.searchFrom); + if (lineEnd < 0) { + if (bytes.length - framing.offset > 8 * 1024) { + throw new Error("direct local HTTP response chunk header exceeds the byte cap"); + } + return { + ...framing, + searchFrom: Math.max(framing.offset, bytes.length - 1), + }; + } + const rawSize = bytes.subarray(framing.offset, lineEnd).toString("ascii").split(";", 1)[0]?.trim() ?? ""; + if (!/^[0-9a-f]+$/i.test(rawSize)) throw new Error("direct local HTTP response has an invalid chunk size"); + const size = Number.parseInt(rawSize, 16); + if (!Number.isSafeInteger(size) || size > DIRECT_LOCAL_HTTP_MAX_BYTES) { + throw new Error("direct local HTTP response chunk exceeds the byte cap"); + } + const offset = lineEnd + 2; + framing = size === 0 + ? { kind: "trailers", offset, searchFrom: offset } + : { kind: "chunk-body", offset, size }; + continue; + } + if (framing.kind === "chunk-body") { + const terminator = framing.offset + framing.size; + if (terminator + 2 > bytes.length) return framing; + if (bytes[terminator] !== 13 || bytes[terminator + 1] !== 10) { + throw new Error("direct local HTTP response has an invalid chunk terminator"); + } + framing = { kind: "chunk-size", offset: terminator + 2, searchFrom: terminator + 2 }; + continue; + } + if (bytes.length < framing.offset + 2) return framing; + if (bytes[framing.offset] === 13 && bytes[framing.offset + 1] === 10) return { kind: "complete" }; + const trailerEnd = bytes.indexOf("\r\n\r\n", framing.searchFrom); + if (trailerEnd >= 0) return { kind: "complete" }; + if (bytes.length - framing.offset > 64 * 1024) { + throw new Error("direct local HTTP response trailers exceed the byte cap"); + } + return { ...framing, searchFrom: Math.max(framing.offset, bytes.length - 3) }; + } +} + +function parseResponse(bytes: Buffer): Response { + const boundary = headerBoundary(bytes); + if (boundary < 0) throw new Error("direct local HTTP response has no header boundary"); + const lines = bytes.subarray(0, boundary).toString("latin1").split("\r\n"); + const statusLine = lines.shift() ?? ""; + const match = /^HTTP\/1\.[01] ([0-9]{3})(?: (.*))?$/.exec(statusLine); + if (!match) throw new Error("direct local HTTP response has an invalid status line"); + const status = Number(match[1]); + if (status < 200 || status > 599) throw new Error("direct local HTTP response has an unsupported status"); + + const headers = new Headers(); + for (const line of lines) { + const colon = line.indexOf(":"); + if (colon <= 0) throw new Error("direct local HTTP response has an invalid header"); + headers.append(line.slice(0, colon).trim(), line.slice(colon + 1).trim()); + } + + let body = bytes.subarray(boundary + 4); + if (/\bchunked\b/i.test(headers.get("transfer-encoding") ?? "")) { + body = decodeChunkedBody(body); + headers.delete("transfer-encoding"); + headers.delete("content-length"); + } else { + const rawLength = headers.get("content-length"); + if (rawLength !== null) { + if (!/^[0-9]+$/.test(rawLength)) throw new Error("direct local HTTP response has an invalid content length"); + const length = Number(rawLength); + if (!Number.isSafeInteger(length) || body.byteLength < length) { + throw new Error("direct local HTTP response body is truncated"); + } + body = body.subarray(0, length); + } + } + + const bodyless = status === 204 || status === 205 || status === 304; + return new Response(bodyless ? null : new Uint8Array(body), { + status, + statusText: match[2] ?? "", + headers, + }); +} + +/** + * Fetch one local HTTP GET over a direct TCP connection. + * + * Bun's global fetch and Bun 1.3's node:http compatibility layer can honor + * HTTP(S)_PROXY. Local identity and capability probes must not expose headers + * to, or accept a fabricated response from, such a proxy. node:net connects to + * the selected listener without consulting proxy environment variables. Each + * caller retains an injected fetch seam for deterministic unit tests. + */ +export const directLocalHttpFetch = (async ( + input: string | URL | Request, + init: RequestInit = {}, +): Promise => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const method = (init.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + const signal = init.signal ?? (input instanceof Request ? input.signal : undefined); + const body = init.body ?? (input instanceof Request ? input.body : null); + + if (url.protocol !== "http:") throw new Error("direct local request must use HTTP"); + if (url.username || url.password) throw new Error("direct local request URL must not contain credentials"); + if (method !== "GET" || body !== null) throw new Error("direct local request must be a bodyless GET"); + if (signal?.aborted) throw abortReason(signal); + + const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined)); + headers.delete("proxy-authorization"); + headers.delete("proxy-connection"); + headers.set("host", url.host); + headers.set("connection", "close"); + const headerLines: string[] = []; + headers.forEach((value, key) => { headerLines.push(`${key}: ${value}`); }); + const requestBytes = Buffer.from( + `GET ${url.pathname}${url.search} HTTP/1.1\r\n${headerLines.join("\r\n")}\r\n\r\n`, + "latin1", + ); + const parsedHostname = url.hostname.startsWith("[") && url.hostname.endsWith("]") + ? url.hostname.slice(1, -1) + : url.hostname; + const hostname = parsedHostname.toLowerCase() === "localhost" ? "127.0.0.1" : parsedHostname; + const port = url.port ? Number(url.port) : 80; + + return await new Promise((resolve, reject) => { + let socket: Socket | undefined; + let settled = false; + let receivedBytes = 0; + let responseBytes = Buffer.allocUnsafe(4 * 1024); + let framing: ResponseFraming = { kind: "head", searchFrom: 0 }; + const cleanup = () => signal?.removeEventListener("abort", onAbort); + const finish = (error?: Error) => { + if (settled) return; + settled = true; + cleanup(); + try { socket?.destroy(); } catch { /* ignore */ } + if (error) { + reject(error); + return; + } + try { + resolve(parseResponse(responseBytes.subarray(0, receivedBytes))); + } catch (parseError) { + reject(parseError instanceof Error ? parseError : new Error(String(parseError))); + } + }; + const onAbort = () => { + const error = signal ? abortReason(signal) : new Error("direct local HTTP request aborted"); + try { socket?.destroy(error); } catch { /* ignore */ } + finish(error); + }; + + socket = net.createConnection({ host: hostname, port }); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) { + onAbort(); + return; + } + socket.on("connect", () => { + try { socket?.write(requestBytes); } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }); + socket.on("data", chunk => { + if (settled) return; + const bytes = Buffer.from(chunk); + receivedBytes += bytes.byteLength; + if (receivedBytes > DIRECT_LOCAL_HTTP_MAX_BYTES) { + finish(new Error("direct local HTTP response exceeds the byte cap")); + return; + } + if (receivedBytes > responseBytes.byteLength) { + let capacity = responseBytes.byteLength; + while (capacity < receivedBytes) capacity = Math.min(DIRECT_LOCAL_HTTP_MAX_BYTES, capacity * 2); + const grown = Buffer.allocUnsafe(capacity); + responseBytes.copy(grown); + responseBytes = grown; + } + bytes.copy(responseBytes, receivedBytes - bytes.byteLength); + try { + framing = advanceResponseFraming(responseBytes.subarray(0, receivedBytes), framing); + if (framing.kind === "complete") finish(); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }); + socket.once("end", () => finish()); + socket.once("error", error => finish(error)); + socket.once("close", () => finish()); + }); +}) as typeof fetch; diff --git a/src/server/local-management-read-client.ts b/src/server/local-management-read-client.ts new file mode 100644 index 000000000..abcaa7acf --- /dev/null +++ b/src/server/local-management-read-client.ts @@ -0,0 +1,87 @@ +import { readRuntimePort, type RuntimePortState } from "../config"; +import { createLocalAttestationChallenge } from "../lib/local-management-attestation"; +import { + LOCAL_MANAGEMENT_CAPABILITY_HEADER, + LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER, + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS, + LOCAL_MANAGEMENT_EXPECTED_PID_HEADER, + LOCAL_MANAGEMENT_NONCE_HEADER, + createLocalManagementReadCapability, + type LocalManagementReadPath, +} from "../lib/local-management-capability"; +import { directLocalHttpFetch } from "./direct-local-http"; +import { probeHostname, type LiveProxy } from "./proxy-liveness"; + +export type LocalManagementReadResult = + | { kind: "response"; response: Response; targetPid: number } + | { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "capability-unavailable" | "transport" }; + +export interface LocalManagementReadDeps { + fetchImpl?: typeof fetch; + readRuntime?: (pid: number) => RuntimePortState | null; + createNonce?: () => string; + now?: () => number; + timeoutMs?: number; +} + +/** + * Read one exact local management endpoint without sending a reusable admin credential. + * + * The runtime record is the protected source of the per-process key. The server proves + * it owns that key by accepting a single-use capability bound to this GET, path, PID, + * port, and short expiry. + */ +export async function fetchBoundLocalManagementRead( + target: LiveProxy, + path: LocalManagementReadPath, + deps: LocalManagementReadDeps = {}, +): Promise { + if ( + target.source !== "runtime" + || target.pid === null + || !Number.isSafeInteger(target.pid) + || target.pid <= 0 + ) { + return { kind: "unavailable", reason: "unattested-target" }; + } + const readRuntime = deps.readRuntime ?? readRuntimePort; + const runtime = readRuntime(target.pid); + if ( + !runtime?.attestationSecret + || runtime.pid !== target.pid + || runtime.port !== target.port + ) { + return { kind: "unavailable", reason: "runtime-mismatch" }; + } + + const nonce = (deps.createNonce ?? createLocalAttestationChallenge)(); + const expiresAt = (deps.now ?? Date.now)() + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS; + const capability = createLocalManagementReadCapability( + runtime.attestationSecret, + nonce, + "GET", + path, + target.pid, + target.port, + expiresAt, + ); + if (!capability) return { kind: "unavailable", reason: "capability-unavailable" }; + + try { + const response = await (deps.fetchImpl ?? directLocalHttpFetch)( + `http://${probeHostname(target.hostname)}:${target.port}${path}`, + { + headers: { + [LOCAL_MANAGEMENT_EXPECTED_PID_HEADER]: String(target.pid), + [LOCAL_MANAGEMENT_NONCE_HEADER]: nonce, + [LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER]: String(expiresAt), + [LOCAL_MANAGEMENT_CAPABILITY_HEADER]: capability, + }, + signal: AbortSignal.timeout(deps.timeoutMs ?? 4_000), + }, + ); + return { kind: "response", response, targetPid: target.pid }; + } catch { + return { kind: "unavailable", reason: "transport" }; + } +} diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 375ea1a11..87280744e 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -13,6 +13,14 @@ import { } from "node:fs"; import { dirname, join } from "node:path"; import { adminApiTokenFilePath } from "../lib/admin-secrets"; +import { + LOCAL_MANAGEMENT_CAPABILITY_HEADER, + LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER, + LOCAL_MANAGEMENT_EXPECTED_PID_HEADER, + LOCAL_MANAGEMENT_NONCE_HEADER, + parseExpectedLocalManagementPid, + verifyLocalManagementReadCapability, +} from "../lib/local-management-capability"; import { SYSTEM_RESTART_CAPABILITY_HEADER, SYSTEM_RESTART_EXPECTED_PID_HEADER, @@ -34,6 +42,9 @@ import { const GUI_SESSION_TTL_MS = 5 * 60_000; const GUI_SESSION_LIMIT = 128; +const LOCAL_READ_REPLAY_LIMIT = 256; +const consumedLocalReadCapabilities = new Map(); +const admittedLocalReadRequests = new WeakSet(); interface GuiSessionRecord { csrfToken: string; @@ -253,10 +264,15 @@ export function issueGuiSession( * minted for a browser, and it only authorizes a mutation after the origin and the * per-session CSRF token match. Consent-bearing routes must key off this value * rather than off request headers, which the token holder can forge freely. - * `system-restart-capability` is a process-scoped HMAC accepted only for the exact - * restart route and bound to the current process PID and listening port. + * The capability principals are process-scoped HMACs bound to the current process + * PID and listening port. Local reads are accepted only for two exact GET paths; + * restart remains a separate wire contract for its exact POST. */ -export type ManagementPrincipal = "admin-token" | "gui-session" | "system-restart-capability"; +export type ManagementPrincipal = + | "admin-token" + | "gui-session" + | "local-read-capability" + | "system-restart-capability"; export interface LocalManagementAuthContext { attestationSecret: string; @@ -291,6 +307,53 @@ function hasSystemRestartCapability( ); } +function hasLocalReadCapability( + req: Request, + local: LocalManagementAuthContext | undefined, +): boolean { + // requireManagementAuth and managementPrincipal inspect the same Request in + // sequence. Preserve that one admission without accepting a replayed request. + if (admittedLocalReadRequests.has(req)) return true; + if (!local || req.method !== "GET") return false; + let url: URL; + try { + url = new URL(req.url); + } catch { + return false; + } + // Do not let a future query-bearing variant silently inherit this narrow grant. + if (url.search !== "") return false; + const expectedPid = parseExpectedLocalManagementPid( + req.headers.get(LOCAL_MANAGEMENT_EXPECTED_PID_HEADER), + ); + if (expectedPid.kind !== "present" || expectedPid.pid !== local.pid) return false; + const expiresAtRaw = req.headers.get(LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER); + if (!expiresAtRaw || !/^[1-9]\d*$/.test(expiresAtRaw)) return false; + const expiresAt = Number(expiresAtRaw); + if (!Number.isSafeInteger(expiresAt)) return false; + const capability = req.headers.get(LOCAL_MANAGEMENT_CAPABILITY_HEADER); + const now = Date.now(); + if (!verifyLocalManagementReadCapability( + local.attestationSecret, + req.headers.get(LOCAL_MANAGEMENT_NONCE_HEADER), + req.method, + url.pathname, + local.pid, + local.port, + expiresAt, + capability, + now, + )) return false; + for (const [consumed, retainedUntil] of consumedLocalReadCapabilities) { + if (retainedUntil <= now) consumedLocalReadCapabilities.delete(consumed); + } + if (!capability || consumedLocalReadCapabilities.has(capability)) return false; + if (consumedLocalReadCapabilities.size >= LOCAL_READ_REPLAY_LIMIT) return false; + consumedLocalReadCapabilities.set(capability, expiresAt); + admittedLocalReadRequests.add(req); + return true; +} + /** * The principal for a request that already passed `requireManagementAuth`. Kept as a * separate resolution (rather than a changed return type) so every existing caller @@ -305,6 +368,7 @@ export function managementPrincipal( local?: LocalManagementAuthContext, ): ManagementPrincipal | null { if (hasSystemRestartCapability(req, local)) return "system-restart-capability"; + if (hasLocalReadCapability(req, local)) return "local-read-capability"; if (!state.available) return null; const actual = req.headers.get("x-opencodex-api-key")?.trim() || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); @@ -322,6 +386,7 @@ export function requireManagementAuth( local?: LocalManagementAuthContext, ): Response | null { if (hasSystemRestartCapability(req, local)) return null; + if (hasLocalReadCapability(req, local)) return null; if (!state.available) { return Response.json({ error: "management API unavailable", diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index ebfb2ea98..48c89fc97 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -10,6 +10,7 @@ * Lives outside cli.ts (which dispatches argv at module top level) so tests can import it. */ import { loadConfig, readAlivePid, readRuntimePort, verifyPidIdentity } from "../config"; +import { directLocalHttpFetch } from "./direct-local-http"; export interface HealthzIdentity { service?: unknown; @@ -97,7 +98,7 @@ export async function proxyIdentityAt( opts: { hostname?: string; expectedPid?: number } = {}, io: LivenessIo = {}, ): Promise<{ pid: number | null } | null> { - const fetchFn = io.fetchFn ?? fetch; + const fetchFn = io.fetchFn ?? directLocalHttpFetch; const sleepFn = io.sleepFn ?? ((ms: number) => new Promise(r => setTimeout(r, ms))); const nowFn = io.nowFn ?? Date.now; const baseTimeoutMs = io.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; @@ -307,7 +308,7 @@ export async function probeReadiness( opts: { hostname?: string; expectedPid?: number } = {}, io: ReadinessProbeIo = {}, ): Promise { - const fetchFn = io.fetchFn ?? fetch; + const fetchFn = io.fetchFn ?? directLocalHttpFetch; try { const res = await fetchFn(`http://${probeHostname(opts.hostname)}:${port}/readyz`, { signal: AbortSignal.timeout(io.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS), diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index a8ad2a898..d061b8c6f 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -23,22 +23,24 @@ OpenCodex uses three mutually exclusive admission credential classes: The service token file remains a delivery mechanism for the data-plane environment token; it is not a fourth credential class. A management credential that equals any configured data-plane credential does not enable management access. The data plane may continue to start, but `/api/*` remains closed. -CLI health collection follows the same boundary: `ocx status` and `ocx doctor` use the configured -management credential for `/api/codex-auth/accounts`, never the service/data-plane token. Their -output distinguishes a missing proxy, rejected management authentication, and an unexpected -management response so a reachable `401` cannot be reported as "proxy not running." - -Before either CLI command attaches the management bearer, it challenges the listener and verifies -an HMAC proof bound to the proxy PID and port. The per-process proof key lives only in the protected -`runtime-port.json`; the public `/healthz` identity marker alone is never sufficient to receive a -management credential. Legacy or configured-port-only listeners still satisfy ordinary liveness, -but their account-health detail remains unavailable until an attested runtime record exists. +CLI health collection follows the same boundary without transporting the reusable management +credential. `ocx status` and `ocx doctor` derive process-scoped HMAC capabilities from the protected +`runtime-port.json` secret for exactly two read-only GETs: `/api/codex-auth/accounts` and +`/api/system/memory`. Each capability is bound to its method, path, nonce, proxy PID, and port. A +short expiry is part of the HMAC, and the server consumes each capability once. A capability cannot +authorize another management route or survive process replacement. These probes connect directly +to the selected listener instead of delegating local identity to an environment HTTP proxy. Their +output distinguishes +a missing proxy, rejected local capability, and an unexpected management response so a reachable +`401` cannot be reported as "proxy not running." Legacy or configured-port-only listeners still +satisfy ordinary liveness, but their detailed CLI health remains unavailable until restarted with +an attested runtime record and capability-aware server. [Decision Log] - 목적과 의도: Keep a lower-privileged local process from collecting the management bearer by impersonating `/healthz` on an unused port. - 기존 구현 및 제약 조건: Liveness must remain public and backward-compatible, but its service string and reported PID are assertions made by the listener itself. - 검토한 주요 대안: Require only a runtime source and non-null PID; stop showing account health; authenticate the listener with a protected per-process challenge secret. -- 선택한 방식: Store a random secret in the mode-protected runtime record and require a challenge/PID/port HMAC before the CLI sends Authorization. +- 선택한 방식: Store a random secret in the mode-protected runtime record and use method/path/PID/port-bound HMAC capabilities for the two CLI health reads, so the CLI sends no reusable Authorization value. - 다른 대안 대신 이 방식을 선택한 이유: PID and command-line checks are not cryptographic listener identity, while removing live account health would regress diagnostics unnecessarily. - 장점, 단점 및 영향: The long-lived token never reaches a listener without the runtime secret; an old running proxy remains visible but cannot provide detailed CLI account health until restarted on the new version. @@ -92,7 +94,7 @@ this document owns is which module holds which area and what invariant that area | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), and the logical maximum thread count. Selecting `v2` enables the native flag and migrates `[agents] max_threads` to the v2 key; selecting `v1` disables it and migrates the same value back. `default` leaves the native flag unchanged. PUT accepts `enabled`, `multiAgentMode`, and/or the compatibility-named `maxConcurrentThreadsPerSession`; contradictory mode/flag pairs are rejected before writes. Every transition is rollback-safe and resyncs the catalog. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from `~/.opencodex/usage.jsonl`; measured / reported / unreported / unsupported / estimated counts, daily zero-filled grid, model and provider breakdowns. Never exposes prompts. | -| System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Scalar-only payload; rides the standard management auth gate and must never move to unauthenticated `/healthz`. Consumed by `ocx doctor`'s Memory/runtime section and the dashboard Memory observability card. | +| System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend plus optional `webSearch.reasoning` and `vision.maxDescriptionsPerTurn`; the read and PUT-response payload reports model, backend, and the vision per-turn limit. Credentials live in the provider and OAuth stores instead. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.4-mini` + `gpt-5.6-luna`), so no client hard-codes a helper slug that a Codex release can invalidate. | diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 3a3ebca18..d7b00691f 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -19,6 +19,14 @@ import { } from "../src/cli/doctor"; import { collectOrcaCodexHomeDiagnostic } from "../src/codex/home"; import { NativeProfileError } from "../src/codex/native-profile-types"; +import { + LOCAL_MANAGEMENT_CAPABILITY_HEADER, + LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER, + LOCAL_MANAGEMENT_EXPECTED_PID_HEADER, + LOCAL_MANAGEMENT_NONCE_HEADER, + LOCAL_MANAGEMENT_READ_PATHS, + verifyLocalManagementReadCapability, +} from "../src/lib/local-management-capability"; const TEST_DIR = join(import.meta.dir, ".tmp-doctor-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); @@ -28,6 +36,7 @@ let prevCodexHome: string | undefined; let prevHttpsProxy: string | undefined; let prevLowerHttpsProxy: string | undefined; let prevProxyRef: string | undefined; +let prevAdminToken: string | undefined; describe("doctor", () => { beforeEach(() => { @@ -36,6 +45,7 @@ describe("doctor", () => { prevHttpsProxy = process.env.HTTPS_PROXY; prevLowerHttpsProxy = process.env.https_proxy; prevProxyRef = process.env.OCX_TEST_PROXY_REF; + prevAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_CODEX_HOME, { recursive: true }); mkdirSync(TEST_OPENCODEX_HOME, { recursive: true }); @@ -57,6 +67,8 @@ describe("doctor", () => { else process.env.https_proxy = prevLowerHttpsProxy; if (prevProxyRef === undefined) delete process.env.OCX_TEST_PROXY_REF; else process.env.OCX_TEST_PROXY_REF = prevProxyRef; + if (prevAdminToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = prevAdminToken; if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); @@ -383,25 +395,90 @@ describe("service memory section (#314 WP4)", () => { }; test("fetchServiceMemory: ok / unauthorized / unreachable / malformed", async () => { - const ok = await fetchServiceMemory("127.0.0.1", 10100, null, - (async () => Response.json(baseData)) as typeof fetch); + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-token-must-not-leave-doctor"; + const target = { hostname: "127.0.0.1", port: 10100, pid: 4242, source: "runtime" } as const; + const attestationSecret = "A".repeat(43); + const nonce = "B".repeat(43); + const now = 1_800_000_000_000; + const deps = { + readRuntime: () => ({ pid: 4242, port: 10100, attestationSecret }), + createNonce: () => nonce, + now: () => now, + }; + const ok = await fetchServiceMemory(target, { + ...deps, + fetchImpl: (async (_input, init) => { + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("x-opencodex-api-key")).toBeNull(); + expect(headers.get(LOCAL_MANAGEMENT_EXPECTED_PID_HEADER)).toBe("4242"); + expect(verifyLocalManagementReadCapability( + attestationSecret, + headers.get(LOCAL_MANAGEMENT_NONCE_HEADER), + "GET", + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + 4242, + 10100, + Number(headers.get(LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER)), + headers.get(LOCAL_MANAGEMENT_CAPABILITY_HEADER), + now, + )).toBe(true); + return Response.json(baseData); + }) as typeof fetch, + }); expect(ok.status).toBe("ok"); if (ok.status === "ok") expect(ok.data.pid).toBe(4242); - const unauthorized = await fetchServiceMemory("127.0.0.1", 10100, "wrong", - (async () => new Response("{}", { status: 401 })) as typeof fetch); + const unauthorized = await fetchServiceMemory(target, { + ...deps, + fetchImpl: (async () => new Response("{}", { status: 401 })) as typeof fetch, + }); expect(unauthorized.status).toBe("unauthorized"); - const unreachable = await fetchServiceMemory("127.0.0.1", 10100, null, - (async () => { throw new TypeError("fetch failed"); }) as typeof fetch); + const unreachable = await fetchServiceMemory(target, { + ...deps, + fetchImpl: (async () => { throw new TypeError("fetch failed"); }) as typeof fetch, + }); expect(unreachable.status).toBe("unreachable"); - const malformed = await fetchServiceMemory("127.0.0.1", 10100, null, - (async () => Response.json({ hello: "world" })) as typeof fetch); + const malformed = await fetchServiceMemory(target, { + ...deps, + fetchImpl: (async () => Response.json({ ...baseData, pid: 9999 })) as typeof fetch, + }); expect(malformed.status).toBe("unreachable"); if (malformed.status === "unreachable") expect(malformed.error).toBe("malformed response"); }); + test("does not contact configured-port or stale runtime targets", async () => { + let fetchCalls = 0; + const fetchImpl = (async () => { + fetchCalls += 1; + return Response.json(baseData); + }) as typeof fetch; + const configured = await fetchServiceMemory( + { hostname: "127.0.0.1", port: 10100, pid: null, source: "config" }, + { fetchImpl }, + ); + const staleRuntime = await fetchServiceMemory( + { hostname: "127.0.0.1", port: 10100, pid: 4242, source: "runtime" }, + { + fetchImpl, + readRuntime: () => ({ pid: 4242, port: 10101, attestationSecret: "A".repeat(43) }), + }, + ); + const legacyRuntime = await fetchServiceMemory( + { hostname: "127.0.0.1", port: 10100, pid: 4242, source: "runtime" }, + { + fetchImpl, + readRuntime: () => ({ pid: 4242, port: 10100 }), + }, + ); + expect(configured.status).toBe("unauthorized"); + expect(staleRuntime.status).toBe("unauthorized"); + expect(legacyRuntime.status).toBe("unauthorized"); + expect(fetchCalls).toBe(0); + }); + test("identity labels: doctor process is never presented as the service", () => { const lines = formatServiceMemoryLines({ status: "ok", data: baseData }); expect(lines[0]).toContain("NOT the service process"); @@ -502,7 +579,7 @@ describe("service memory section (#314 WP4)", () => { test("unauthorized and unreachable render honest lines without fake data", () => { const unauthorized = formatServiceMemoryLines({ status: "unauthorized" }); - expect(unauthorized.some(l => l.includes("rejected the request"))).toBe(true); + expect(unauthorized.some(l => l.includes("local diagnostic capability unavailable"))).toBe(true); expect(unauthorized.some(l => l.includes("service pid"))).toBe(false); const unreachable = formatServiceMemoryLines({ status: "unreachable", error: "ECONNREFUSED" }); diff --git a/tests/local-management-capability.test.ts b/tests/local-management-capability.test.ts new file mode 100644 index 000000000..88976fee8 --- /dev/null +++ b/tests/local-management-capability.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test"; +import { + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS, + LOCAL_MANAGEMENT_READ_PATHS, + createLocalManagementReadCapability, + verifyLocalManagementReadCapability, +} from "../src/lib/local-management-capability"; +import { + SYSTEM_RESTART_METHOD, + SYSTEM_RESTART_PATH, + createSystemRestartCapability, + verifySystemRestartCapability, +} from "../src/lib/system-restart-contract"; + +describe("local management read capability", () => { + const secret = "A".repeat(43); + const nonce = "B".repeat(43); + const pid = 4242; + const port = 10100; + const now = 1_800_000_000_000; + const expiresAt = now + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS; + + test("binds one allowlisted GET to its nonce, path, PID, and port", () => { + const capability = createLocalManagementReadCapability( + secret, + nonce, + "GET", + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + pid, + port, + expiresAt, + ); + expect(capability).toHaveLength(43); + expect(verifyLocalManagementReadCapability( + secret, + nonce, + "GET", + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + pid, + port, + expiresAt, + capability, + now, + )).toBe(true); + + const invalid: Array<[string, string, number, number, string | null]> = [ + ["POST", LOCAL_MANAGEMENT_READ_PATHS.systemMemory, pid, port, capability], + ["GET", LOCAL_MANAGEMENT_READ_PATHS.codexAccounts, pid, port, capability], + ["GET", LOCAL_MANAGEMENT_READ_PATHS.systemMemory, pid + 1, port, capability], + ["GET", LOCAL_MANAGEMENT_READ_PATHS.systemMemory, pid, port + 1, capability], + ["GET", LOCAL_MANAGEMENT_READ_PATHS.systemMemory, pid, port, "C".repeat(43)], + ]; + for (const [method, path, candidatePid, candidatePort, candidate] of invalid) { + expect(verifyLocalManagementReadCapability( + secret, + nonce, + method, + path, + candidatePid, + candidatePort, + expiresAt, + candidate, + now, + )).toBe(false); + } + expect(verifyLocalManagementReadCapability( + secret, + "D".repeat(43), + "GET", + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + pid, + port, + expiresAt, + capability, + now, + )).toBe(false); + const expiredCapability = createLocalManagementReadCapability( + secret, + nonce, + "GET", + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + pid, + port, + now, + ); + expect(verifyLocalManagementReadCapability( + secret, + nonce, + "GET", + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + pid, + port, + now, + expiredCapability, + now, + )).toBe(false); + const farFutureExpiry = now + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS + 1; + const farFutureCapability = createLocalManagementReadCapability( + secret, + nonce, + "GET", + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + pid, + port, + farFutureExpiry, + ); + expect(verifyLocalManagementReadCapability( + secret, + nonce, + "GET", + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + pid, + port, + farFutureExpiry, + farFutureCapability, + now, + )).toBe(false); + }); + + test("cannot cross the restart or other local-read capability domains", () => { + const memoryCapability = createLocalManagementReadCapability( + secret, + nonce, + "GET", + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + pid, + port, + expiresAt, + ); + const restartCapability = createSystemRestartCapability( + secret, + nonce, + SYSTEM_RESTART_METHOD, + SYSTEM_RESTART_PATH, + pid, + port, + ); + expect(verifySystemRestartCapability( + secret, + nonce, + SYSTEM_RESTART_METHOD, + SYSTEM_RESTART_PATH, + pid, + port, + memoryCapability, + )).toBe(false); + expect(verifyLocalManagementReadCapability( + secret, + nonce, + "GET", + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + pid, + port, + expiresAt, + restartCapability, + now, + )).toBe(false); + }); +}); diff --git a/tests/local-management-direct-transport.test.ts b/tests/local-management-direct-transport.test.ts new file mode 100644 index 000000000..de800f14f --- /dev/null +++ b/tests/local-management-direct-transport.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "bun:test"; +import { createServer } from "node:http"; +import { createServer as createTcpServer, type Server, type Socket } from "node:net"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { directLocalHttpFetch } from "../src/server/direct-local-http"; + +const PID = 4242; +const SECRET = "A".repeat(43); + +async function listen(server: Server): Promise { + return await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("test server did not bind TCP")); + return; + } + resolve(address.port); + }); + }); +} + +async function close(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); +} + +describe("local management direct transport", () => { + test("preserves an AbortError for an already-cancelled request", async () => { + const controller = new AbortController(); + controller.abort(); + await expect(directLocalHttpFetch("http://127.0.0.1:9/healthz", { + signal: controller.signal, + })).rejects.toMatchObject({ name: "AbortError" }); + }); + + test.each([ + ["content-length", (body: string) => `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`], + ["chunked", (body: string) => `Transfer-Encoding: chunked\r\n\r\n${Buffer.byteLength(body).toString(16)}\r\n${body}\r\n0\r\n\r\n`], + ])("finishes a %s response without waiting for a keep-alive socket to close", async (_name, frame) => { + const sockets = new Set(); + const body = JSON.stringify({ ok: true }); + const server = createTcpServer(socket => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.once("data", () => { + socket.write(`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: keep-alive\r\n${frame(body)}`); + }); + }); + let port = 0; + try { + port = await listen(server); + const response = await directLocalHttpFetch(`http://127.0.0.1:${port}/healthz`, { + signal: AbortSignal.timeout(500), + }); + expect(await response.json()).toEqual({ ok: true }); + } finally { + for (const socket of sockets) socket.destroy(); + if (port !== 0) await close(server); + } + }); + + test("bypasses configured environment proxies for liveness, readiness, and capability reads", async () => { + const targetPaths: string[] = []; + const targetCapabilities: string[] = []; + const proxyPaths: string[] = []; + let targetPort = 0; + + const reply = ( + rawPath: string, + write: (status: number, body: unknown) => void, + ) => { + const pathname = new URL(rawPath, "http://127.0.0.1").pathname; + if (pathname === "/healthz") { + write(200, { service: "opencodex", status: "ok", version: "test", uptime: 1, pid: PID, port: targetPort }); + return; + } + if (pathname === "/readyz") { + write(200, { service: "opencodex", status: "ready", version: "test", uptime: 1, pid: PID, port: targetPort }); + return; + } + if (pathname === "/api/system/memory") { + write(200, { pid: PID }); + return; + } + write(404, { error: "not found" }); + }; + + const target = createServer((request, response) => { + const rawPath = request.url ?? "/"; + const pathname = new URL(rawPath, "http://127.0.0.1").pathname; + targetPaths.push(pathname); + if (pathname === "/__proxy-control") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ via: "target" })); + return; + } + const capability = request.headers["x-opencodex-local-capability"]; + if (typeof capability === "string") targetCapabilities.push(capability); + reply(rawPath, (status, body) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }); + }); + const proxy = createServer((request, response) => { + const rawPath = request.url ?? "/"; + proxyPaths.push(rawPath); + const pathname = new URL(rawPath, "http://127.0.0.1").pathname; + if (pathname === "/__proxy-control") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ via: "proxy" })); + return; + } + // Return valid-looking data so the assertion detects routing, not parsing. + reply(rawPath, (status, body) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }); + }); + + let proxyPort = 0; + try { + targetPort = await listen(target); + proxyPort = await listen(proxy); + + const proxyLivenessUrl = pathToFileURL(join(import.meta.dir, "..", "src", "server", "proxy-liveness.ts")).href; + const localClientUrl = pathToFileURL(join(import.meta.dir, "..", "src", "server", "local-management-read-client.ts")).href; + const capabilityUrl = pathToFileURL(join(import.meta.dir, "..", "src", "lib", "local-management-capability.ts")).href; + const childSource = ` + const liveness = await import(${JSON.stringify(proxyLivenessUrl)}); + const client = await import(${JSON.stringify(localClientUrl)}); + const capability = await import(${JSON.stringify(capabilityUrl)}); + const port = ${targetPort}; + const pid = ${PID}; + const control = await fetch(\`http://127.0.0.1:\${port}/__proxy-control\`).then(response => response.json()); + const identity = await liveness.proxyIdentityAt(port, { hostname: "127.0.0.1", expectedPid: pid }); + const readiness = await liveness.probeReadiness(port, { hostname: "127.0.0.1", expectedPid: pid }); + const read = await client.fetchBoundLocalManagementRead( + { hostname: "127.0.0.1", port, pid, source: "runtime" }, + capability.LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + { + readRuntime: () => ({ pid, port, hostname: "127.0.0.1", attestationSecret: ${JSON.stringify(SECRET)} }), + createNonce: () => "B".repeat(43), + timeoutMs: 2_000, + }, + ); + const memory = read.kind === "response" ? await read.response.json() : null; + const result = { control, identity, readiness, readKind: read.kind, memory }; + console.log(JSON.stringify(result)); + if (control?.via !== "proxy" || identity?.pid !== pid || readiness?.ready !== true || read.kind !== "response" || memory?.pid !== pid) { + process.exitCode = 2; + } + `; + + const childEnv = { ...process.env } as Record; + for (const key of ["NO_PROXY", "no_proxy"]) delete childEnv[key]; + const proxyUrl = `http://127.0.0.1:${proxyPort}`; + for (const key of ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]) { + childEnv[key] = proxyUrl; + } + + const child = Bun.spawn([process.execPath, "--eval", childSource], { + cwd: join(import.meta.dir, ".."), + env: childEnv, + stdout: "pipe", + stderr: "pipe", + }); + let childTimedOut = false; + const childWatchdog = setTimeout(() => { + childTimedOut = true; + child.kill(); + }, 3_000); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]).finally(() => clearTimeout(childWatchdog)); + if (childTimedOut) throw new Error("direct-transport child timed out"); + if (exitCode !== 0) { + throw new Error(`direct-transport child failed (${exitCode}): ${stderr.trim()}\n${stdout.trim()}`); + } + + const line = stdout.trim().split(/\r?\n/).at(-1); + expect(line ? JSON.parse(line) : null).toEqual({ + control: { via: "proxy" }, + identity: { pid: PID }, + readiness: { ready: true, status: "ready", pid: PID, port: targetPort }, + readKind: "response", + memory: { pid: PID }, + }); + expect(proxyPaths).toHaveLength(1); + expect(proxyPaths[0]).toEndWith("/__proxy-control"); + expect(targetPaths).toEqual(["/healthz", "/readyz", "/api/system/memory"]); + expect(targetCapabilities).toHaveLength(1); + expect(targetCapabilities[0]).toHaveLength(43); + } finally { + if (proxyPort !== 0) await close(proxy); + if (targetPort !== 0) await close(target); + } + }); +}); diff --git a/tests/oauth-health.test.ts b/tests/oauth-health.test.ts index e8dd3eb24..15766edf5 100644 --- a/tests/oauth-health.test.ts +++ b/tests/oauth-health.test.ts @@ -23,10 +23,13 @@ import { import type { OcxConfig } from "../src/types"; import { formatOAuthHealthForStatus } from "../src/cli/status-oauth"; import { - LOCAL_ATTESTATION_CHALLENGE_HEADER, - LOCAL_ATTESTATION_PROOF_HEADER, - createLocalAttestationProof, -} from "../src/lib/local-management-attestation"; + LOCAL_MANAGEMENT_CAPABILITY_HEADER, + LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER, + LOCAL_MANAGEMENT_EXPECTED_PID_HEADER, + LOCAL_MANAGEMENT_NONCE_HEADER, + LOCAL_MANAGEMENT_READ_PATHS, + verifyLocalManagementReadCapability, +} from "../src/lib/local-management-capability"; const origHome = process.env.HOME; const origOcxHome = process.env.OPENCODEX_HOME; @@ -181,16 +184,27 @@ describe("collectOAuthHealthEntriesForCli", () => { process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx-admin-health-test"; const attestationSecret = "A".repeat(43); let authorization: string | null = null; + let apiKey: string | null = null; + let fetchCalls = 0; const report = await collectOAuthHealthEntriesForCli(Date.now(), { findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), - fetchImpl: async (input, init) => { - if (String(input).endsWith("/healthz")) { - const challenge = new Headers(init?.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER)!; - const proof = createLocalAttestationProof(attestationSecret, challenge, 4242, 19191)!; - return new Response("ok", { headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: proof } }); - } - authorization = new Headers(init?.headers).get("authorization"); + fetchImpl: async (_input, init) => { + fetchCalls += 1; + const headers = new Headers(init?.headers); + authorization = headers.get("authorization"); + apiKey = headers.get("x-opencodex-api-key"); + expect(headers.get(LOCAL_MANAGEMENT_EXPECTED_PID_HEADER)).toBe("4242"); + expect(verifyLocalManagementReadCapability( + attestationSecret, + headers.get(LOCAL_MANAGEMENT_NONCE_HEADER), + "GET", + LOCAL_MANAGEMENT_READ_PATHS.codexAccounts, + 4242, + 19191, + Number(headers.get(LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER)), + headers.get(LOCAL_MANAGEMENT_CAPABILITY_HEADER), + )).toBe(true); return new Response(JSON.stringify({ accounts: [{ id: "proxy-codex-acct", @@ -203,7 +217,9 @@ describe("collectOAuthHealthEntriesForCli", () => { }), { status: 200 }); }, }); - expect(authorization).toBe("Bearer ocx-admin-health-test"); + expect(fetchCalls).toBe(1); + expect(authorization).toBeNull(); + expect(apiKey).toBeNull(); expect(report.codexHealthSource).toBe("management-api"); expect(report.entries.some(e => e.accountId === MAIN_CODEX_ACCOUNT_ID)).toBe(false); const remote = report.entries.find(e => e.accountId === "proxy-codex-acct"); @@ -231,17 +247,17 @@ describe("collectOAuthHealthEntriesForCli", () => { expect(report.codexHealthSource).toBe("management-api-unavailable"); }); - test("an invalid listener proof cannot unlock the bearer-bearing request", async () => { + test("a stale runtime record cannot launch a local capability request", async () => { process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx-admin-health-test"; const attestationSecret = "A".repeat(43); let apiCalls = 0; const report = await collectOAuthHealthEntriesForCli(Date.now(), { findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), - readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), - fetchImpl: async (input, init) => { + readRuntimePortImpl: () => ({ pid: 4242, port: 19192, attestationSecret }), + fetchImpl: async (_input, init) => { expect(new Headers(init?.headers).get("authorization")).toBeNull(); - if (!String(input).endsWith("/healthz")) apiCalls += 1; - return new Response("fake", { headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: "B".repeat(43) } }); + apiCalls += 1; + return new Response("fake"); }, }); expect(apiCalls).toBe(0); @@ -261,8 +277,10 @@ describe("collectOAuthHealthEntriesForCli", () => { }); test("distinguishes management authentication failure from a stopped proxy", async () => { + const attestationSecret = "A".repeat(43); const report = await collectOAuthHealthEntriesForCli(Date.now(), { - findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }), + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), + readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), fetchImpl: async () => new Response("unauthorized", { status: 401 }), }); expect(report.codexHealthSource).toBe("management-auth-failed"); @@ -273,8 +291,10 @@ describe("collectOAuthHealthEntriesForCli", () => { }); test("distinguishes an invalid management response from a stopped proxy", async () => { + const attestationSecret = "A".repeat(43); const report = await collectOAuthHealthEntriesForCli(Date.now(), { - findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }), + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), + readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), fetchImpl: async () => new Response("upstream error", { status: 500 }), }); expect(report.codexHealthSource).toBe("management-api-unavailable"); @@ -284,8 +304,10 @@ describe("collectOAuthHealthEntriesForCli", () => { }); test("malformed remote health is re-derived instead of rendering undefined", async () => { + const attestationSecret = "A".repeat(43); const report = await collectOAuthHealthEntriesForCli(Date.now(), { - findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }), + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), + readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), fetchImpl: async () => new Response(JSON.stringify({ accounts: [{ diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 0d732c9b3..ac19a50b5 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -29,6 +29,15 @@ import { LOCAL_ATTESTATION_PROOF_HEADER, verifyLocalAttestationProof, } from "../src/lib/local-management-attestation"; +import { + LOCAL_MANAGEMENT_CAPABILITY_HEADER, + LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER, + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS, + LOCAL_MANAGEMENT_EXPECTED_PID_HEADER, + LOCAL_MANAGEMENT_NONCE_HEADER, + LOCAL_MANAGEMENT_READ_PATHS, + createLocalManagementReadCapability, +} from "../src/lib/local-management-capability"; import { SYSTEM_RESTART_CAPABILITY_HEADER, SYSTEM_RESTART_EXPECTED_PID_HEADER, @@ -209,6 +218,125 @@ describe("management and data-plane credential separation", () => { } }); + test("a local-read capability authorizes only its exact GET path", async () => { + const secret = "A".repeat(43); + const nonce = "B".repeat(43); + const unavailable = { available: false, reason: "injected unavailable state" } as const; + const server = startServer(0, { + localAttestationSecret: secret, + managementAuthState: unavailable, + }); + const headersFor = (path: string, port = server.port, requestNonce = nonce) => { + const expiresAt = Date.now() + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS; + return { + [LOCAL_MANAGEMENT_EXPECTED_PID_HEADER]: String(process.pid), + [LOCAL_MANAGEMENT_NONCE_HEADER]: requestNonce, + [LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER]: String(expiresAt), + [LOCAL_MANAGEMENT_CAPABILITY_HEADER]: createLocalManagementReadCapability( + secret, + requestNonce, + "GET", + path, + process.pid, + port, + expiresAt, + )!, + }; + }; + try { + const memoryHeaders = headersFor(LOCAL_MANAGEMENT_READ_PATHS.systemMemory); + const memory = await fetch(new URL(LOCAL_MANAGEMENT_READ_PATHS.systemMemory, server.url), { + headers: memoryHeaders, + }); + expect(memory.status).toBe(200); + const memoryBody = await memory.json() as { pid?: number; bunVersion?: string }; + expect(memoryBody.pid).toBe(process.pid); + expect(memoryBody.bunVersion).toBe(Bun.version); + + const replay = await fetch(new URL(LOCAL_MANAGEMENT_READ_PATHS.systemMemory, server.url), { + headers: memoryHeaders, + }); + expect(replay.status).toBe(503); + + const memoryCapabilityOnAccounts = await fetch( + new URL(LOCAL_MANAGEMENT_READ_PATHS.codexAccounts, server.url), + { + headers: headersFor( + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + server.port, + "C".repeat(43), + ), + }, + ); + expect(memoryCapabilityOnAccounts.status).toBe(503); + + const accountHeaders = headersFor(LOCAL_MANAGEMENT_READ_PATHS.codexAccounts); + const accounts = await fetch(new URL(LOCAL_MANAGEMENT_READ_PATHS.codexAccounts, server.url), { + headers: accountHeaders, + }); + expect(accounts.status).toBe(200); + + const query = await fetch( + new URL(`${LOCAL_MANAGEMENT_READ_PATHS.codexAccounts}?include=all`, server.url), + { + headers: headersFor( + LOCAL_MANAGEMENT_READ_PATHS.codexAccounts, + server.port, + "E".repeat(43), + ), + }, + ); + expect(query.status).toBe(503); + + const mutation = await fetch(new URL(LOCAL_MANAGEMENT_READ_PATHS.codexAccounts, server.url), { + method: "POST", + headers: { + ...headersFor( + LOCAL_MANAGEMENT_READ_PATHS.codexAccounts, + server.port, + "F".repeat(43), + ), + "content-type": "application/json", + }, + body: "{}", + }); + expect(mutation.status).toBe(503); + + const foreignRoute = await fetch(new URL("/api/config", server.url), { + headers: headersFor( + LOCAL_MANAGEMENT_READ_PATHS.codexAccounts, + server.port, + "G".repeat(43), + ), + }); + expect(foreignRoute.status).toBe(503); + + const wrongPort = await fetch(new URL(LOCAL_MANAGEMENT_READ_PATHS.systemMemory, server.url), { + headers: headersFor( + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + server.port + 1, + "H".repeat(43), + ), + }); + expect(wrongPort.status).toBe(503); + + const principalHeaders = headersFor( + LOCAL_MANAGEMENT_READ_PATHS.systemMemory, + server.port, + "I".repeat(43), + ); + const request = new Request(new URL(LOCAL_MANAGEMENT_READ_PATHS.systemMemory, server.url), { + headers: principalHeaders, + }); + const local = { attestationSecret: secret, pid: process.pid, port: server.port }; + expect(requireManagementAuth(request, unavailable, remoteConfig(), local)).toBeNull(); + expect(managementPrincipal(request, unavailable, remoteConfig(), local)) + .toBe("local-read-capability"); + } finally { + await server.stop(true); + } + }); + test("management-token temp cleanup forgets successful ACL memos and retains failed removals", () => { const temporary = join(testHome, ".admin-token.tmp"); const previousUsername = process.env.USERNAME;