From 149b7215aaa17967f45e39d07c5241dbe292fa83 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:20:00 +0900 Subject: [PATCH 1/8] 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 25d0714efa..ec97934745 100644 --- a/src/config.ts +++ b/src/config.ts @@ -870,6 +870,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) => { @@ -2370,6 +2376,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) @@ -2385,7 +2437,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 78aaabc83b..d85e338763 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -657,6 +657,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); @@ -863,22 +890,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}`); @@ -1671,7 +1708,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(); @@ -1966,6 +2024,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server => { @@ -1977,6 +2036,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server loopbackListenerRef.stop(closeActiveConnections)] : []), + ...(managementIngressRef + ? [() => managementIngressRef.stop(closeActiveConnections)] + : []), async () => { userCostOverlayReconciler?.stop(); }, @@ -2011,6 +2073,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server Date: Fri, 28 Aug 2026 04:22:30 +0900 Subject: [PATCH 2/8] 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 3eb35155f5..4ab6da6df7 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("insecure HTTP pairing is explicit and a refused exchange can be retried after opt-in", () => { const config = hubConfig("http://hub.example.test"); const state = initializeManagementAuthState(config); diff --git a/tests/service.test.ts b/tests/service.test.ts index 29712adca9..d1390c33e0 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -345,6 +345,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 d6461bfd2317ea70d5bac79c472b1740b5bde6cc Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:24:57 +0900 Subject: [PATCH 3/8] 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 d85e338763..733507576f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -716,12 +716,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 7d7faf17f2fb86ddefd8f4f14272435a1f5f3202 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:25:03 +0900 Subject: [PATCH 4/8] 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 12995b8c93..3164bfb393 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. | | `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. 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 596bb02f3f0a806a2ee3dde316940259b5004302 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:29:41 +0900 Subject: [PATCH 5/8] 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 9a589e0cfe..3bccbdce45 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 19eb6a4bd797cdd0d1188114b7a61cbd038edb63 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:34:13 +0900 Subject: [PATCH 6/8] 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 f98081fbf3a61cc9b463ff05a77a5e6db2f1cd13 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:40:59 +0900 Subject: [PATCH 7/8] 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 a62c8eba20ec1ecc7531f2ce8eb2310004052dde Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:42:17 +0900 Subject: [PATCH 8/8] =?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. +