From fcd79c40816f72a75781a8d29b2440404e94f905 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:20:00 +0900 Subject: [PATCH 1/9] feat(deploy): add loopback hub management ingress --- src/config.ts | 55 +++++++++++++++++++++++++++++++- src/server/index.ts | 76 ++++++++++++++++++++++++++++++++++++++++++--- src/types/config.ts | 8 +++++ 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/config.ts b/src/config.ts index 9173c4b353..c2d4158032 100644 --- a/src/config.ts +++ b/src/config.ts @@ -884,6 +884,12 @@ const hubConfigSchema = z.object({ } return origin; }).optional(), + // A malformed hand edit disables only the optional ingress. Live writes are rejected by + // managementIngressConfigError before this load-time degradation can hide the mistake. + managementIngress: z.union([ + z.object({ enabled: z.literal(false) }).strict(), + z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }).strict(), + ]).optional().catch(undefined), }).strict(); const tailscaleUserSchema = z.string().trim().min(1).superRefine((value, ctx) => { @@ -2404,6 +2410,52 @@ function loopbackListenerPortError(value: unknown): string | null { return null; } +/** + * Validate the hub management ingress at the live-write boundary. + * + * The persisted schema intentionally degrades a malformed hand edit to disabled so a typo in + * this opt-in listener cannot discard providers or credentials. A live config mutation must not + * get that leniency: it receives an exact field error before the degrading schema is applied. + */ +function managementIngressConfigError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + const hub = rawConfigRecord(raw.hub); + if (!hub || !Object.hasOwn(hub, "managementIngress") || hub.managementIngress === undefined) return null; + const ingress = rawConfigRecord(hub.managementIngress); + if (!ingress) { + return "schema_invalid: hub.managementIngress: must be an object or omitted"; + } + if (typeof ingress.enabled !== "boolean") { + return "schema_invalid: hub.managementIngress.enabled: must be a boolean"; + } + const keys = Object.keys(ingress); + if (ingress.enabled === false) { + return keys.length === 1 + ? null + : "schema_invalid: hub.managementIngress: disabled ingress accepts only enabled"; + } + if (keys.some(key => key !== "enabled" && key !== "port")) { + return "schema_invalid: hub.managementIngress: contains an unsupported field"; + } + const ingressPort = ingress.port; + if (typeof ingressPort !== "number" || !Number.isInteger(ingressPort) || ingressPort < 1 || ingressPort > 65535) { + return "schema_invalid: hub.managementIngress.port: must be an integer port when enabled"; + } + if (raw.runtimeRole !== "hub") { + return "schema_invalid: hub.managementIngress: enabled ingress requires runtimeRole hub"; + } + const proxyPort = typeof raw.port === "number" ? raw.port : 10100; + if (proxyPort === ingressPort) { + return "schema_invalid: hub.managementIngress.port: must differ from the proxy port"; + } + const loopback = rawConfigRecord(raw.unauthenticatedLoopbackListener); + if (loopback?.enabled === true && loopback.port === ingressPort) { + return "schema_invalid: hub.managementIngress.port: must differ from unauthenticatedLoopbackListener.port"; + } + return null; +} + export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value) @@ -2419,7 +2471,8 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? remoteGuiConfigError(value) ?? clientConnectionConfigError(value) ?? clientRolePairError(value) - ?? loopbackListenerPortError(value); + ?? loopbackListenerPortError(value) + ?? managementIngressConfigError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); if (result.success) { diff --git a/src/server/index.ts b/src/server/index.ts index f96b77b62e..9e809da20a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -753,6 +753,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server; let loopbackServer: Server | null = null; + let managementIngressServer: Server | null = null; + + type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management"; + function ingressForServer(requestServer: Server): ServerIngress { + if (requestServer === loopbackServer) return "unauthenticated-loopback"; + if (requestServer === managementIngressServer) return "hub-management"; + return "public"; + } let backgroundLifecycle: ReturnType | null = null; try { backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy); @@ -963,22 +990,32 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server): Promise { + const ingress = ingressForServer(requestServer); // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing // else. Rejecting here, before any handler runs, is what keeps the surface from growing // silently when a route is added below. - if (requestServer === loopbackServer && !loopbackRouteAllowed(new URL(req.url), req)) { + if (ingress === "unauthenticated-loopback" && !loopbackRouteAllowed(new URL(req.url), req)) { return withCors( formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`), req, loopbackPolicy(), ); } + // Tailscale Serve terminates only on this separately bound loopback socket. Reject before + // dispatch so no data, readiness, health, WebSocket, or unknown-static handler can run. + if (ingress === "hub-management" && !managementIngressRouteAllowed(new URL(req.url), req)) { + return withCors( + formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`), + req, + config, + ); + } // Auth and CORS decisions below read `policy`, not `config`. For the public listener the // two are the same object, so its behaviour is unchanged; for the loopback listener the // view substitutes 127.0.0.1 as the bind address, which is what routes it through the // same code path a plain loopback bind has always taken — Host-header check included. // Routing, provider selection and response bodies keep using `config`. - const policy: RequestPolicyView = requestServer === loopbackServer ? loopbackPolicy() : config; + const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config; const url = new URL(req.url); markActivity(`${req.method} ${url.pathname}`); @@ -1846,7 +1883,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server({ + ...serveOptions, + port: managementIngressPort, + hostname: "127.0.0.1", + }); + } catch (error) { + // Preserve the management bind failure while synchronously initiating rollback of every + // listener already opened in this startup transaction. startServer must not become async. + for (const bound of [loopbackServer, server]) { + if (!bound) continue; + try { void bound.stop(true); } catch { /* report the original bind error */ } + } + throw error; + } + } } catch (error) { userCostOverlayReconciler?.stop(); backgroundLifecycle?.releaseAfterFailedStart(); @@ -2157,6 +2215,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server => { @@ -2168,6 +2227,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server loopbackListenerRef.stop(closeActiveConnections)] : []), + ...(managementIngressRef + ? [() => managementIngressRef.stop(closeActiveConnections)] + : []), async () => { userCostOverlayReconciler?.stop(); }, @@ -2202,6 +2264,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server Date: Fri, 28 Aug 2026 04:22:30 +0900 Subject: [PATCH 2/9] test(deploy): cover hub management ingress boundaries --- tests/loopback-listener-admission.test.ts | 49 ++++++++ tests/loopback-listener-integration.test.ts | 117 ++++++++++++++++++++ tests/oauth-manual-code.test.ts | 38 +++++++ tests/server-management-auth.test.ts | 96 ++++++++++++++++ tests/service.test.ts | 24 ++++ 5 files changed, 324 insertions(+) diff --git a/tests/loopback-listener-admission.test.ts b/tests/loopback-listener-admission.test.ts index f9858b5b08..ca84d78bbc 100644 --- a/tests/loopback-listener-admission.test.ts +++ b/tests/loopback-listener-admission.test.ts @@ -169,6 +169,55 @@ describe("loopback listener configuration", () => { }); }); +describe("hub management ingress configuration", () => { + const candidate = (overrides: Record = {}) => ({ + port: 10100, + runtimeRole: "hub", + hub: { managementIngress: { enabled: true, port: 10101 } }, + providers: { openai: { adapter: "openai", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + defaultProvider: "openai", + ...overrides, + }); + + test("missing and disabled ingress preserve the no-listener default", () => { + const missing = validateConfigCandidate(candidate({ hub: {} })); + expect(missing.ok).toBe(true); + if (missing.ok) expect(missing.config.hub?.managementIngress).toBeUndefined(); + + const disabled = validateConfigCandidate(candidate({ hub: { managementIngress: { enabled: false } } })); + expect(disabled.ok).toBe(true); + if (disabled.ok) expect(disabled.config.hub?.managementIngress).toEqual({ enabled: false }); + }); + + test("enabled ingress requires the hub role", () => { + for (const runtimeRole of [undefined, "standalone", "client"] as const) { + const result = validateConfigCandidate(candidate({ runtimeRole })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("requires runtimeRole hub"); + } + }); + + test("enabled ingress rejects public and unauthenticated-loopback port collisions", () => { + const publicCollision = validateConfigCandidate(candidate({ + hub: { managementIngress: { enabled: true, port: 10100 } }, + })); + expect(publicCollision.ok).toBe(false); + if (!publicCollision.ok) expect(publicCollision.error).toContain("must differ from the proxy port"); + + const loopbackCollision = validateConfigCandidate(candidate({ + unauthenticatedLoopbackListener: { enabled: true, port: 10101 }, + })); + expect(loopbackCollision.ok).toBe(false); + if (!loopbackCollision.ok) expect(loopbackCollision.error).toContain("unauthenticatedLoopbackListener.port"); + }); + + test("a valid hub ingress survives strict parsing", () => { + const result = validateConfigCandidate(candidate()); + expect(result.ok).toBe(true); + if (result.ok) expect(result.config.hub?.managementIngress).toEqual({ enabled: true, port: 10101 }); + }); +}); + describe("injected Codex provider block", () => { test("a wildcard bind alone still emits the env auth header", () => { expect(shouldInjectApiAuthHeader({ hostname: "0.0.0.0" })).toBe(true); diff --git a/tests/loopback-listener-integration.test.ts b/tests/loopback-listener-integration.test.ts index 7ed51757b2..37380029cf 100644 --- a/tests/loopback-listener-integration.test.ts +++ b/tests/loopback-listener-integration.test.ts @@ -25,6 +25,7 @@ import type { OcxConfig } from "../src/types"; import { SERVER_BUDGET_MS } from "./helpers/test-budget"; const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; const previousHome = process.env.OPENCODEX_HOME; let testDir = ""; @@ -46,6 +47,18 @@ function baseConfig(loopbackPort: number | null): OcxConfig { } as unknown as OcxConfig; } +function hubIngressConfig(managementPort: number, loopbackPort: number | null = null): OcxConfig { + return { + ...baseConfig(loopbackPort), + runtimeRole: "hub", + hub: { + managementPublicOrigin: "https://hub.example.test", + managementIngress: { enabled: true, port: managementPort }, + }, + remoteGui: { allowedTailscaleUsers: ["alice@example.test"] }, + }; +} + /** A free port to hand the loopback listener, chosen the same way production would not reuse. */ async function freePort(): Promise { return await findAvailablePort(0, "127.0.0.1"); @@ -83,17 +96,117 @@ beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), "ocx-loopback-listener-")); process.env.OPENCODEX_HOME = testDir; process.env.OPENCODEX_API_AUTH_TOKEN = "public-secret"; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-secret"; }); afterEach(() => { if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; + if (previousAdminToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousAdminToken; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); testDir = ""; }); +describe("hub management ingress", () => { + test("binds only loopback and serves GUI plus authenticated management routes", async () => { + const managementPort = await freePort(); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); + saveConfig(hubIngressConfig(managementPort)); + const server = startServer(publicPort); + try { + const page = await fetch(`http://127.0.0.1:${managementPort}/`, { + headers: { Host: "hub.example.test", "Tailscale-User-Login": "alice@example.test" }, + }); + expect(page.status).not.toBe(404); + + const management = await fetch(`http://127.0.0.1:${managementPort}/api/config`, { + headers: { + Host: "hub.example.test", + Origin: "https://hub.example.test", + "x-opencodex-api-key": "admin-secret", + }, + }); + expect(management.status).toBe(200); + + const address = firstNonLoopbackIPv4(); + if (address) { + const refused = await new Promise(resolve => { + const socket = connect({ host: address, port: managementPort }); + const settle = (value: boolean) => { socket.destroy(); resolve(value); }; + socket.setTimeout(2_000); + socket.once("connect", () => settle(false)); + socket.once("error", () => settle(true)); + socket.once("timeout", () => settle(true)); + }); + expect(refused).toBe(true); + } + } finally { + await server.stop(true); + } + }, SERVER_BUDGET_MS); + + test("default-denies every data, health, readiness, WebSocket, and unknown-static route", async () => { + const managementPort = await freePort(); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); + saveConfig(hubIngressConfig(managementPort)); + const server = startServer(publicPort); + const base = `http://127.0.0.1:${managementPort}`; + try { + const denied: Array<{ path: string; headers?: Record }> = [ + { path: "/v1/catalog" }, + { path: "/healthz" }, + { path: "/readyz" }, + { path: "/v1/responses", headers: { Connection: "Upgrade", Upgrade: "websocket" } }, + { path: "/missing-static.js" }, + ]; + for (const entry of denied) { + const response = await fetch(`${base}${entry.path}`, { headers: entry.headers }); + expect({ path: entry.path, status: response.status }).toEqual({ path: entry.path, status: 404 }); + expect(response.headers.get("content-type")).toContain("application/json"); + } + } finally { + await server.stop(true); + } + }); + + test("a failed management bind rolls back both earlier listeners", async () => { + const managementPort = await freePort(); + const loopbackPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); + const squatter = Bun.serve({ + port: managementPort, + hostname: "127.0.0.1", + fetch: () => new Response("occupied"), + }); + saveConfig(hubIngressConfig(managementPort, loopbackPort)); + try { + expect(() => startServer(publicPort)).toThrow(); + for (const port of [publicPort, loopbackPort]) { + const rebound = Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("ok") }); + await rebound.stop(true); + } + } finally { + await squatter.stop(true); + } + }); + + test("normal shutdown closes public, data-loopback, and management listeners", async () => { + const managementPort = await freePort(); + const loopbackPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: loopbackPort }); + saveConfig(hubIngressConfig(managementPort, loopbackPort)); + const server = startServer(publicPort); + await server.stop(true); + for (const port of [publicPort, loopbackPort, managementPort]) { + const rebound = Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("ok") }); + await rebound.stop(true); + } + }); +}); + describe("unauthenticated loopback listener", () => { test("is absent unless configured, and the public listener still demands a key", async () => { saveConfig(baseConfig(null)); @@ -535,6 +648,10 @@ describe("seams the runtime cannot defend", () => { // holds everywhere. expect(serverSource).toMatch(/port: loopbackListenerPort,\s*\n\s*hostname: "127\.0\.0\.1",/); }); + + test("the hub management ingress binds 127.0.0.1 explicitly", () => { + expect(serverSource).toMatch(/port: managementIngressPort,\s*\n\s*hostname: "127\.0\.0\.1",/); + }); }); describe("public port selection avoids the loopback port", () => { diff --git a/tests/oauth-manual-code.test.ts b/tests/oauth-manual-code.test.ts index a6a12ba889..6e03dd0d15 100644 --- a/tests/oauth-manual-code.test.ts +++ b/tests/oauth-manual-code.test.ts @@ -13,6 +13,7 @@ import { import { parseCallbackInput } from "../src/oauth/callback-server"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; +import { findAvailablePort } from "../src/server/ports"; import type { OcxConfig } from "../src/types"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-manual-code-test"); @@ -324,4 +325,41 @@ describe("OAuth manual login code fallback", () => { await server.stop(true); } }); + + test("headless manual-code route is available through hub management ingress", async () => { + const managementPort = await findAvailablePort(0, "127.0.0.1"); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); + const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; + process.env.OPENCODEX_API_AUTH_TOKEN = "hub-data-secret"; + saveConfig({ + port: 0, + hostname: "0.0.0.0", + runtimeRole: "hub", + hub: { + managementPublicOrigin: "https://hub.example.test", + managementIngress: { enabled: true, port: managementPort }, + }, + oauthOpenBrowser: false, + defaultProvider: "xai", + providers: { xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth" } }, + } as OcxConfig); + const server = startServer(publicPort); + try { + const response = await fetch(`http://127.0.0.1:${managementPort}/api/oauth/login/code`, { + method: "POST", + headers: { + Host: "hub.example.test", + Origin: "https://hub.example.test", + "Content-Type": "application/json", + }, + body: JSON.stringify({ provider: "xai", input: "some-code" }), + }); + expect(response.status).toBe(409); + expect(((await response.json()) as { error?: string }).error).toContain("no login in progress"); + } finally { + await server.stop(true); + if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken; + } + }); }); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index d7713e0cca..091aea7092 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigPath, saveConfig } from "../src/config"; import { startServer } from "../src/server"; +import { findAvailablePort } from "../src/server/ports"; import type { OcxConfig } from "../src/types"; import { serveGuiFile, serveSessionBootstrap } from "../src/server/gui-static"; import { isProxyAdmissionSecret } from "../src/server/auth-cors"; @@ -975,6 +976,57 @@ describe("management and data-plane credential separation", () => { }), httpConfig, state, { trustedTailscaleIngress: true, now })).toBeNull(); }); + test("the live listener trusts Tailscale identity only on hub management ingress", async () => { + const managementPort = await findAvailablePort(0, "127.0.0.1"); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); + const config = hubConfig(); + config.hub = { + ...config.hub, + managementIngress: { enabled: true, port: managementPort }, + }; + saveConfig(config); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const server = startServer(publicPort, { managementAuthState: state }); + const headers = { Host: "hub.example.test", "Tailscale-User-Login": "alice@example.test" }; + try { + const spoofedPublic = await fetch(new URL("/opencodex-session", server.url), { headers }); + expect(spoofedPublic.status).toBe(401); + + const wrongUser = await fetch(`http://127.0.0.1:${managementPort}/opencodex-session`, { + headers: { ...headers, "Tailscale-User-Login": "mallory@example.test" }, + }); + expect(wrongUser.status).toBe(401); + + const issued = await fetch(`http://127.0.0.1:${managementPort}/opencodex-session`, { headers }); + expect(issued.status).toBe(200); + const html = await issued.text(); + const token = /name="opencodex-session-token" content="([^"]+)"/.exec(html)?.[1]; + expect(token).toBeDefined(); + const management = await fetch(`http://127.0.0.1:${managementPort}/api/config`, { + headers: { + Host: "hub.example.test", + Origin: "https://hub.example.test", + "x-opencodex-api-key": token!, + "x-opencodex-gui-origin": "https://hub.example.test", + }, + }); + expect(management.status).toBe(200); + + const adminConsent = await fetch(`http://127.0.0.1:${managementPort}/api/github/star`, { + method: "POST", + headers: { + Host: "hub.example.test", + Origin: "https://hub.example.test", + "x-opencodex-api-key": "admin-secret", + }, + }); + expect(adminConsent.status).toBe(403); + } finally { + await server.stop(true); + } + }); + test("pairing grants are digest-only, origin-bound, single-use, and never accept alternate credentials", () => { const config = hubConfig(); const state = initializeManagementAuthState(config); @@ -1022,6 +1074,50 @@ describe("management and data-plane credential separation", () => { )).toBeNull(); }); + test("the management ingress preserves the one-use pairing exchange contract", async () => { + const managementPort = await findAvailablePort(0, "127.0.0.1"); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); + const config = hubConfig(); + config.hub = { ...config.hub, managementIngress: { enabled: true, port: managementPort } }; + saveConfig(config); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const created = createGuiPairingGrant("https://dashboard.example.test", config, state); + const server = startServer(publicPort, { managementAuthState: state }); + const url = `http://127.0.0.1:${managementPort}/opencodex-session`; + const headers = { + Host: "hub.example.test", + Origin: "https://dashboard.example.test", + "content-type": "application/json", + }; + try { + const adminAttempt = await fetch(url, { + method: "POST", + headers: { ...headers, "x-opencodex-api-key": "admin-secret" }, + body: JSON.stringify({ grant: created.grant }), + }); + expect(adminAttempt.status).toBe(401); + expect(state.pairingGrants.size).toBe(1); + + const exchanged = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ grant: created.grant }), + }); + expect(exchanged.status).toBe(200); + expect(state.pairingGrants.size).toBe(0); + + const replay = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ grant: created.grant }), + }); + expect(replay.status).toBe(401); + } finally { + await server.stop(true); + } + }); + test("non-loopback plaintext HTTP cannot carry a pairing grant, and no opt-in re-opens it", () => { // An earlier revision let this exchange succeed when `remoteGui.allowInsecureHttp` was // true, and this test asserted exactly that. The flag is retired: a reusable grant on diff --git a/tests/service.test.ts b/tests/service.test.ts index f9b4e55cd5..48788a60f3 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -410,6 +410,30 @@ describe("service install auth preflight", () => { expect(() => assertServiceAuthEnvironment()).not.toThrow(); }); + test("hub-mode launchd and systemd installs reuse the protected data-token file", () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + process.env.OPENCODEX_API_AUTH_TOKEN = "phase5-data-secret"; + saveConfig({ + port: 10100, + hostname: "0.0.0.0", + runtimeRole: "hub", + hub: { + managementPublicOrigin: "https://hub.example.test", + managementIngress: { enabled: true, port: 10101 }, + }, + providers: { openai: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1" } }, + defaultProvider: "openai", + } as OcxConfig); + + expect(() => assertServiceAuthEnvironment()).not.toThrow(); + for (const definition of [buildUnit(), buildPlist()]) { + expectTextToContainPath(definition, serviceApiTokenFilePath()); + expect(definition).not.toContain("phase5-data-secret"); + } + }); + test("rejects restore operations from a different CODEX_HOME than service install", () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); From a8fdcf6c50facd41bf3d629c1bbf9ddc1f2c54e1 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:24:57 +0900 Subject: [PATCH 3/9] feat(deploy): harden management ingress allowlist --- src/server/index.ts | 22 ++++++++++++++++----- tests/loopback-listener-integration.test.ts | 1 + 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 9e809da20a..43c159d78c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -811,12 +811,24 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { try { const denied: Array<{ path: string; headers?: Record }> = [ { path: "/v1/catalog" }, + { path: "/v1%2Fcatalog" }, { path: "/healthz" }, { path: "/readyz" }, { path: "/v1/responses", headers: { Connection: "Upgrade", Upgrade: "websocket" } }, From 7d8b878fdf22ab92458abb4d0ed26465aa9748bc Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:25:03 +0900 Subject: [PATCH 4/9] docs(deploy): add remote hub deployment guide --- docs-site/astro.config.mjs | 1 + .../src/content/docs/guides/remote-hub.md | 229 ++++++++++++++++++ structure/01_runtime.md | 19 +- structure/05_gui-and-management-api.md | 24 +- structure/06_docs-and-release.md | 19 ++ 5 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 docs-site/src/content/docs/guides/remote-hub.md diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index f80cf2e90f..d1ff26edcc 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -85,6 +85,7 @@ export default defineConfig({ label: "Guides", translations: { fr: "Guides", ko: "가이드", "zh-CN": "指南", "zh-TW": "指南", ru: "Руководства", ja: "ガイド", tr: "Kılavuzlar" }, items: [ + { label: "Remote Hub Deployment", slug: "guides/remote-hub" }, { label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" }, { label: "Factory Droid Bridge", translations: { fr: "Pont Factory Droid", ko: "Factory Droid 브리지" }, slug: "guides/factory-droid" }, { label: "Model Routing", translations: { fr: "Routage des modèles", ko: "모델 라우팅", "zh-CN": "模型路由", "zh-TW": "模型路由", ru: "Маршрутизация моделей", ja: "モデルルーティング", tr: "Model Yönlendirme" }, slug: "guides/model-routing" }, diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md new file mode 100644 index 0000000000..b1d4a08a76 --- /dev/null +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -0,0 +1,229 @@ +--- +title: Remote Hub Deployment +description: Run an opencodex hub on Linux, macOS, or Docker with a loopback-only management ingress, Tailscale Serve, and headless OAuth. +--- + +An opencodex hub keeps provider credentials and usage state on one host while authenticated clients +use its data plane remotely. The browser-facing management plane is separate: an optional listener +binds only `127.0.0.1`, serves the dashboard and `/api/*`, and is intended to sit behind Tailscale +Serve or another operator-owned HTTPS frontend. + +The management ingress never serves `/v1/*`, `/healthz`, `/readyz`, or WebSockets. Do not publish its +port directly, do not add a cloud-firewall rule for it, and do not use Tailscale Funnel. Funnel is a +public-internet surface and is outside this deployment model. + +## Trust and consent boundaries + +- Provider and OAuth credentials stay on the hub. Never copy them into a client, image layer, + service definition, support bundle, screenshot, or command line. +- The data admission token is delivered through the owner-only `service-api-token` file or + `OCX_API_TOKEN_FILE`. It is not a management credential. +- A raw management admin token can perform ordinary administration, but it cannot mint a browser + session or authorize consent-bearing actions such as starring the repository. Those actions + require a server-issued `gui-session`, matching browser origin, and CSRF token. +- `Tailscale-User-Login` is trusted only on the separately bound management ingress. The same header + on the public listener is ignored. `remoteGui.allowedTailscaleUsers` controls session issuance; it + does not create a new general-purpose principal. + +## Linux systemd or macOS launchd + +Choose the hub's Tailscale address for the data listener and the exact browser-visible HTTPS origin +for management. The values below are examples: + +```bash +ocx config set runtimeRole hub +ocx config set hostname 100.64.0.10 +ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' +ocx config set corsAllowOrigins '["http://localhost:10100"]' +ocx config set hub.managementIngress '{"enabled":true,"port":10101}' +ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' + +# Generate/read this in a protected operator shell or secret manager. +# It is a data-admission token, not a provider credential. +export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)" +ocx service install +ocx service status +``` + +`ocx service install` copies the token into the existing owner-only `service-api-token` path. The +launchd plist and systemd user unit read that protected file when the process starts; neither embeds +the literal token. Do not paste the value into `ocx config show`, unit/plist output, screenshots, or +support bundles. + +Prove liveness and readiness on the public data listener: + +```bash +curl --fail --silent http://100.64.0.10:10100/healthz +curl --fail --silent http://100.64.0.10:10100/readyz +``` + +A `200` from `/healthz` proves only that the process is alive. Deployment acceptance also requires +`/readyz`, an authenticated `GET /v1/catalog`, and one real routed response. + +## Tailscale Serve + +First prove the management socket is loopback-only, then publish it through Serve: + +```bash +ss -ltnp | grep 10101 # Linux: expected 127.0.0.1:10101 only +lsof -nP -iTCP:10101 -sTCP:LISTEN # macOS: expected 127.0.0.1 only + +tailscale serve --bg --https=443 http://127.0.0.1:10101 +tailscale serve status +``` + +Set `hub.managementPublicOrigin` to the exact HTTPS origin shown by Serve. Add the operator's exact +Tailscale login to `remoteGui.allowedTailscaleUsers`; an empty list means no remote identity can mint +a session. Verify both directions: + +```bash +# Negative: the loopback-only port must not be reachable through the node's tailnet address. +curl --fail --connect-timeout 3 http://100.64.0.10:10101/ && echo "unexpected exposure" + +# Positive: the HTTPS dashboard loads through Serve from an allowed tailnet user. +curl --fail --silent --show-error https://hub-name.tailnet-name.ts.net/ >/dev/null +``` + +The positive browser test must use a real signed-in Tailscale session; a bare `curl` may not carry the +identity headers needed for automatic session issuance. Pairing remains the fallback when the HTTPS +frontend cannot provide trustworthy Tailscale identity. + +### Operator-owned ts.net certificate proxy + +If you operate your own TLS proxy, obtain a certificate only for the full ts.net FQDN: + +```bash +tailscale cert hub-name.tailnet-name.ts.net +``` + +Protect the private key, renew it through Tailscale's supported mechanism, and proxy only to +`127.0.0.1:10101`. A generic TLS proxy does not supply trustworthy Tailscale identity. Do not +fabricate `Tailscale-User-*` headers; use the single-use, origin-bound pairing flow instead. + +## Headless OAuth + +Disable browser launch on the hub: + +```bash +ocx config set oauthOpenBrowser false +``` + +1. From the authenticated remote dashboard or management client, start `POST /api/oauth/login` for + the provider. The hub returns the authorization URL and instructions without opening a browser. +2. Open the URL on the operator's machine and authorize there. +3. If the loopback callback cannot reach the hub, paste the final redirect URL or code into the + dashboard/CLI. It sends `POST /api/oauth/login/code` with `{provider,input}`. +4. Poll the existing status endpoint until complete, then make a routed model request. + +Never put the OAuth code in shell argv, logs, issue text, screenshots, or deployment evidence. The +manual-code route keeps its existing unknown-provider, no-active-flow, invalid-code, and 4096-byte +input checks. + +## Operator-owned Docker recipe + +opencodex does not publish or maintain an official container image. The following recipe is an +operator-owned starting point. Before building, resolve `oven/bun:1.4.0` to a registry digest and +replace both `REPLACE_WITH_BUN_1_4_0_DIGEST` values. A tag alone is not a production pin. + +```dockerfile +# syntax=docker/dockerfile:1 +FROM oven/bun:1.4.0@sha256:REPLACE_WITH_BUN_1_4_0_DIGEST AS build +WORKDIR /home/bun/app +COPY --chown=bun:bun package.json bun.lock ./ +RUN bun install --frozen-lockfile +COPY --chown=bun:bun src ./src +COPY --chown=bun:bun gui ./gui +COPY --chown=bun:bun tsconfig.json ./ +RUN cd gui && bun install --frozen-lockfile && bun run build + +FROM oven/bun:1.4.0@sha256:REPLACE_WITH_BUN_1_4_0_DIGEST AS runtime +WORKDIR /home/bun/app +ENV OPENCODEX_HOME=/home/bun/.opencodex +ENV OCX_API_TOKEN_FILE=/run/secrets/ocx_api_token +COPY --from=build --chown=bun:bun /home/bun/app/package.json ./package.json +COPY --from=build --chown=bun:bun /home/bun/app/bun.lock ./bun.lock +COPY --from=build --chown=bun:bun /home/bun/app/node_modules ./node_modules +COPY --from=build --chown=bun:bun /home/bun/app/src ./src +COPY --from=build --chown=bun:bun /home/bun/app/gui/dist ./gui/dist +USER bun +VOLUME ["/home/bun/.opencodex"] +EXPOSE 10100 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD ["bun", "-e", "const r=await fetch('http://127.0.0.1:10100/healthz');if(!r.ok)process.exit(1)"] +CMD ["bun", "run", "src/cli/index.ts", "start", "--port", "10100"] +``` + +An example Compose definition keeps mutable state and the token outside the image: + +```yaml +services: + hub: + build: . + read_only: true + ports: + - "10100:10100" + volumes: + - ocx-state:/home/bun/.opencodex + tmpfs: + - /tmp + secrets: + - source: ocx_api_token + target: ocx_api_token + uid: "1000" + gid: "1000" + mode: 0440 + restart: unless-stopped + +volumes: + ocx-state: + +secrets: + ocx_api_token: + file: ./secrets/ocx_api_token +``` + +Initialize the named volume before the first normal start. Container port publishing requires the +data listener to bind `0.0.0.0`; the management listener remains fixed to container loopback: + +```bash +docker compose run --rm hub bun run src/cli/index.ts config set runtimeRole hub +docker compose run --rm hub bun run src/cli/index.ts config set hostname 0.0.0.0 +docker compose run --rm hub bun run src/cli/index.ts config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' +docker compose run --rm hub bun run src/cli/index.ts config set hub.managementIngress '{"enabled":true,"port":10101}' +docker compose run --rm hub bun run src/cli/index.ts config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' +docker compose up -d +``` + +Do not put a token in `ARG`, `ENV`, `COPY`, Compose YAML, image history, or the command line. Do not +mount the Docker socket, host home, Codex home, SSH agent, or provider-key files. Publish only port +`10100`. A management ingress bound to `127.0.0.1:10101` inside the container is reachable only by a +TLS/tailnet frontend in the same network namespace; never publish `10101` as a shortcut. + +After the container is healthy, run a separate readiness promotion check: + +```bash +docker compose exec hub bun -e \ + "const r=await fetch('http://127.0.0.1:10100/readyz');console.log(r.status,await r.text());if(!r.ok)process.exit(1)" + +docker compose exec hub bun -e \ + "const t=(await Bun.file('/run/secrets/ocx_api_token').text()).trim();const r=await fetch('http://127.0.0.1:10100/v1/catalog',{headers:{'x-opencodex-api-key':t}});console.log(r.status);if(!r.ok)process.exit(1)" +``` + +Then send one real authenticated routed response with a configured model. If the secret is absent or +unreadable, a non-loopback hub must not be accepted as ready. Never treat liveness alone as proof. + +## Rollback + +Inspect existing Serve mappings before changing them. `tailscale serve reset` removes every mapping +on the node; use a narrower supported removal command when unrelated mappings exist. + +```bash +tailscale serve status +tailscale serve reset +ocx config set hub.managementIngress '{"enabled":false}' +ocx service repair +``` + +For a container rollback, remove or replace the container while retaining the named state volume. +For a service rollback, stop the branch service and repair the prior release against the same +`OPENCODEX_HOME`. Disabling management ingress or Serve does not require changing the data listener. diff --git a/structure/01_runtime.md b/structure/01_runtime.md index f6bdaa1740..3cc98a81a2 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -7,7 +7,7 @@ | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | -| `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, and facade re-exports for split server modules. | +| `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/config.ts` | Persisted `~/.opencodex/config.json` schema, defaults, migrations, transactions, and compatibility re-exports for split config modules. | | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | @@ -53,6 +53,23 @@ until shutdown. Normal shutdown restores native Codex. Service mode sets `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and uninstall still restore. +`startServer` composes up to three sockets in one synchronous startup transaction: the public data +listener, the optional unauthenticated data-loopback listener, and the optional hub-management +listener. The hub-management socket is enabled only by `runtimeRole: "hub"` plus +`hub.managementIngress.enabled`, always binds `127.0.0.1`, and default-denies everything except GUI, +session bootstrap/exchange, and `/api/*`. A failed optional bind initiates rollback of every earlier +socket; normal stop joins all bound sockets before lifecycle release. The existing launchd/systemd +installer remains the service owner and continues loading the data token from `service-api-token`; +hub mode adds no service-manager fork and no token-bearing unit/plist field. + +[Decision Log] +- 목적과 의도: Give a headless hub a browser management ingress without widening its data plane or trusting spoofable forwarding headers on the public listener. +- 기존 구현 및 제약 조건: `startServer` is synchronous through Lab activation, already owns an optional-listener transaction, and the service installer already has an owner-only token-file flow. +- 검토한 주요 대안: Add management routes to the public listener; infer trusted ingress from `Host`/`Forwarded`/Tailscale headers; create a separate service manager; extend the existing composition root. +- 선택한 방식: Bind a third socket exactly to `127.0.0.1`, select trust by receiving `Bun.serve` instance, keep a fixed route allowlist, and reuse the current launchd/systemd definitions. +- 다른 대안 대신 이 방식을 선택한 이유: Headers do not prove which transport received a request, while a kernel loopback bind plus Tailscale Serve supplies a concrete ingress boundary without duplicating lifecycle or secret delivery. +- 장점, 단점 및 영향: Public/default behavior stays unchanged and management can use Tailscale identity; operators must provide a co-located HTTPS frontend and pairing remains necessary for generic TLS proxies. + The process-state boundary deliberately exposes two PID checks. `readAlivePid()` is the cheap non-destructive probe used by liveness polling. `readPid()` and `verifyPidIdentity()` include the fixed-path command-line check required before stop, kill, port reclaim, or stale-state deletion. diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 4294a24551..d6d374a149 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -61,11 +61,25 @@ management token creation, validation, or permission hardening fails, every `/ap must be checked explicitly because an `icacls` timeout is a soft failure in the shared secret helper. Local dashboard page entry requires a loopback binding, a valid parseable loopback `Host`, and an -exact request origin. A non-loopback dashboard uses the management token flow instead. The server -issues an in-memory session for five minutes, capped at 128 live sessions. The session is bound to the -exact protocol, host, and port; state-changing requests additionally require the session CSRF token. -The dashboard never attaches its management session to `/v1/*` requests, and pages containing a -session bootstrap are served with `Cache-Control: no-store`. +exact request origin. A hub may additionally enable `hub.managementIngress`, a second management +surface bound exactly to `127.0.0.1` for a local Tailscale Serve or operator TLS frontend. That +listener serves only packaged GUI/SPA routes, `GET`/`POST /opencodex-session`, and `/api/*`; all data, +health, readiness, WebSocket, and unknown-static routes receive a JSON 404 before dispatch. + +Tailscale identity headers authorize session issuance only when the request arrived on that specific +listener and the exact login appears in `remoteGui.allowedTailscaleUsers`. The public listener and +the unauthenticated data-loopback listener always pass `trustedTailscaleIngress: false`, regardless +of `Host`, `Origin`, `Forwarded`, `X-Forwarded-*`, or `Tailscale-User-*` values. A generic TLS proxy +cannot establish that identity and uses the existing single-use, digest-only, origin-bound pairing +exchange. Pairing accepts no admin/data credential substitute and consumes a grant only after the +full origin predicate succeeds. + +The server issues a local in-memory session for five minutes or a remote session for twelve hours, +with 128 live sessions maximum. Every session is bound to the exact server and browser origins; +state-changing requests additionally require the session CSRF token. A raw admin token remains +ordinary management authority only and cannot satisfy consent routes. The dashboard never attaches +its management session to `/v1/*` requests, and pages containing a session bootstrap are served with +`Cache-Control: no-store`. Proxy admission credentials must never reach an upstream provider. The forwarding guard rejects the `ocx_data_`, `ocx_admin_`, and `ocx_session_` prefixes, historical keys matching diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index 71c86df6f9..5c8f7346ad 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -35,6 +35,25 @@ bun install --frozen-lockfile bun run build ``` +## Container deployment recipe + +Phase-5 remote-hub documentation includes an operator-owned multi-stage Dockerfile and Compose +example in `guides/remote-hub`; the repository intentionally ships no root `Dockerfile`, +`.dockerignore`, registry image, or publish workflow. An official image would create a release +surface that also requires maintained base-image digest updates, vulnerability scanning, SBOM, +signing, registry provenance, rollback, and support policy. Until those controls have an explicit +owner, the guide requires operators to pin the Bun base digest, run non-root, persist +`OPENCODEX_HOME`, mount the data token through `OCX_API_TOKEN_FILE`, and prove liveness, readiness, +authenticated catalog access, and a real routed response themselves. + +[Decision Log] +- 목적과 의도: Document a reproducible container topology without silently creating an official image channel. +- 기존 구현 및 제약 조건: The repository has no maintained Docker release artifacts, registry workflow, scanner, SBOM/signing chain, or image rollback policy. +- 검토한 주요 대안: Add a root Dockerfile and publish it; omit containers entirely; provide a complete operator-owned recipe in the remote-hub guide. +- 선택한 방식: Keep the recipe in documentation, require an operator-resolved base digest and mounted secret file, and publish only the public data port. +- 다른 대안 대신 이 방식을 선택한 이유: A source recipe communicates the supported runtime contract while leaving image provenance and operations with the party building it. +- 장점, 단점 및 영향: Docker users have a concrete starting point, but opencodex does not claim to ship, scan, sign, or support the resulting image. + ## Windows service wrapper and incomplete updates [Decision Log] From 5cdef72371d0ee1037adde833a9a34b50b148896 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:29:41 +0900 Subject: [PATCH 5/9] fix(client): a hub without client state is disconnected, not mismatched (first oracle dogfood boot) --- src/client/state.ts | 7 ++++--- tests/client-connect.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/client/state.ts b/src/client/state.ts index 3947b383cd..a28ff01fc5 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -38,9 +38,10 @@ export function readClientConnectionState(): ClientConnectionState { return { kind: "invalid", reason: "config.json.runtimeRole is invalid" }; } if (!hasClient && (role === undefined || role === "standalone")) return { kind: "disconnected" }; - if (!hasClient && role === "hub") { - return { kind: "mismatched", reason: "runtimeRole=hub cannot be used as a connected client" }; - } + // A hub is a server role, not a broken client: without client state it simply is not + // connected, and refusing here blocked `ocx start` on every hub (found on the first + // clisu-oracle dogfood boot). Hub role WITH client state remains mismatched below. + if (!hasClient && role === "hub") return { kind: "disconnected" }; if (!hasClient || role !== "client") { return { kind: "mismatched", diff --git a/tests/client-connect.test.ts b/tests/client-connect.test.ts index 00d236c71c..7e9f0563f0 100644 --- a/tests/client-connect.test.ts +++ b/tests/client-connect.test.ts @@ -31,6 +31,30 @@ function readyBody(protocol = 1, minimumClientProtocol = 1) { } describe("remote hub client boundary", () => { + test("runtimeRole=hub without client state reads as disconnected so the hub can start", () => { + // First clisu-oracle dogfood boot: the hub role refused 'ocx start' because the + // client-state reader classified role=hub (no client block) as mismatched. A hub + // is a server; without client state it is simply not a connected client. + const readScript = ` + const { readClientConnectionState } = require("./src/client/state"); + console.log(JSON.stringify(readClientConnectionState())); + `; + const home = mkdtempSync(join(tmpdir(), "ocx-hub-role-")); + const readState = () => { + const child = spawnSync(process.execPath, ["--eval", readScript], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: home }, + encoding: "utf8", + }); + return JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}"); + }; + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub" })); + expect(readState().kind).toBe("disconnected"); + // Hub role WITH a client block stays mismatched (the honest conflict). + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub", client: { serverUrl: "https://hub.example.test" } })); + expect(readState().kind).toBe("mismatched"); + rmSync(home, { recursive: true, force: true }); + }); test("canonicalizes origin and terminal /v1 only", () => { expect(normalizeHubOrigin("https://hub.example.test/v1")).toBe("https://hub.example.test"); expect(normalizeHubOrigin("https://hub.example.test/v1/")).toBe("https://hub.example.test"); From bf221bc26fc6db15f8e1bb8a50cf4b70fad05be4 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:34:13 +0900 Subject: [PATCH 6/9] fix(hub): the hub role never rewrites its host client configs on start (oracle dogfood) --- src/cli/claude-agent-startup-sync.ts | 3 +++ src/codex/desired-state.ts | 16 +++++++++++++--- tests/codex-desired-state.test.ts | 11 +++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/cli/claude-agent-startup-sync.ts b/src/cli/claude-agent-startup-sync.ts index 10751ae8de..772a88ddee 100644 --- a/src/cli/claude-agent-startup-sync.ts +++ b/src/cli/claude-agent-startup-sync.ts @@ -54,6 +54,9 @@ export async function syncClaudeAgentDefsAtProxyStartup( const warn = deps.warn ?? (message => console.warn(message)); try { + // Hub role: never rewrite this host's ~/.claude roster on startup (same rule as + // shouldSyncCodexOnStart / shouldSyncGrokOnStart — the hub serves other machines). + if (config.runtimeRole === "hub") return null; if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) { return inject(config, {}); } diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts index 320e077050..b750592403 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -71,7 +71,14 @@ export function codexIntegrationEnabled(config: Pick): boolean { +export function shouldSyncCodexOnStart(config: Pick): boolean { + // A hub is a server for OTHER machines: it must not rewrite its own host's + // Codex/Claude/Grok client configs on startup (interview decision Q6, and the + // first clisu-oracle dogfood boot proved the failure mode — the hub marked + // /readyz failed because it tried to run the full local client sync). + // "Hub is also a client" stays possible by explicitly enabling integrations + // later; the ROLE alone never injects. + if (config.runtimeRole === "hub") return false; return codexIntegrationEnabled(config); } @@ -182,7 +189,7 @@ export function setClaudeDesktopIntegrationEnabled(enabled: boolean): CodexDesir */ export async function syncCodexOnStartIfEnabled( port: number, - config: Pick, + config: Pick, sync: CodexStartupSync = defaultStartupSync, readinessGate?: ReadinessGate, ): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean }> { @@ -225,6 +232,9 @@ async function defaultStartupSync(port: number): Promise): boolean { +export function shouldSyncGrokOnStart(config: Pick): boolean { + // Same hub rule as shouldSyncCodexOnStart: the hub role never rewrites its + // host's client configs on startup. + if (config.runtimeRole === "hub") return false; return grokIntegrationEnabled(config); } diff --git a/tests/codex-desired-state.test.ts b/tests/codex-desired-state.test.ts index e069955b23..4838afcd5f 100644 --- a/tests/codex-desired-state.test.ts +++ b/tests/codex-desired-state.test.ts @@ -192,6 +192,17 @@ describe("the startup gate", () => { expect(shouldSyncCodexOnStart({ ...baseConfig(), clientIntegrations: { codex: false } })).toBe(false); }); + test("the hub role never syncs its host's client configs on start", () => { + // First clisu-oracle dogfood boot: runtimeRole=hub ran the full local client + // sync, marked /readyz failed on provider-discovery noise, and rewrote + // ~/.grok/config.toml on a machine that is a SERVER for other machines. + expect(shouldSyncCodexOnStart({ ...baseConfig(), runtimeRole: "hub" })).toBe(false); + expect(shouldSyncGrokOnStart({ ...baseConfig(), runtimeRole: "hub" })).toBe(false); + // client/standalone roles keep today's behavior. + expect(shouldSyncCodexOnStart({ ...baseConfig(), runtimeRole: "standalone" })).toBe(true); + expect(shouldSyncGrokOnStart({ ...baseConfig(), runtimeRole: "standalone" })).toBe(true); + }); + test("absence, an empty object, and an explicit true all still sync", async () => { for (const clientIntegrations of [undefined, {}, { codex: true }]) { let calls = 0; From 0a73858a1d12b556e8bb89cc10c87bedf60a7893 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:40:59 +0900 Subject: [PATCH 7/9] fix(connect): seed default config on a fresh machine instead of refusing the commit (dogfood) --- src/client/state.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/client/state.ts b/src/client/state.ts index a28ff01fc5..e702a30f8b 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -2,8 +2,10 @@ import { readFileSync } from "node:fs"; import { getConfigPath, deleteConfigTopLevelKey, + getDefaultConfig, mutatePersistedConfig, readConfigDiagnostics, + saveConfig, } from "../config"; import type { OcxClientConnectionConfig } from "../types"; @@ -71,6 +73,18 @@ export function commitClientConnection( return { changed: !unchanged, value: undefined }; }); if (outcome.status === "committed" || outcome.status === "unchanged") return outcome.status; + if (outcome.status === "unavailable" && outcome.reason === "missing") { + // First ocx run on a fresh machine: ocx connect is the expected first command in + // client mode, so there is no config.json yet. mutatePersistedConfig correctly + // refuses to invent one (a lost config must fail closed), but a genuinely absent + // file is the bootstrap case, not corruption — seed defaults plus the client + // block atomically. Found on the first MacBook↔oracle dogfood connect. + const seeded = getDefaultConfig(); + seeded.runtimeRole = "client"; + seeded.client = structuredClone(state); + saveConfig(seeded); + return "committed"; + } throw new Error(`client state commit unavailable: ${"reason" in outcome ? outcome.reason : "unknown"}`); } From 7bf28233df456561bc8e8e6c1f0a5316b1df769b Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:42:17 +0900 Subject: [PATCH 8/9] =?UTF-8?q?docs(devlog):=20clisu-oracle=20dogfood=20re?= =?UTF-8?q?cord=20=E2=80=94=20full=20connect=20lifecycle=20proven=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260827_remote_hub/090_dogfood_record.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 devlog/_plan/260827_remote_hub/090_dogfood_record.md diff --git a/devlog/_plan/260827_remote_hub/090_dogfood_record.md b/devlog/_plan/260827_remote_hub/090_dogfood_record.md new file mode 100644 index 0000000000..162ebecc02 --- /dev/null +++ b/devlog/_plan/260827_remote_hub/090_dogfood_record.md @@ -0,0 +1,34 @@ +# 090 — Dogfood record: clisu-oracle hub + MacBook client (2026-08-28) + +Branch build @ f98081fbf. Hub: clisu-oracle (aarch64), OPENCODEX_HOME=~/.opencodex-hub, +bind 100.100.245.81:10190, data token file-fed, remoteGui.allowInsecureHttp=true, +hub.managementPublicOrigin=http://100.100.245.81:10190, corsAllowOrigins += http://localhost:10100. +Client: this MacBook, isolated OPENCODEX_HOME/CODEX_HOME under /tmp/ocx-dogfood-SzfA +(real user config untouched; the temp grok rewrite from the earlier standalone probe was +reverted to :10100). + +Proven end-to-end (commands + outputs in session log): +1. /readyz over tailnet: status ready, protocol 1, managementUrl advertised. +2. /v1/catalog over tailnet: 401 without token; 200 + strong ETag + Cache-Control + private,no-cache with the data token (516 KB). +3. Admin token over plain HTTP refused by connect ("Admin credentials may be sent only + over HTTPS") — HTTPS-only admin rule enforced live. +4. ocx gui pair --origin http://localhost:10100 issued a single-use grant (json shape). +5. ocx connect --pairing-code-stdin --allow-insecure-http --clients codex: + full transaction — grant exchanged, per-client key 085da5fb… auto-issued, key stored + ONLY in service-api-token (0600, 50 bytes), catalog placed atomically (262 KB), + dedicated provider block injected (base_url hub, env_key contract, absolute + model_catalog_json), client state committed with apiKeyId. +6. Real routed completion through the hub with the per-client key: gpt-5.6-luna answered + "HUB_OK" (chat.completions 200). +7. Usage attribution on the hub: the request row carries apiKeyId 085da5fb…, + admissionKind configured — per-machine slice works. +8. ocx disconnect: injected config restored byte-identically to the seeded original, + token file deleted, client state cleared, reminder to revoke the still-valid key via + hub GUI (by design — operator-owned revocation). + +Three live defects found and fixed during dogfood (each with a regression test): +- 596bb02f3 runtimeRole=hub refused ocx start (state read). +- 19eb6a4bd hub role ran local client syncs on start (readyz failed + grok rewrite). +- f98081fbf connect refused to commit on a fresh machine with no config.json. + From 76257cc87437356218b818ba6b42bc3842cec4c1 Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 17:41:18 +0900 Subject: [PATCH 9/9] test(deploy): assert the ingress role rule where the message is actually reachable tests/loopback-listener-admission.test.ts asserted that every non-hub role is refused with "requires runtimeRole hub", looping over undefined, standalone, and client. The client case is refused earlier, by the separate rule that a client role needs a complete client connection block, so the assertion was testing an ordering that two independent validation rules never promised. Split rather than loosened. undefined and standalone still assert the exact ingress message. client asserts only that it is refused, because that is the guarantee the config actually makes for a role that is incomplete on its own. A second case closes the gap this would otherwise open: with a COMPLETE client connection, so the earlier rule no longer fires, the ingress rule is what refuses. Without it, the weaker client assertion could pass even if the ingress rule stopped applying to that role entirely. Also keeps both sides of two rebase conflicts. This phase inserts a management ingress pairing-exchange test ahead of the plaintext-pairing test that phase 2 rewrote, and structure/01_runtime.md gained a codex-cli-update sentence on dev and a hub-management-listener clause here; both rows carry both. --- tests/loopback-listener-admission.test.ts | 32 ++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/loopback-listener-admission.test.ts b/tests/loopback-listener-admission.test.ts index ca84d78bbc..e45c8bf19f 100644 --- a/tests/loopback-listener-admission.test.ts +++ b/tests/loopback-listener-admission.test.ts @@ -190,11 +190,41 @@ describe("hub management ingress configuration", () => { }); test("enabled ingress requires the hub role", () => { - for (const runtimeRole of [undefined, "standalone", "client"] as const) { + // Every non-hub role is rejected. Only the two roles that are otherwise complete can be + // asserted on THIS message, though: `client` is refused earlier, by the rule that a + // client role needs a full client connection block. Asserting the ingress wording for it + // would be asserting an order these two independent rules do not promise, so the + // requirement checked for `client` is that it is refused at all. + for (const runtimeRole of [undefined, "standalone"] as const) { const result = validateConfigCandidate(candidate({ runtimeRole })); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain("requires runtimeRole hub"); } + + const asClient = validateConfigCandidate(candidate({ runtimeRole: "client" })); + expect(asClient.ok).toBe(false); + }); + + test("a complete client connection still cannot enable hub ingress", () => { + // Proves the row above is not hiding a gap: once the client role IS complete, so the + // earlier rule no longer fires, the ingress rule is what refuses it. + const result = validateConfigCandidate(candidate({ + runtimeRole: "client", + client: { + serverUrl: "https://hub.example.test", + managementUrl: "https://hub.example.test", + managementTransport: "direct", + selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "client-key-1", + tokenFingerprint: "a".repeat(64), + protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", + catalogSyncedAt: "2026-08-28T00:00:00.000Z", + }, + })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("requires runtimeRole hub"); }); test("enabled ingress rejects public and unauthenticated-loopback port collisions", () => {