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. + 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/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/client/state.ts b/src/client/state.ts index 3947b383cd..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"; @@ -38,9 +40,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", @@ -70,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"}`); } 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/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..43c159d78c 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 +1002,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 +1895,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 +2227,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server => { @@ -2168,6 +2239,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server loopbackListenerRef.stop(closeActiveConnections)] : []), + ...(managementIngressRef + ? [() => managementIngressRef.stop(closeActiveConnections)] + : []), async () => { userCostOverlayReconciler?.stop(); }, @@ -2202,6 +2276,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + 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"); 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; diff --git a/tests/loopback-listener-admission.test.ts b/tests/loopback-listener-admission.test.ts index f9858b5b08..e45c8bf19f 100644 --- a/tests/loopback-listener-admission.test.ts +++ b/tests/loopback-listener-admission.test.ts @@ -169,6 +169,85 @@ 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", () => { + // 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", () => { + 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..d2d695bb28 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,118 @@ 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: "/v1%2Fcatalog" }, + { 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 +649,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 });