From eda63563d2942d70bab8d6c8e520094f1ae0d4e2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 11 Sep 2026 12:10:04 +0900 Subject: [PATCH 1/7] docs(remote-hub): the one-port recipe, invite flow, and launchd semantics (en+ko) The guide told the operator to export a data-plane token by hand before `ocx service install`, described the loopback listener only in its ported form, and had no path for joining a second machine other than assembling `ocx connect` from parts. All three stopped being true in this stack (#4236). English and Korean now share one structure. The setup block is the one-port recipe: hostname is the tailnet IP, `unauthenticatedLoopbackListener: {"enabled": true}` binds the companion socket on the same port, and `hub.dataPublicOrigin` sits beside `hub.managementPublicOrigin` so the printed invite is usable. The `export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)"` line is gone, replaced by the real precedence (env, then the existing owner-only file, then 32 fresh bytes) and the fact that an ADMIN token there is refused. New sections cover the ported form as the documented alternative, the hub's own local clients with the exact admitted wire list and `count_tokens` named as absent, `ocx hub invite` with the `corsAllowOrigins` precondition and the reason `--management-url` can only confirm, and macOS service operations. Two corrections the code supports and the plan's wording did not. `ocx service restart` aliases `repair`, and after PR1 a repair of a healthy launchd job is a no-op, so it restarts nothing -- `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy` is the way to bounce it. And Tailscale Serve must not target the companion listener: it is a real socket, so the mapping is accepted, and then the loopback admission policy's `Host` check rejects the forwarded Host exactly as the plain loopback-bind trap does. The forwarder section stays, with its example port moved off 10100 because the companion owns that address now. #4241's structure and every claim its guard test pins are intact. The guard was extended, not relaxed: five new cases over both locales pin the companion form, the ported alternative, `ocx hub invite` with its precondition, the kickstart line, the four-state launchd wording, and the Serve warning. The no-exported-token assertion is line-anchored so prose can still name the variable -- the page has to, to say the step is gone and that an admin token is refused there. `reference/configuration/server.md`: `hub.dataPublicOrigin`, how the two public origins relate, and the companion refusal rule. The `hostname` row and `## Remote access` no longer claim a non-loopback bind refuses to start without the environment variable, which stopped being true in PR4. The Korean page had neither the loopback-listener section nor the Remote Hub key table; both are written. The other five translations are out of scope and still carry the old recipe. Co-Authored-By: Claude Fable 5.1 --- .../src/content/docs/guides/remote-hub.md | 270 ++++++++++++++++-- .../src/content/docs/ko/guides/remote-hub.md | 209 +++++++++++++- .../docs/ko/reference/configuration/server.md | 63 +++- .../docs/reference/configuration/server.md | 51 +++- .../docs-remote-hub-claims.test.ts | 66 +++++ 5 files changed, 615 insertions(+), 44 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 60331d9746..705e2f5964 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -1,6 +1,6 @@ --- 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. +description: Run a one-port opencodex hub on Linux, macOS, or Docker with a loopback companion listener, a self-provisioned data token, ocx hub invite, 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 @@ -8,6 +8,11 @@ use its data plane remotely. The browser-facing management plane is separate: an 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 data plane is **one port**. Remote machines dial `hostname:port` with their own per-client key; +the hub's own processes dial `127.0.0.1:` with no credential, through the loopback +companion listener. Start from [the recipe below](#linux-systemd-or-macos-launchd), then hand a +second machine a ready-made command with [`ocx hub invite`](#inviting-another-machine). + 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. @@ -41,6 +46,10 @@ ocx connect status ocx sync ``` +You do not have to assemble that line by hand. `ocx hub invite`, run on the hub, mints the code and +prints the exact command — including both origins — for the machine that is joining. See +[Inviting another machine](#inviting-another-machine). + The hub automatically issues a per-client key. The client writes it to the existing owner-only `service-api-token` file, never `config.json`. While connected, usage comes from the hub usage store filtered to that client's stable `apiKeyId`. After disconnect, usage comes from the local store. @@ -68,8 +77,9 @@ data-key rotation, revocation, and disconnect. ## 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: +Bind the data listener to the hub's Tailscale address, enable the loopback companion so the hub's +own processes reach that same port without a credential, and publish management separately. The +values below are examples: ```bash ocx config set runtimeRole hub @@ -82,20 +92,24 @@ ocx config set corsAllowOrigins '["http://localhost:10100"]' ocx config set hub '{}' ocx config set remoteGui '{}' ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' +ocx config set hub.dataPublicOrigin '"https://hub-name.tailnet-name.ts.net:8443"' 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)" +# One port. Remote machines dial 100.64.0.10:10100 with their own key; the hub's own local +# processes dial 127.0.0.1:10100 with no credential, on that same port. +ocx config set unauthenticatedLoopbackListener '{"enabled":true}' + +# No token to export: install provisions the hub's own data-plane token. See below. ocx service install ocx service status +ocx status # the "Hub:" block summarizes every line above ``` On a genuinely empty configuration you can set each object in one call instead: ```bash -ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","managementIngress":{"enabled":true,"port":10101}}' +ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","dataPublicOrigin":"https://hub-name.tailnet-name.ts.net:8443","managementIngress":{"enabled":true,"port":10101}}' ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}' ``` @@ -109,12 +123,102 @@ Two details that decide whether a line is accepted. The value is parsed as JSON back to the raw string, which is why a URL is written as `'"https://…"'`: objects, arrays, booleans and numbers must be valid JSON. And `hub` and `remoteGui` are strict, so a mistyped key is rejected at write time as `schema_invalid: hub.` instead of becoming a setting that never takes -effect. `managementPublicOrigin` must be a bare origin with no path, query or fragment. +effect. `managementPublicOrigin` and `dataPublicOrigin` must each be a bare origin with no path, +query or fragment. + +### The data-plane token provisions itself + +There is no `export OPENCODEX_API_AUTH_TOKEN=…` step before `ocx service install`. On a non-loopback +bind the installer resolves the hub's data-admission token by precedence and writes the result to +the owner-only `service-api-token` file, mode `0600`: + +1. **`OPENCODEX_API_AUTH_TOKEN`**, when the installing shell exports one. An operator who wants to + own that value keeps owning it. +2. **The existing `service-api-token` file.** Reusing it is what makes `ocx service install`, + `ocx service repair` and a restart idempotent; regenerating would silently invalidate every + per-client key already exchanged against the old value. +3. **32 fresh random bytes, hex.** This is the branch that removes the manual step. + +The command prints the **path**, never the value. The launchd plist and the 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. A foreground +`ocx start` on the hub reads the same file, so it binds the non-loopback hostname without an +exported token either. + +A **management admin token** in `OPENCODEX_API_AUTH_TOKEN` is refused, and the refusal names the +fix: `unset OPENCODEX_API_AUTH_TOKEN` and rerun. They are different credentials — the data token +admits `/v1/*` callers and administers nothing — and exporting the admin token as the data token +fails the hub's own admission check at every start. Since the service provisions its own token, +there is no reason to export either one. `ocx service repair` never demands the variable again once +the file exists. + +`ocx status` reports the token's source without its value: `present (env)`, `present (file)`, +`unsafe (file)` (the file exists but is not owner-only — fix the permissions; install refuses it), +or `missing`. + +### One port, and the ported alternative + +`unauthenticatedLoopbackListener: {"enabled": true}` with **no** `port` is the *companion* form: a +second socket on `127.0.0.1:` — the same port number the public listener uses on the +tailnet address. That is the address every local integration already writes, so nothing on the hub +has to be taught a new port, and one port stays the whole remote data surface. + +The companion form is accepted only when `hostname` is a specific non-loopback, non-wildcard +address. On `127.0.0.1`, `localhost`, `0.0.0.0` or `::` the public listener already holds that +loopback address, so opencodex refuses the pair at write time and again at startup — naming the +collision — rather than letting the second bind fail. On those binds you do not need the listener at +all: a loopback bind already admits local callers. + +The older *ported* form still works and is the alternative when you want the two surfaces on +separate ports: + +```bash +ocx config set unauthenticatedLoopbackListener '{"enabled":true,"port":10104}' +``` + +With a `port` set, the local integrations follow the listener and write `http://127.0.0.1:10104` +instead. The port must differ from the proxy port and is never OS-assigned: an ephemeral port would +change across restarts while already-running app-servers kept the previous `base_url`. + +**Restart the proxy after changing this field.** The sockets are bound once at startup and the local +client files are written from the resolved value, so a running hub keeps its old answer. On a ported +hub that is the difference between `ocx claude` reaching the listener and getting a `404` from it. +See [macOS service operations](#macos-service-operations) for how to actually bounce a launchd job. + +### The hub's own local clients + +A hub used to be the one machine that could not use itself: `ocx claude`, Claude Desktop, Cursor, +the `system-env` injection and the routed vision helper all dial `http://127.0.0.1:`, which +does not exist when the listener is bound to a tailnet address. With the loopback listener enabled +they work on the hub: + +```bash +ocx sync # the hub now writes its own Codex/Grok blocks +ocx claude # Claude Code wired to the hub's own loopback address +``` + +The listener carries inference wires only: `POST /v1/responses` and its WebSocket upgrade, +`POST /v1/responses/compact`, `POST /v1/messages`, `POST /v1/chat/completions`, +`POST /v1/alpha/search`, `GET /v1/models`, and the realtime voice surface. +`POST /v1/messages/count_tokens` is deliberately **not** admitted, so Claude Code falls back to +local token estimation — a cosmetic loss, not a broken launch. `/api/*`, `/healthz`, `/readyz` and +the dashboard all return `404` there: local management reads such as `ocx claude`'s discovery call go +to the authenticated management surface with a management credential, never to an unauthenticated +socket. That is why the management ingress and this listener remain two different things. + +With the listener **off**, a hub deliberately does not rewrite its own client configs, and every +skip names the gate that stopped it: + +```text +This machine is a hub; it does not rewrite its own Codex/Grok/Claude configs unless +unauthenticatedLoopbackListener is enabled. +``` -`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. +That sentence means the hub gate, not your `clientIntegrations` toggle. `ocx ensure` leaves an +existing managed Grok block in place when it is gated rather than stripping it, and +`ocx restore back` reports the gate instead of blaming a competing writer. + +### Acceptance on the data plane Prove liveness and readiness on the public data listener: @@ -167,14 +271,21 @@ bound to the node's own tailnet address, and the App Store build of the macOS cl remote destination outright. Run a loopback forwarder on the hub and point Serve at that: ```bash -# Any loopback TCP forwarder works; socat is one. The data listener is bound to the tailnet -# address, so 127.0.0.1:10100 is free for the forwarder to take. -socat TCP-LISTEN:10100,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 & +# Any loopback TCP forwarder works; socat is one. Pick a port the hub is not already using: +# with the loopback companion enabled, 127.0.0.1:10100 belongs to opencodex itself. +socat TCP-LISTEN:10110,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 & -tailscale serve --bg --https=8443 http://127.0.0.1:10100 -tailscale serve status # expect both mappings: 443 -> 10101, 8443 -> 10100 +tailscale serve --bg --https=8443 http://127.0.0.1:10110 +tailscale serve status # expect both mappings: 443 -> 10101, 8443 -> 10110 ``` +**Do not point Serve at the loopback companion listener instead.** It is a real socket on +`127.0.0.1:10100`, so the mapping would be created and then fail the same way the trap below +describes: the companion runs the loopback admission policy, which requires a loopback `Host` +header, and Serve forwards `Host: hub-name.tailnet-name.ts.net`. The companion exists for processes +*on* the hub, which send a loopback `Host` of their own. The forwarder carries the tailnet-bound +listener, whose credential admission and `Host` handling are what a TLS frontend needs. + Serve accepts a limited set of HTTPS ports; confirm the mapping was actually created with `tailscale serve status` rather than assuming the port was allowed. @@ -187,11 +298,15 @@ it is where `/readyz` and `/v1/catalog` are fetched — and `--management-url` i origin used for pairing and key issuance. They do not have to share a port: ```bash -ocx connect https://hub-name.tailnet-name.ts.net:8443 \ +# This is exactly the line `ocx hub invite` prints, with the code filled in. +echo '' | ocx connect https://hub-name.tailnet-name.ts.net:8443 \ --management-url https://hub-name.tailnet-name.ts.net \ - --admin-token-stdin + --pairing-code-stdin ``` +Record those two origins on the hub as `hub.dataPublicOrigin` and `hub.managementPublicOrigin`, and +`ocx hub invite` will print them for you instead of asking you to remember them. + When `--management-url` is omitted it is taken from the `/readyz` response, which reports `hub.managementPublicOrigin`. Setting it explicitly is clearer when the two origins differ. @@ -204,9 +319,17 @@ cannot serve a model. Nothing in the request path reads `X-Forwarded-Host`, so t repair it. Keep the listener on the tailnet address, where credential admission stays on and the `Host` check does not apply. +That trap is about the **bind**, and it is still true. Getting a `127.0.0.1` socket on a hub for the +hub's own processes is a different problem, and +[`unauthenticatedLoopbackListener`](#one-port-and-the-ported-alternative) is the sanctioned answer to +it: the public bind stays on the tailnet address with admission on, and a second socket serves local +callers. It is not a TLS target, for the reason given above. + Binding `0.0.0.0` also works and removes the need for a forwarder, since the listener is then reachable on loopback as well. It publishes the data port on every interface, so prefer it only -where the host has no other network you care about. +where the host has no other network you care about — and note that the companion form of +`unauthenticatedLoopbackListener` is refused on a wildcard bind, because the public listener already +holds `127.0.0.1:` there. Re-run the acceptance checks against the HTTPS data origin once Serve is up: `/readyz`, an authenticated `GET /v1/catalog`, and one real routed response. @@ -223,6 +346,87 @@ Protect the private key, renew it through Tailscale's supported mechanism, and p `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. +## Inviting another machine + +Run this on the hub rather than writing an `ocx connect` line by hand: + +```bash +ocx hub invite +``` + +It mints a single-use, short-lived pairing code and prints the command to run on the other machine: + +```text +# Run on the other machine: +echo '' | ocx connect https://hub-name.tailnet-name.ts.net:8443 --management-url https://hub-name.tailnet-name.ts.net --pairing-code-stdin +``` + +The data origin comes from `hub.dataPublicOrigin`, or `--data-url`, or `http://:` as a +last resort. The management origin is `hub.managementPublicOrigin`, and on `invite` the +`--management-url` flag is a **confirmation, not an override**: the grant is bound to the configured +origin and the exchange compares against it, so a value that differs is refused with both origins +named rather than printing a code the hub would then reject. + +`invite` refuses *before* minting anything when the setup cannot work — a `runtimeRole` that is not +`hub`, a missing `hub.managementPublicOrigin`, a plaintext non-loopback management origin, a +malformed `--data-url`, or no running attested proxy. One precondition deserves its own paragraph. + +**`corsAllowOrigins` has to name the joining machine's local browser origin.** `ocx connect` sends +`Origin: http://localhost:` when it exchanges the grant, and grants are +origin-bound, so only `hub.managementPublicOrigin` itself or a loopback entry of `corsAllowOrigins` +can ever match. With neither present, `invite` exits non-zero, mints nothing, and names the exact +command: + +```bash +ocx config set corsAllowOrigins '["http://localhost:10100"]' +``` + +Use the port the **joining** machine's proxy listens on; `10100` is the default. The setup block +above already sets it. + +`ocx hub invite --json` emits `{ code, expiresAt, dataUrl, managementUrl, command }` with `expiresAt` +as ISO 8601. The code is a secret: single-use, five-minute lifetime, rate-limited at the hub, and not +to be persisted, logged, or pasted into an issue. `--clients codex,claude` picks which client configs +the printed command will point at the hub. + +`invite` is a convenience over the existing pairing flow, not a second mechanism. It drives the same +attested local route `ocx gui pair` uses, so it needs no admin token and nothing has to be exported +into your shell. Everything in [Roles and direct data flow](#roles-and-direct-data-flow) about +rotation, revocation and disconnect applies unchanged to a machine that joined this way. + +## macOS service operations + +`ocx service install` and `ocx service repair` are safe to re-run against a live hub. A repair +renders the plist first and compares it: when the rendered bytes equal the bytes on disk, the token +file is unchanged, and `launchctl print` reports the job loaded from that plist, the repair re-asserts +`0600`, refreshes its install state, prints `service is already loaded from the current plist; +nothing to do.` and returns — launchd is never touched. Earlier builds evicted a healthy job +unconditionally, which made a diagnostic command an outage. + +That no-op has one consequence worth knowing: **`ocx service restart` is an alias of `repair`, so on +a healthy macOS job it restarts nothing.** To actually bounce the process — which is what you need +after changing `unauthenticatedLoopbackListener`, `hostname` or `port` — kick the job: + +```bash +launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy +``` + +`ocx service stop` followed by `ocx service start` is the equivalent through the CLI. Use +`ocx service repair` for the case it is actually for: a job loaded from an older plist, or not loaded +at all. + +`ocx service status` distinguishes four launchd states, and the last one is the one people misread: + +| Summary | Meaning | +| --- | --- | +| `installed and loaded` | A domain answers and runs the command this plist bakes. Nominal. | +| `installed and loaded from an OLDER plist` | The job is running, from a definition that no longer matches. This is what `ocx service repair` is for. | +| `installed, not loaded` | Every domain answered "absent", which is proof the job is gone. Repair re-registers it. | +| `installed; launchd state could not be verified` | `launchctl` could not be asked — for example from a context that cannot reach the `gui/` domain. This is **not** evidence the hub is down: nothing recommends a repair, and an unanswerable probe never marks a running proxy as dead. | + +A probe that could not run used to be reported as "not loaded", which told operators to repair a +serving hub and let the updater start a competing proxy on the service's own port. + ## Headless OAuth Disable browser launch on the hub: @@ -355,6 +559,11 @@ These nested sets work because the image seeds a first-run `hub` configuration, already exists. On a fresh standalone install it does not, and the same lines fail until you create it — see [Linux systemd or macOS launchd](#linux-systemd-or-macos-launchd) above. +The container listener binds `0.0.0.0`, so it is already reachable on the container's own loopback +address and the companion form of `unauthenticatedLoopbackListener` does not apply there — it is +refused on a wildcard bind. The token bootstrap below is the container equivalent of the service's +own provisioning step, and it likewise runs once. + Do not put a token in `ARG`, `ENV`, `COPY`, Compose YAML, image history, or command arguments. Do not mount the Docker socket, the host's home or Codex home, SSH agent, or provider-key files. A management ingress bound to `127.0.0.1:10101` inside the container is reachable only by a TLS/tailnet frontend @@ -412,8 +621,27 @@ For a service rollback, stop the branch service and repair the prior release aga Do not edit or remove either token candidate before the recovery probe finishes. - **Protocol mismatch:** upgrade the older side named by the `hub-too-new` or `hub-too-old` message. Negotiation fails before token, catalog, journal, or client-state writes. -- **Lost or burned pairing code:** create a new short-lived code. Grants are one-use and repeated +- **Lost or burned pairing code:** run `ocx hub invite` again. Grants are one-use and repeated failures are rate-limited without revealing whether a code exists. +- **`ocx hub invite` says `No loopback browser origin is admitted for pairing`:** the hub admits no + loopback browser origin, so an origin-bound grant could never match. Nothing was minted. Run the + `ocx config set corsAllowOrigins` line the error prints, with the joining machine's proxy port. + See [Inviting another machine](#inviting-another-machine). +- **`ocx hub invite` refuses a `--management-url`:** on a hub that flag confirms + `hub.managementPublicOrigin` rather than overriding it, because the grant is bound to the + configured value. Change the config, or drop the flag. +- **`ocx claude` on the hub launches native Codex/Claude, or the hub refuses to write its own client + configs:** `unauthenticatedLoopbackListener` is off. The skip message names the gate. Enable the + listener and restart the proxy. +- **`ocx claude` on the hub gets `404` from the listener:** the proxy is still the process that + started before the listener's wires existed, or before the port changed. Restart it — + see [macOS service operations](#macos-service-operations). +- **`ocx service restart` printed `nothing to do` and the process did not bounce (macOS):** expected. + `restart` aliases `repair`, and a repair of a healthy job is deliberately a no-op. Use + `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy`. +- **`ocx service install` refuses `OPENCODEX_API_AUTH_TOKEN`:** that value is a management admin + token. `unset OPENCODEX_API_AUTH_TOKEN` and rerun; the service provisions its own data-plane + token. See [The data-plane token provisions itself](#the-data-plane-token-provisions-itself). - **Plain HTTP refused:** pairing over non-loopback HTTP is refused outright, and there is no flag that opts out of it. Put the management origin behind HTTPS, or pair over loopback. Admin tokens are never sent over HTTP. diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index e924175672..630915c562 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -1,12 +1,21 @@ --- title: Remote Hub 배포 -description: Linux, macOS, Docker에서 관리 포트는 로컬에만 열고 Tailscale Serve와 헤드리스 OAuth를 사용하는 방법입니다. +description: Linux, macOS, Docker에서 포트 하나로 동작하는 opencodex 허브를 구성합니다. 루프백 companion 리스너, 스스로 준비되는 데이터 토큰, ocx hub invite, 로컬 전용 관리 인그레스, Tailscale Serve, 헤드리스 OAuth를 다룹니다. --- Remote Hub를 쓰면 프로바이더 인증 정보와 사용량 기록은 허브 한 곳에 두고, 인증된 클라이언트가 허브의 데이터 API를 직접 사용합니다. 브라우저용 관리 API는 별도입니다. 선택 사항인 관리 리스너는 `127.0.0.1`에만 열리며 대시보드와 `/api/*`만 제공합니다. +데이터 플레인은 **포트 하나**입니다. 원격 컴퓨터는 `hostname:port`를 자기 전용 키로 호출하고, 허브 자신의 프로세스는 **같은 포트**의 `127.0.0.1`을 자격 증명 없이 호출합니다. 후자를 담당하는 것이 루프백 companion 리스너입니다. 아래 [설치 레시피](#systemd-또는-launchd)로 시작한 뒤, [`ocx hub invite`](#다른-컴퓨터-초대하기)로 다른 컴퓨터에 그대로 붙여 넣을 명령을 건네세요. + 관리 포트에서는 `/v1/*`, `/healthz`, `/readyz`, WebSocket을 제공하지 않습니다. 이 포트를 직접 공개하거나 방화벽에 열지 말고 Tailscale Funnel도 사용하지 마세요. +## 보안과 동의 경계 + +- 프로바이더/OAuth 인증 정보는 허브 밖으로 복사하지 마세요. +- 데이터 키는 `service-api-token` 또는 `OCX_API_TOKEN_FILE`로 전달하며 관리 권한이 없습니다. +- 관리자 토큰은 일반 관리 작업만 할 수 있습니다. 브라우저 동의 세션을 만들거나 저장소 Star 같은 동의 작업을 승인할 수는 없습니다. 그런 작업에는 서버가 발급한 `gui-session`, 일치하는 Origin, CSRF 토큰이 필요합니다. +- `Tailscale-User-Login`은 별도 관리 리스너에서만 신뢰합니다. 공개 리스너의 같은 헤더는 무시합니다. `remoteGui.allowedTailscaleUsers`에는 허용할 로그인 ID를 정확히 적으세요. + ## 역할과 데이터 흐름 - `standalone`: 데이터와 관리를 한 컴퓨터에서 처리합니다. @@ -15,36 +24,109 @@ Remote Hub를 쓰면 프로바이더 인증 정보와 사용량 기록은 허브 Codex와 Claude 요청은 클라이언트에서 허브의 데이터 리스너로 바로 갑니다. 대시보드나 로컬 관리 릴레이를 거치지 않습니다. +임시 권한은 stdin으로만 전달하며 설정 파일이나 토큰 파일에 저장되지 않습니다. + ```bash ocx connect https://hub-name.tailnet-name.ts.net --pairing-code-stdin ocx connect status ocx sync ``` -허브가 발급한 클라이언트별 키는 권한이 제한된 `service-api-token` 파일에 저장됩니다. `config.json`에는 저장되지 않습니다. 연결 중 사용량은 허브 기록에서 해당 `apiKeyId`만 조회하고, 연결을 끊은 뒤에는 로컬 기록을 봅니다. 두 기록은 서로 복제되지 않습니다. - -## 보안과 동의 경계 +이 줄을 직접 만들 필요는 없습니다. 허브에서 `ocx hub invite`를 실행하면 코드를 발급하고, 두 Origin이 모두 채워진 명령을 그대로 출력합니다. [다른 컴퓨터 초대하기](#다른-컴퓨터-초대하기)를 보세요. -- 프로바이더/OAuth 인증 정보는 허브 밖으로 복사하지 마세요. -- 데이터 키는 `service-api-token` 또는 `OCX_API_TOKEN_FILE`로 전달하며 관리 권한이 없습니다. -- 관리자 토큰은 일반 관리 작업만 할 수 있습니다. 브라우저 동의 세션을 만들거나 저장소 Star 같은 동의 작업을 승인할 수는 없습니다. 그런 작업에는 서버가 발급한 `gui-session`, 일치하는 Origin, CSRF 토큰이 필요합니다. -- `Tailscale-User-Login`은 별도 관리 리스너에서만 신뢰합니다. 공개 리스너의 같은 헤더는 무시합니다. `remoteGui.allowedTailscaleUsers`에는 허용할 로그인 ID를 정확히 적으세요. +허브가 발급한 클라이언트별 키는 권한이 제한된 `service-api-token` 파일에 저장됩니다. `config.json`에는 저장되지 않습니다. 연결 중 사용량은 허브 기록에서 해당 `apiKeyId`만 조회하고, 연결을 끊은 뒤에는 로컬 기록을 봅니다. 두 기록은 서로 복제되지 않습니다. ## systemd 또는 launchd +데이터 리스너는 허브의 Tailscale 주소에 바인드하고, 허브 자신의 프로세스가 같은 포트를 자격 증명 없이 쓸 수 있도록 루프백 companion을 켜고, 관리 평면은 따로 공개합니다. 아래 값은 예시입니다. + ```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"]' + +# 새로 만든 standalone 설정에는 `hub`나 `remoteGui` 객체가 없고, `ocx config set`은 없는 부모를 +# 만들어 주지 않습니다. 중첩 경로를 먼저 쓰면 `config parent path not found: hub`로 실패합니다. +# `runtimeRole`을 설정해도 객체는 생기지 않습니다. 빈 객체를 먼저 만들고 필드를 설정하세요. +ocx config set hub '{}' +ocx config set remoteGui '{}' +ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' +ocx config set hub.dataPublicOrigin '"https://hub-name.tailnet-name.ts.net:8443"' ocx config set hub.managementIngress '{"enabled":true,"port":10101}' ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' -export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)" + +# 포트 하나. 원격 컴퓨터는 100.64.0.10:10100을 자기 키로 호출하고, 허브의 로컬 프로세스는 +# 같은 포트의 127.0.0.1:10100을 자격 증명 없이 호출합니다. +ocx config set unauthenticatedLoopbackListener '{"enabled":true}' + +# 손으로 내보낼 토큰은 없습니다. install이 허브 전용 데이터 플레인 토큰을 직접 준비합니다. ocx service install ocx service status +ocx status # "Hub:" 블록이 위의 모든 값을 요약합니다 ``` -`ocx service install`은 키를 기존 `service-api-token` 경로에 안전하게 저장합니다. plist나 systemd unit에는 실제 키가 들어가지 않습니다. +설정이 완전히 비어 있다면 객체를 한 번에 설정할 수도 있습니다. + +```bash +ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","dataPublicOrigin":"https://hub-name.tailnet-name.ts.net:8443","managementIngress":{"enabled":true,"port":10101}}' +ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}' +``` + +이 형태는 객체가 아직 없을 때만 쓰세요. 객체 전체를 설정하면 병합이 아니라 **교체**되므로, 이미 `hub.managementIngress`가 있던 설정에 위 줄을 실행하면 인그레스가 조용히 사라집니다. 기존 설정을 수정할 때는 부모가 이미 있으므로 필드를 하나씩 설정하면 되고, 그 경우 다른 값은 건드리지 않습니다. + +값이 받아들여지는지를 결정하는 두 가지. 값은 먼저 JSON으로 파싱되고 실패하면 원시 문자열로 처리하므로 URL은 `'"https://…"'`처럼 적습니다. 객체, 배열, 불리언, 숫자는 올바른 JSON이어야 합니다. 그리고 `hub`와 `remoteGui`는 strict 스키마라서 오타 난 키는 쓰는 시점에 `schema_invalid: hub.`로 거부됩니다. 효과 없는 설정으로 남지 않습니다. `managementPublicOrigin`과 `dataPublicOrigin`은 경로·쿼리·프래그먼트가 없는 순수 Origin이어야 합니다. + +### 데이터 플레인 토큰은 스스로 준비됩니다 + +`ocx service install` 앞에 `export OPENCODEX_API_AUTH_TOKEN=…` 단계는 없습니다. 루프백이 아닌 바인드에서 설치 과정이 다음 우선순위로 데이터 admission 토큰을 결정하고, 결과를 owner-only `service-api-token` 파일(모드 `0600`)에 기록합니다. + +1. **`OPENCODEX_API_AUTH_TOKEN`** — 설치하는 셸이 내보낸 값이 있을 때. 값을 직접 관리하고 싶은 운영자는 계속 직접 관리할 수 있습니다. +2. **기존 `service-api-token` 파일.** 이 재사용이 `ocx service install`, `ocx service repair`, 재시작을 멱등하게 만듭니다. 새로 만들면 이미 교환된 클라이언트 키가 모두 조용히 무효가 됩니다. +3. **무작위 32바이트(hex) 새 값.** 손으로 하던 단계를 없애는 분기입니다. + +명령은 **경로**만 출력하고 값은 절대 출력하지 않습니다. launchd plist와 systemd user unit은 프로세스가 시작할 때 그 보호된 파일을 읽으며, 토큰 문자열이 정의 파일에 들어가지 않습니다. 값을 `ocx config show`, unit/plist 출력, 스크린샷, 지원 번들에 붙여 넣지 마세요. 포그라운드 `ocx start`도 같은 파일을 읽으므로, 토큰을 내보내지 않아도 루프백이 아닌 hostname에 바인드합니다. + +`OPENCODEX_API_AUTH_TOKEN`에 **관리자 토큰**이 들어 있으면 거부하며, 해결책을 함께 알려 줍니다: `unset OPENCODEX_API_AUTH_TOKEN` 후 다시 실행하세요. 둘은 다른 자격 증명입니다. 데이터 토큰은 `/v1/*` 호출자를 허용할 뿐 관리 권한이 없고, 관리자 토큰을 데이터 토큰으로 내보내면 시작할 때마다 허브 자신의 admission 검사에서 실패합니다. 서비스가 토큰을 직접 준비하므로 어느 쪽도 내보낼 이유가 없습니다. 파일이 한 번 만들어진 뒤에는 `ocx service repair`가 환경 변수를 다시 요구하지 않습니다. + +`ocx status`는 값 없이 출처만 보고합니다: `present (env)`, `present (file)`, `unsafe (file)`(파일은 있지만 권한이 owner-only가 아님 — 권한을 고치세요. 설치는 이 상태를 거부합니다), `missing`. + +### 한 포트, 그리고 포트를 지정하는 대안 + +`port` 없이 쓰는 `unauthenticatedLoopbackListener: {"enabled": true}`가 *companion* 형태입니다. 공개 리스너가 tailnet 주소에서 쓰는 것과 **같은 포트 번호**로 `127.0.0.1`에 소켓을 하나 더 엽니다. 로컬 통합이 이미 기록하는 주소가 바로 그것이라서, 허브에 새 포트를 가르칠 필요가 없고 원격에 열리는 데이터 표면은 포트 하나로 유지됩니다. + +companion 형태는 `hostname`이 루프백도 와일드카드도 아닌 구체 주소일 때만 허용됩니다. `127.0.0.1`, `localhost`, `0.0.0.0`, `::`에서는 공개 리스너가 이미 그 루프백 주소를 쓰고 있으므로, opencodex가 두 번째 바인드를 실패하게 두지 않고 쓰는 시점과 시작 시점에 충돌을 지목하며 거부합니다. 그런 바인드에서는 리스너 자체가 필요 없습니다. 루프백 바인드는 이미 로컬 호출자를 허용합니다. + +두 표면을 서로 다른 포트에 두고 싶다면 기존 *포트 지정* 형태도 그대로 동작합니다. + +```bash +ocx config set unauthenticatedLoopbackListener '{"enabled":true,"port":10104}' +``` + +`port`를 지정하면 로컬 통합이 리스너를 따라 `http://127.0.0.1:10104`를 기록합니다. 이 포트는 프록시 포트와 달라야 하고 OS가 자동 할당하지 않습니다. 임시 포트는 재시작 때마다 바뀌는데 이미 실행 중인 app-server는 예전 `base_url`을 들고 있기 때문입니다. + +**이 필드를 바꾸면 프록시를 재시작하세요.** 소켓은 시작할 때 한 번 바인드되고 로컬 클라이언트 파일도 그때 결정된 값으로 기록되므로, 실행 중인 허브는 예전 답을 유지합니다. 포트 지정 허브에서는 이것이 `ocx claude`가 리스너에 닿는지 `404`를 받는지의 차이입니다. launchd 작업을 실제로 재시작하는 방법은 [macOS 서비스 운영](#macos-서비스-운영)을 보세요. + +### 허브 자신의 로컬 클라이언트 + +예전에는 허브가 자기 자신을 쓸 수 없는 유일한 컴퓨터였습니다. `ocx claude`, Claude Desktop, Cursor, `system-env` 주입, 라우팅 vision 헬퍼는 모두 `http://127.0.0.1:`를 호출하는데, 리스너가 tailnet 주소에 바인드되어 있으면 그 주소는 존재하지 않습니다. 루프백 리스너를 켜면 허브에서도 동작합니다. + +```bash +ocx sync # 이제 허브가 자기 Codex/Grok 블록을 기록합니다 +ocx claude # 허브 자신의 루프백 주소로 연결된 Claude Code +``` + +이 리스너는 추론 경로만 제공합니다: `POST /v1/responses`와 그 WebSocket 업그레이드, `POST /v1/responses/compact`, `POST /v1/messages`, `POST /v1/chat/completions`, `POST /v1/alpha/search`, `GET /v1/models`, 그리고 실시간 음성 표면입니다. `POST /v1/messages/count_tokens`는 의도적으로 허용하지 않으므로 Claude Code는 로컬 토큰 추정으로 대체합니다. 실행이 깨지는 문제가 아니라 표시상의 손실입니다. `/api/*`, `/healthz`, `/readyz`, 대시보드는 이 리스너에서 모두 `404`입니다. `ocx claude`의 탐색 호출 같은 로컬 관리 읽기는 관리 자격 증명을 들고 인증된 관리 표면으로 가며, 인증 없는 소켓으로 가지 않습니다. 관리 인그레스와 이 리스너가 서로 다른 것인 이유가 그것입니다. + +리스너를 **끈** 상태에서는 허브가 의도적으로 자기 클라이언트 설정을 고치지 않으며, 건너뛸 때마다 무엇이 막았는지 말해 줍니다. + +```text +This machine is a hub; it does not rewrite its own Codex/Grok/Claude configs unless +unauthenticatedLoopbackListener is enabled. +``` + +이 문장은 `clientIntegrations` 토글이 아니라 허브 게이트를 뜻합니다. 게이트로 건너뛴 경우 `ocx ensure`는 기존 관리 Grok 블록을 제거하지 않고 그대로 두며, `ocx restore back`도 존재하지 않는 경쟁 작성자를 탓하는 대신 게이트를 보고합니다. + +### 데이터 플레인 수용 검사 ```bash curl --fail --silent http://100.64.0.10:10100/healthz @@ -64,6 +146,98 @@ tailscale serve status 관리 포트는 `127.0.0.1:10101`에서만 보여야 합니다. `hub.managementPublicOrigin`은 Serve가 표시한 정확한 HTTPS Origin으로 설정하세요. 직접 TLS 프록시를 운영한다면 `tailscale cert hub-name.tailnet-name.ts.net`으로 ts.net 전체 FQDN 인증서만 발급하고 `127.0.0.1:10101`로만 프록시하세요. 임의의 `Tailscale-User-*` 헤더를 만들지 말고, 신뢰할 수 있는 Tailscale 신원이 없으면 일회용 pairing을 사용하세요. +### 데이터 리스너에 TLS 붙이기 + +위의 Serve 매핑은 **관리** 인그레스만 공개합니다. 그 인그레스는 `/v1/*`, `/healthz`, `/readyz`를 제공하지 않으므로 그것만으로는 원격 클라이언트에게 쓸 수 있는 데이터 플레인이 생기지 않습니다. opencodex는 자체적으로 TLS를 종료하지 않습니다. 리스너는 평문 HTTP이고 HTTPS는 항상 운영자가 소유한 프런트엔드입니다. + +Serve를 데이터 플레인의 프런트엔드로도 쓸 수 있습니다. macOS에서는 한 단계가 더 필요합니다. Tailscale Serve는 `127.0.0.1`로만 프록시할 수 있어서 노드 자신의 tailnet 주소에 바인드한 리스너를 목표로 지정할 수 없고, App Store 빌드의 macOS 클라이언트는 원격 목적지를 아예 거부합니다. 허브에 루프백 포워더를 두고 Serve를 그쪽으로 보내세요. + +```bash +# 루프백 TCP 포워더면 무엇이든 됩니다(socat이 한 예). 허브가 쓰지 않는 포트를 고르세요. +# 루프백 companion을 켰다면 127.0.0.1:10100은 opencodex 자신의 소켓입니다. +socat TCP-LISTEN:10110,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 & + +tailscale serve --bg --https=8443 http://127.0.0.1:10110 +tailscale serve status # 매핑 두 개: 443 -> 10101, 8443 -> 10110 +``` + +**대신 Serve를 루프백 companion 리스너로 보내지 마세요.** companion은 `127.0.0.1:10100`의 실제 소켓이라 매핑은 만들어지지만, 아래 함정과 똑같이 실패합니다. companion은 루프백 admission 정책을 적용하므로 `Host` 헤더가 루프백이어야 하는데, Serve는 `Host: hub-name.tailnet-name.ts.net`을 전달합니다. companion은 자기 `Host`가 루프백인 *허브 위의* 프로세스를 위한 것이고, TLS 프런트엔드가 필요한 자격 증명 admission과 `Host` 처리를 갖춘 쪽은 tailnet에 바인드된 리스너입니다. 포워더가 그 리스너를 전달합니다. + +Serve가 허용하는 HTTPS 포트는 제한적입니다. 포트가 실제로 매핑되었는지 `tailscale serve status`로 확인하세요. 포워더는 허브와 같은 수명을 갖게 하세요. 백그라운드 셸 작업은 재부팅에서 사라지지만 서비스는 살아나므로, 실행 중이면서 TLS로는 닿지 않는 허브가 남습니다. `ocx service install`과 함께 launchd나 systemd로 띄우세요. + +그다음 두 Origin을 따로 지정해 연결합니다. 위치 인자 URL이 **데이터** Origin이고(`/readyz`와 `/v1/catalog`를 가져오는 곳), `--management-url`이 pairing과 키 발급에 쓰는 대시보드 Origin입니다. 두 Origin이 같은 포트일 필요는 없습니다. + +```bash +# `ocx hub invite`가 코드까지 채워서 출력해 주는 바로 그 줄입니다. +echo '' | ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --pairing-code-stdin +``` + +`--management-url`을 생략하면 `/readyz` 응답이 보고하는 `hub.managementPublicOrigin`을 씁니다. 두 Origin이 다르면 명시하는 편이 분명합니다. 두 Origin을 허브에 `hub.dataPublicOrigin`과 `hub.managementPublicOrigin`으로 기록해 두면 `ocx hub invite`가 대신 출력해 줍니다. + +**데이터 리스너를 `127.0.0.1`에 바인드해서 우회하지 마세요.** 루프백 바인드는 opencodex가 "순수 로컬 배포"를 인식하는 방법입니다. 데이터 자격 증명을 요구하지 않게 되고, 대신 요청의 `Host` 헤더도 루프백이어야 합니다. TLS 프런트엔드는 `Host: hub-name.tailnet-name.ts.net`을 전달하므로 `/v1/catalog`는 `403 origin_rejected`를 돌려주는데, 그 검사를 하지 않는 `/readyz`는 여전히 `200`입니다. 배포는 건강해 보이고 모델은 서비스하지 못합니다. 요청 경로에서 `X-Forwarded-Host`를 읽는 곳이 없으므로 프런트엔드가 고칠 수도 없습니다. 리스너는 tailnet 주소에 두세요. 그쪽은 자격 증명 admission이 켜져 있고 `Host` 검사가 적용되지 않습니다. + +이 함정은 **바인드**에 관한 것이고 지금도 유효합니다. 허브 자신의 프로세스에게 `127.0.0.1` 소켓을 주는 것은 별개의 문제이고, [`unauthenticatedLoopbackListener`](#한-포트-그리고-포트를-지정하는-대안)가 그 문제에 대한 공식 답입니다. 공개 바인드는 admission을 켠 채 tailnet 주소에 남고, 두 번째 소켓이 로컬 호출자를 받습니다. 위에서 말한 이유로 그것은 TLS 목적지가 아닙니다. + +`0.0.0.0` 바인드도 동작하고 포워더가 필요 없어집니다. 리스너가 루프백에서도 닿기 때문입니다. 다만 모든 인터페이스에 데이터 포트를 공개하므로 다른 네트워크를 신경 쓰지 않아도 되는 호스트에서만 선택하세요. 와일드카드 바인드에서는 공개 리스너가 이미 `127.0.0.1:`를 쓰고 있어서 companion 형태가 거부된다는 점도 함께 기억하세요. + +Serve가 올라온 뒤 HTTPS 데이터 Origin으로 수용 검사를 다시 실행하세요: `/readyz`, 인증된 `GET /v1/catalog`, 실제 모델 요청 1회. + +## 다른 컴퓨터 초대하기 + +`ocx connect` 줄을 손으로 쓰는 대신 허브에서 이것을 실행하세요. + +```bash +ocx hub invite +``` + +일회용 단기 pairing 코드를 발급하고, 다른 컴퓨터에서 실행할 명령을 출력합니다. + +```text +# Run on the other machine: +echo '' | ocx connect https://hub-name.tailnet-name.ts.net:8443 --management-url https://hub-name.tailnet-name.ts.net --pairing-code-stdin +``` + +데이터 Origin은 `hub.dataPublicOrigin`, 또는 `--data-url`, 마지막 수단으로 `http://:`에서 옵니다. 관리 Origin은 `hub.managementPublicOrigin`이며, `invite`에서 `--management-url`은 **덮어쓰기가 아니라 확인**입니다. grant는 설정된 Origin에 묶이고 교환 시 그 값과 비교하므로, 다른 값을 주면 허브가 거부할 코드를 출력하는 대신 두 Origin을 모두 지목하며 거부합니다. + +동작할 수 없는 상태라면 `invite`는 코드를 만들기 **전에** 거부합니다: `runtimeRole`이 `hub`가 아님, `hub.managementPublicOrigin` 없음, 루프백이 아닌 평문 관리 Origin, 잘못된 `--data-url`, attested 프록시가 실행 중이 아님. 전제 조건 하나는 따로 적을 만합니다. + +**`corsAllowOrigins`에 참가할 컴퓨터의 로컬 브라우저 Origin이 있어야 합니다.** `ocx connect`는 grant를 교환할 때 `Origin: http://localhost:<자기 프록시 포트>`를 보내고 grant는 Origin에 묶이므로, `hub.managementPublicOrigin` 자신이나 `corsAllowOrigins`의 루프백 항목만 일치할 수 있습니다. 둘 다 없으면 `invite`는 0이 아닌 코드로 끝나고 아무것도 발급하지 않으며 정확한 명령을 알려 줍니다. + +```bash +ocx config set corsAllowOrigins '["http://localhost:10100"]' +``` + +**참가하는** 컴퓨터의 프록시 포트를 쓰세요. 기본값은 `10100`입니다. 위의 설치 블록에 이미 들어 있습니다. + +`ocx hub invite --json`은 `{ code, expiresAt, dataUrl, managementUrl, command }`를 출력하며 `expiresAt`은 ISO 8601입니다. 코드는 비밀입니다. 일회용이고 수명은 5분이며 허브에서 요청 수를 제한하고, 저장하거나 로그·이슈에 붙여 넣으면 안 됩니다. `--clients codex,claude`로 출력된 명령이 어떤 클라이언트 설정을 허브로 향하게 할지 고릅니다. + +`invite`는 기존 pairing 흐름에 대한 편의 명령이고 두 번째 메커니즘이 아닙니다. `ocx gui pair`가 쓰는 attested 로컬 경로를 그대로 사용하므로 관리자 토큰이 필요 없고 셸에 무엇도 내보내지 않습니다. 키 교체, 폐기, 연결 해제에 관한 내용은 이렇게 참가한 컴퓨터에도 그대로 적용됩니다. + +## macOS 서비스 운영 + +`ocx service install`과 `ocx service repair`는 실행 중인 허브에 다시 실행해도 안전합니다. repair는 plist를 먼저 렌더링해 비교합니다. 렌더링 결과가 디스크의 바이트와 같고, 토큰 파일도 그대로이며, `launchctl print`가 그 plist에서 로드된 작업을 보고하면 repair는 `0600`을 다시 확인하고 설치 상태를 갱신한 뒤 `service is already loaded from the current plist; nothing to do.`를 출력하고 끝냅니다. launchd를 전혀 건드리지 않습니다. 이전 빌드는 정상 작업도 무조건 bootout해서 진단 명령이 장애가 되었습니다. + +이 no-op에는 알아 둘 결과가 하나 있습니다. **`ocx service restart`는 `repair`의 별칭이므로, 정상인 macOS 작업에서는 아무것도 재시작하지 않습니다.** `unauthenticatedLoopbackListener`, `hostname`, `port`를 바꾼 뒤처럼 프로세스를 실제로 교체해야 할 때는 작업을 kick하세요. + +```bash +launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy +``` + +CLI로는 `ocx service stop` 다음 `ocx service start`가 같은 일을 합니다. `ocx service repair`는 원래 용도, 즉 더 오래된 plist에서 로드된 작업이나 로드되지 않은 작업에 쓰세요. + +`ocx service status`는 네 가지 launchd 상태를 구분하고, 마지막 것이 자주 잘못 읽힙니다. + +| 요약 | 뜻 | +| --- | --- | +| `installed and loaded` | 도메인이 응답하고 이 plist가 만든 명령을 실행 중입니다. 정상입니다. | +| `installed and loaded from an OLDER plist` | 작업은 실행 중이지만 더 이상 일치하지 않는 정의에서 왔습니다. `ocx service repair`가 바로 이 경우를 위한 것입니다. | +| `installed, not loaded` | 모든 도메인이 "없음"으로 답했고, 작업이 사라졌다는 증거입니다. repair가 다시 등록합니다. | +| `installed; launchd state could not be verified` | `launchctl`에 물어볼 수 없었습니다(예: `gui/` 도메인에 접근할 수 없는 컨텍스트). 허브가 죽었다는 증거가 **아닙니다**. 아무것도 repair를 권하지 않고, 물어볼 수 없던 조사 결과가 실행 중인 프록시를 죽은 것으로 표시하지도 않습니다. | + +예전에는 물어볼 수 없던 경우를 "not loaded"로 보고했고, 그 때문에 운영자가 정상 서비스에 repair를 실행하고 업데이터가 서비스 포트에 경쟁 프록시를 띄웠습니다. + ## 헤드리스 OAuth ```bash @@ -119,6 +293,8 @@ openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-t docker compose up -d ``` +컨테이너 리스너는 `0.0.0.0`에 바인드되므로 컨테이너 자신의 루프백 주소에서도 이미 닿습니다. 와일드카드 바인드에서는 companion 형태가 거부되므로 `unauthenticatedLoopbackListener`는 여기에 해당하지 않습니다. 위의 토큰 부트스트랩이 서비스가 직접 하는 토큰 준비 단계의 컨테이너판이며, 역시 한 번만 실행합니다. + 이미지는 non-root `bun` 사용자로 실행되고 루트 파일 시스템은 read-only이며 공개 포트는 `10100` 하나뿐입니다. 토큰을 `ARG`, `ENV`, `COPY`, Compose YAML, 이미지 기록, 명령행에 넣지 마세요. Docker socket, 호스트의 홈이나 Codex 홈, SSH agent, 프로바이더 키도 마운트하지 마세요. 컨테이너 안의 `127.0.0.1:10101` 관리 포트는 같은 네트워크 네임스페이스의 TLS/tailnet 프런트엔드로만 연결하고 직접 publish하지 마세요. 컨테이너 healthcheck의 `/healthz`가 통과한 뒤 `/readyz`, 인증된 `/v1/catalog`, 실제 모델 응답을 별도로 확인하세요. @@ -133,7 +309,14 @@ docker compose up -d - 일시적 허브 오류에서는 검증된 마지막 카탈로그를 유지합니다. 인증·스키마·크기·프로토콜 오류는 로컬 프로바이더로 대체하지 않습니다. - `.prev` 복구가 필요하면 두 파일을 지우지 말고 임시 권한과 함께 `ocx connect rotate`를 다시 실행하세요. - `hub-too-new` 또는 `hub-too-old`가 나오면 메시지가 가리키는 오래된 쪽을 업그레이드하세요. 불일치는 로컬 파일을 쓰기 전에 차단됩니다. -- pairing 코드는 일회용이며 반복 실패는 429로 제한됩니다. 코드를 잃었거나 소진했다면 새로 만드세요. -- 루프백이 아닌 HTTP pairing은 `--allow-insecure-http`를 명시해야 합니다. 관리자 토큰은 HTTP로 보내지 않습니다. +- pairing 코드를 잃었거나 소진했다면 `ocx hub invite`를 다시 실행하세요. grant는 일회용이고 반복 실패는 코드 존재 여부를 드러내지 않는 방식으로 제한됩니다. +- `ocx hub invite`가 `No loopback browser origin is admitted for pairing`이라고 하면 허브가 허용하는 루프백 브라우저 Origin이 없다는 뜻이며 아무것도 발급되지 않았습니다. 오류가 출력한 `ocx config set corsAllowOrigins` 줄을 참가할 컴퓨터의 프록시 포트로 실행하세요. +- `ocx hub invite`가 `--management-url`을 거부하면, 허브에서 그 플래그는 `hub.managementPublicOrigin`을 덮어쓰는 것이 아니라 확인하는 것입니다. 설정을 바꾸거나 플래그를 빼세요. +- 허브에서 `ocx claude`가 native로 실행되거나 허브가 자기 클라이언트 설정을 쓰지 않으면 `unauthenticatedLoopbackListener`가 꺼져 있습니다. 건너뛴 메시지가 게이트를 지목합니다. 리스너를 켜고 프록시를 재시작하세요. +- 허브에서 `ocx claude`가 리스너로부터 `404`를 받으면, 리스너 경로가 생기기 전이나 포트가 바뀌기 전에 시작된 프로세스가 아직 돌고 있는 것입니다. [macOS 서비스 운영](#macos-서비스-운영)을 보고 재시작하세요. +- macOS에서 `ocx service restart`가 `nothing to do`를 출력하고 프로세스가 바뀌지 않는 것은 정상입니다. `restart`는 `repair`의 별칭이고 정상 작업의 repair는 의도적으로 no-op입니다. `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy`를 쓰세요. +- `ocx service install`이 `OPENCODEX_API_AUTH_TOKEN`을 거부하면 그 값은 관리자 토큰입니다. `unset OPENCODEX_API_AUTH_TOKEN` 후 다시 실행하세요. 서비스가 데이터 플레인 토큰을 직접 준비합니다. +- 루프백이 아닌 평문 HTTP로는 pairing을 할 수 없고, 이를 우회하는 플래그도 없습니다. 관리 Origin을 HTTPS 뒤에 두거나 루프백에서 pairing하세요. 관리자 토큰은 HTTP로 보내지 않습니다. +- `/v1/catalog`가 `403 origin_rejected`인데 `/readyz`가 `200`이면 데이터 리스너가 TLS 프런트엔드 뒤에서 루프백에 바인드되어 있습니다. [데이터 리스너에 TLS 붙이기](#데이터-리스너에-tls-붙이기)를 보세요. - 브라우저 로그아웃/만료는 해당 원격 세션만 끊습니다. 데이터 키와는 별개입니다. - 연결 해제 후 남은 키는 허브의 **Integrations → API Keys**에서만 폐기할 수 있습니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 1ccaa8851a..7830d6a020 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -10,7 +10,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | 필드 | 형식 | 기본값 | 의미 | | --- | --- | --- | --- | | `port` | `number` | `10100` | 프록시 수신 포트입니다. | -| `hostname?` | `string` | `"127.0.0.1"` | 바인드 주소입니다. 루프백이 아닌 바인드에는 `OPENCODEX_API_AUTH_TOKEN`이 필요합니다. | +| `hostname?` | `string` | `"127.0.0.1"` | 바인드 주소입니다. 루프백이 아닌 바인드에는 데이터 admission 토큰이 필요하며, `OPENCODEX_API_AUTH_TOKEN` → `OCX_API_TOKEN_FILE` → 설치된 owner-only `service-api-token` 순서로 결정됩니다. 손으로 내보낼 값은 없습니다. [Remote access](#remote-access)를 보세요. | | `proxy?` | `string` | — | 송신용 HTTP(S) 프록시 URL 또는 `${ENV_VAR}`입니다. 해당 변수가 비어 있을 때만 `HTTP_PROXY` / `HTTPS_PROXY`에 적용되며, 루프백은 `NO_PROXY`에 그대로 남습니다. | | `emptyCompletionRetry?` | `boolean` | `false` | 텍스트나 도구 호출이 없는 Responses 턴을, 터미널 이벤트 전에 스트림이 종료된 경우를 포함해 동일한 요청으로 한 번 재시도하도록 선택합니다. 재시도에는 비용이 발생할 수 있습니다. `OCX_EMPTY_COMPLETION_RETRY=0`은 설정을 바꾸지 않고 비활성화하며, combo 및 routed-compaction turn은 제외됩니다. | | `stallTimeoutSec?` | `number` | `300` | 업스트림 데이터가 없을 때 `response.incomplete`가 되기까지의 초 수입니다. 최소 1입니다. | @@ -34,14 +34,18 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, ## Remote access -기본 `127.0.0.1` 바인드는 루프백 전용입니다. `0.0.0.0` 같은 루프백이 아닌 주소는 `/api/*`와 데이터 플레인 모두에서 토큰 인증이 필요합니다. 시작하기 전에 토큰을 내보냅니다: +기본 `127.0.0.1` 바인드는 루프백 전용입니다. `0.0.0.0`이나 tailnet IP처럼 루프백이 아닌 주소는 `/api/*`와 데이터 플레인 모두에서 토큰 인증이 필요합니다. + +토큰을 직접 만들 필요는 없습니다. 루프백이 아닌 바인드에서 `ocx service install`이 다음 순서로 토큰을 준비합니다: 설치하는 셸의 `OPENCODEX_API_AUTH_TOKEN` → 기존 owner-only `service-api-token` 파일 → 무작위 32바이트 새 값. 결과는 `0600`으로 기록되고 실행 래퍼(launchd plist, systemd unit, Windows 래퍼)가 시작할 때 그 파일을 읽으므로, 값이 서비스 정의나 argv에 들어가지 않습니다. 포그라운드 `ocx start`도 같은 우선순위(환경 변수 → `OCX_API_TOKEN_FILE` → 설치된 `service-api-token`)를 적용하므로 토큰을 내보내지 않아도 루프백이 아닌 hostname에 바인드합니다. + +`OPENCODEX_API_AUTH_TOKEN`에 **관리자 토큰**이 들어 있으면 거부합니다. 두 평면은 서로 다른 자격 증명이며, 거부 메시지는 대체 값을 제안하는 대신 `unset OPENCODEX_API_AUTH_TOKEN` 후 다시 실행하라고 알려 줍니다. 값을 직접 관리하려는 운영자는 여전히 변수를 설정할 수 있습니다: ```bash export OPENCODEX_API_AUTH_TOKEN="your-secret-token" ocx start ``` -이 변수가 없으면 프록시는 원격 바인드를 거부합니다. 백그라운드 서비스라면 `ocx service install` 전에 내보내서 launchd, systemd, 또는 Task Scheduler가 이를 받도록 합니다. 클라이언트는 다음을 보내야 합니다: +클라이언트는 다음을 보내야 합니다: ```text x-opencodex-api-key: your-secret-token @@ -66,6 +70,48 @@ Messages와 `count_tokens`는 라우팅 클라이언트 호환성을 위해 세 `0.0.0.0` 바인드는 프록시와 설정된 provider 접근을 LAN에 노출합니다. 신뢰할 수 있는 네트워크에서 강한 토큰과 함께만 사용합니다. ::: +### 토큰을 받을 수 없는 로컬 클라이언트 + +루프백이 아닌 바인드는 로컬 호출자에게도 자격 증명을 요구합니다. 그래서 한 가지 경우가 깨집니다. 호스트 프로세스가 Codex 진입점을 직접 resolve해서 띄운 `codex app-server`는 생성된 `codex` shim을 거치지 않으므로 `OPENCODEX_API_AUTH_TOKEN`을 물려받지 못하고, 모든 모델 호출이 스트림이 열리기 전에 `401`로 실패합니다. + +`unauthenticatedLoopbackListener`는 자격 증명 없이 허용하는 두 번째 리스너를 `127.0.0.1`에 엽니다. 메인 리스너는 그대로입니다. 원격 호출자는 여전히 토큰이 필요합니다. `port`는 **선택 사항**이고, 있는지 없는지가 두 형태를 가릅니다. + +`port`를 생략하면 *companion* 형태입니다. 리스너가 프록시 포트와 같은 번호로 `127.0.0.1`에 바인드합니다. + +```json +{ + "hostname": "100.76.170.81", + "port": 10100, + "unauthenticatedLoopbackListener": { "enabled": true } +} +``` + +원격 클라이언트는 자격 증명과 함께 `100.76.170.81:10100`을, 로컬 프로세스는 자격 증명 없이 `127.0.0.1:10100`을 호출합니다. 로컬 통합이 이미 기록하는 주소가 바로 그것이라서, 공개 바인드에 닿을 수 없는 호스트에서도 `ocx claude`, Claude Desktop, Cursor, `system-env` 주입이 그대로 동작합니다. + +companion 형태는 `hostname`이 루프백도 와일드카드도 아닌 구체 주소일 때만 허용됩니다. `127.0.0.1`, `localhost`, `0.0.0.0`에서는 공개 리스너가 이미 그 루프백 주소를 쓰고 있으므로, opencodex가 두 번째 바인드를 실패하게 두지 않고 쓰는 시점과 시작 시점에 거부합니다. 그런 바인드에서는 리스너가 필요 없습니다. 루프백 바인드는 이미 로컬 호출자를 허용합니다. + +`port`를 지정하면 두 표면이 서로 다른 포트에 놓입니다. + +```json +{ + "hostname": "0.0.0.0", + "port": 10100, + "unauthenticatedLoopbackListener": { "enabled": true, "port": 10200 } +} +``` + +이때 `ocx sync`는 관리되는 Codex provider 블록에 `base_url = "http://127.0.0.1:10200/v1"`을 기록하고 auth 헤더를 생략합니다. `port`는 프록시 포트와 달라야 하며 OS가 자동 할당하지 않습니다. 임시 포트는 재시작마다 바뀌는데 이미 실행 중인 app-server는 예전 `base_url`을 들고 있기 때문입니다. + +**어느 형태든 이 필드를 바꾸면 프록시를 재시작하세요.** 소켓은 시작할 때 한 번 바인드되고 내보내는 클라이언트 값도 그때 결정된 포트로 기록되므로, 실행 중인 프록시는 예전 답을 유지합니다. + +이 리스너는 `POST /v1/responses`와 그 WebSocket 업그레이드, `POST /v1/responses/compact`, `POST /v1/messages`, `POST /v1/chat/completions`, `POST /v1/alpha/search`, `GET /v1/models`, 실시간 음성 표면만 제공합니다. `POST /v1/messages/count_tokens`를 포함해 `/api/*`, `/healthz`, `/readyz`, 대시보드는 모두 `404`입니다. `ocx claude`의 탐색 호출 같은 로컬 관리 읽기는 관리 자격 증명을 들고 인증된 관리 표면으로 갑니다. + +:::danger[인증 없는 표면입니다] +컴퓨터의 모든 프로세스가 이 리스너를 사용할 수 있습니다. 계정 쿼터와 유료 provider 비용을 소모합니다. +::: + +`runtimeRole: "hub"`에서는 이 필드가 허브가 **자기 자신의** 로컬 클라이언트 설정을 고칠지 결정하는 게이트이기도 합니다. 리스너가 꺼져 있으면 `ocx sync`, `ocx ensure`, `ocx restore back`이 허브 자신의 Codex/Grok/Claude 쓰기를 건너뛰고, `clientIntegrations` 토글이 아니라 `unauthenticatedLoopbackListener`를 지목해 이유를 밝힙니다. + ### SSH port forwarding 원격 사용에 원격 바인드는 필요하지 않습니다. 루프백으로 유지한 채 포워딩하면 됩니다: @@ -166,6 +212,17 @@ Anthropic OAuth 사이드카는 opencodex의 기존 Claude Code OAuth fingerprin `runtimeRole` 기본값은 `standalone`입니다. 허브는 `hub.managementPublicOrigin`, 로컬에만 열리는 `hub.managementIngress`(없으면 `enabled:false`), 정확한 `remoteGui.allowedTailscaleUsers`(없으면 빈 목록)를 사용합니다. 클라이언트 데이터 키는 `config.json`이 아니라 `service-api-token`에 저장되며 교체 중에는 `service-api-token.prev`가 잠시 생길 수 있습니다. 사용량 기록은 서로 복제하지 않습니다. +| 키 | 형식 | 없을 때 기본값 | 역할 | +| --- | --- | --- | --- | +| `hub.managementPublicOrigin` | string | 없음 | 허브가 광고하는, 브라우저가 실제로 닿는 관리 Origin입니다. `runtimeRole`이 `hub`일 때 `/readyz`가 `managementUrl`로 보고합니다. 설정하지 않으면 요청이 도착한 Origin으로 대체하므로, 다른 프런트엔드 뒤의 클라이언트가 닿을 수 없는 주소를 받을 수 있습니다. | +| `hub.dataPublicOrigin` | string | 없음 | 원격 클라이언트가 **데이터** 플레인으로 호출해야 하는 정식 Origin입니다(예: tailnet 바인드 앞의 TLS 프런트엔드가 공개하는 HTTPS Origin). 광고용 값이며 바인드 주소가 아니고, 바꿔도 소켓은 움직이지 않습니다. `ocx hub invite`가 출력하는 `ocx connect` 줄의 위치 인자 URL로 쓰이고, 없으면 `http://:`로 대체합니다. 그 대체값은 원격 컴퓨터가 TLS로 닿지 못할 LAN/tailnet 주소이므로 프런트엔드가 있는 허브라면 설정하세요. 대부분의 선택 키와 달리 잘못된 값은 조용히 버리지 않고 쓰는 시점에 거부합니다. 바인드 주소로 대체되는 것이 바로 이 필드가 막으려는 상황입니다. | +| `hub.managementIngress` | `{enabled:false}` 또는 `{enabled:true, port}` | `{enabled:false}` | 로컬 HTTPS 프런트엔드용 관리 전용 리스너입니다. hostname은 설정할 수 없고, 켜면 항상 `127.0.0.1`에 바인드하며 GUI·세션 부트스트랩·관리 API 경로만 허용합니다. 데이터 플레인 경로는 dispatch 전에 거부합니다. | +| `remoteGui.allowedTailscaleUsers` | string[] | `[]`(아무도 없음) | 자동 원격 GUI 세션을 발급받을 수 있는 정확한 Tailscale 로그인 ID입니다. `Tailscale-User-Login` 헤더는 별도 관리 인그레스에서**만** 신뢰합니다. 빈 목록은 실수가 아니라 안전한 기본값입니다. 정확히 비교하므로 오타는 조용히 거부됩니다. | + +`dataPublicOrigin`과 `managementPublicOrigin`은 서로 독립적인 광고이며, 실제 배포에서는 서로 다른 소켓입니다. 관리는 443에 공개하는 루프백 전용 인그레스이고, 데이터는 자체 HTTPS 포트에 공개하는 tailnet 바인드입니다. 둘은 `ocx hub invite`가 출력하는 명령의 두 조각이고, 그중 `managementPublicOrigin`이 더 엄격합니다. pairing grant가 이 값을 grant 자신의 server origin으로 기록하고 교환 시 비교하므로, `ocx hub invite --management-url`은 설정값을 *확인*할 수만 있고 다른 값은 거부합니다. `--data-url`은 아무것도 바인드되어 있지 않으므로 실제로 덮어쓰기입니다. + +허브가 자기 로컬 클라이언트까지 서비스하려면 [`unauthenticatedLoopbackListener`](#토큰을-받을-수-없는-로컬-클라이언트)도 설정합니다. `port` 없는 companion 형태가 허브를 단일 포트 배포로 만들어 주며, 공개 리스너가 이미 `127.0.0.1:`를 쓰는 루프백·와일드카드 `hostname`에서는 거부됩니다. + `remoteGui.allowInsecureHttp`는 이전 strict-schema 설정을 계속 읽기 위해서만 남겨 둔 폐기된 no-op입니다. 설정에서 제거하세요. 페어링 grant는 loopback 또는 인증된 HTTPS에서만 허용되며, 이 값을 `true`로 설정해도 평문 HTTP 페어링은 다시 활성화되지 않습니다. ## Codex 할당량 네트워크 진단 diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index cf7237a1c1..1ec7d4ab4b 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -11,7 +11,7 @@ runs helper features around provider requests. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `port` | `number` | `10100` | Proxy listen port. | -| `hostname?` | `string` | `"127.0.0.1"` | Bind address. Non-loopback binds require `OPENCODEX_API_AUTH_TOKEN`. | +| `hostname?` | `string` | `"127.0.0.1"` | Bind address. A non-loopback bind requires a data-admission token, resolved from `OPENCODEX_API_AUTH_TOKEN`, then `OCX_API_TOKEN_FILE`, then the installed owner-only `service-api-token` — nothing has to be exported by hand. See [Remote access](#remote-access). | | `proxy?` | `string` | — | Outbound HTTP(S) proxy URL, `${ENV_VAR}`, or `"auto"`. Applied to `HTTP_PROXY` / `HTTPS_PROXY` only when those variables are unset; loopback remains in `NO_PROXY`. `"auto"` reads the Windows system proxy (WinINET `ProxyEnable`/`ProxyServer`, `https=` then `http=` entry) once at process start and logs the host it chose. On other platforms, or when the system proxy is off, SOCKS-only, or unreadable, it uses direct egress and says so. PAC/WPAD and live proxy changes are not followed; restart the service after changing the system proxy. | | `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | @@ -114,16 +114,28 @@ path failed, and does not establish a general fix. ## Remote access -The default `127.0.0.1` bind is loopback-only. A non-loopback address such as `0.0.0.0` requires -token authentication on both `/api/*` and the data plane. Export the token before starting: +The default `127.0.0.1` bind is loopback-only. A non-loopback address such as `0.0.0.0` or a tailnet +IP requires token authentication on both `/api/*` and the data plane. + +You do not have to produce that token. `ocx service install` provisions one on a non-loopback bind, +in this order: `OPENCODEX_API_AUTH_TOKEN` from the installing shell, then an existing owner-only +`service-api-token` file, then 32 fresh random bytes. The result is written `0600` and the launch +wrapper (launchd plist, systemd unit, Windows wrapper) reads the file at start, so the value never +enters a service definition or argv. A foreground `ocx start` applies the same precedence — +environment, then `OCX_API_TOKEN_FILE`, then the installed `service-api-token` — so it binds a +non-loopback hostname without an exported token too. + +A **management admin token** in `OPENCODEX_API_AUTH_TOKEN` is refused: the two planes are different +credentials, and the refusal says to `unset OPENCODEX_API_AUTH_TOKEN` and rerun rather than +suggesting a substitute value. Setting the variable yourself is still supported for an operator who +wants to own the value: ```bash export OPENCODEX_API_AUTH_TOKEN="your-secret-token" ocx start ``` -The proxy refuses a remote bind without this variable. For a background service, export it before -`ocx service install` so launchd, systemd, or Task Scheduler receives it. Clients should send: +Clients should send: ```text x-opencodex-api-key: your-secret-token @@ -197,8 +209,19 @@ loopback bind already admits local callers. With a `port` set, the local integrations follow the listener: `ocx claude`, the `system-env` injection, the Claude Desktop profile, the Cursor gateway value and the routed vision helper all -write `http://127.0.0.1:`, the same port `ocx sync` writes into Codex. Restart the -proxy after changing this field so those values are rewritten. +write `http://127.0.0.1:`, the same port `ocx sync` writes into Codex. In the +companion form those same integrations keep writing the proxy port, which is where the companion +socket is. + +**Restart the proxy after changing this field, in either form.** The sockets are bound once at +startup and the exported client values are written from the resolved port, so a running proxy keeps +its previous answer — on a ported listener that is the difference between a served request and a +`404` from the listener. + +On a `runtimeRole: "hub"`, this field is also the gate on whether the hub rewrites **its own** local +client configuration. With the listener off, `ocx sync`, `ocx ensure` and `ocx restore back` skip the +hub's own Codex/Grok/Claude writes and say so, naming +`unauthenticatedLoopbackListener` rather than the `clientIntegrations` toggle. The listener serves only `POST /v1/responses`, its WebSocket upgrade, `POST /v1/responses/compact`, `POST /v1/messages` (the Anthropic wire Claude Code and Claude Desktop speak), @@ -500,6 +523,7 @@ intended account and workload. | Key | Type | Default when absent | What it does | | --- | --- | --- | --- | | `hub.managementPublicOrigin` | string | unset | The canonical browser-reachable management origin a hub advertises, for example the HTTPS origin Tailscale Serve prints. It is what `/readyz` reports as `managementUrl` while `runtimeRole` is `hub`; with it unset the hub falls back to whatever origin each request arrived on, so a client behind a different frontend can be handed an address it cannot reach. | +| `hub.dataPublicOrigin` | string | unset | The canonical origin a remote client should dial for the **data** plane, for example the HTTPS origin a TLS frontend publishes in front of the tailnet bind. Advisory only: it is never a bind address and changing it moves no socket. `ocx hub invite` prints it as the positional URL of the `ocx connect` line, falling back to `http://:` — which is a LAN/tailnet address a remote machine may not be able to reach over TLS, so set this on any hub with a frontend. Unlike most optional keys it is **not** silently dropped when malformed: a typo is rejected at write time, because falling back to the bind address is exactly what the field exists to avoid. | | `hub.managementIngress` | `{enabled:false}` or `{enabled:true, port}` | `{enabled:false}` | An extra management-only listener for a local HTTPS frontend. The hostname is not configurable: when enabled the socket always binds `127.0.0.1`, and only GUI, session-bootstrap, and management API routes are admitted. Data-plane routes are rejected before dispatch. | | `remoteGui.allowedTailscaleUsers` | string[] | `[]` (empty — nobody) | Exact Tailscale login identities allowed to be issued an automatic remote GUI session. The `Tailscale-User-Login` header is trusted **only** on the separate management ingress; an empty list means no remote identity can mint a session, which is the safe default rather than an oversight. Identities are compared exactly, so a typo silently denies access. | | `remoteGui.allowInsecureHttp` | boolean | unset | **Retired — has no effect.** It once permitted a one-time pairing exchange over non-loopback plaintext HTTP. A pairing grant now crosses loopback or authenticated HTTPS only. The key is still parsed so an existing `config.json` keeps loading (the schema is strict, and dropping the key outright would make an older config fail to load entirely); a persisted `true` is reported once and then ignored. Remove it from your config. | @@ -508,3 +532,16 @@ A hub that is reachable from a browser needs `hub.managementPublicOrigin` and at in `remoteGui.allowedTailscaleUsers`. Setting the origin without the user list produces a hub that advertises itself correctly and then refuses every session; setting the user list without the origin produces sessions pointed at whichever origin the request happened to use. + +`dataPublicOrigin` and `managementPublicOrigin` are two independent advertisements, and on a real +deployment they are two different sockets: management is the loopback-only ingress published on 443, +data is the tailnet bind published on its own HTTPS port. They are the two halves of what +`ocx hub invite` prints, and `managementPublicOrigin` is the stricter of the two — a pairing grant +records it as the grant's own server origin and the exchange compares against it, which is why +`ocx hub invite --management-url` can only *confirm* the configured value and refuses one that +differs. `--data-url` really is an override, because nothing is bound to it. + +A hub that serves its own local clients also sets +[`unauthenticatedLoopbackListener`](#local-clients-that-cannot-receive-the-token). Its port-less +companion form is what makes a hub a single-port deployment, and it is refused on a loopback or +wildcard `hostname`, where the public listener already holds `127.0.0.1:`. diff --git a/tests/ci-workflows/docs-remote-hub-claims.test.ts b/tests/ci-workflows/docs-remote-hub-claims.test.ts index c2d73f428e..1ad087378e 100644 --- a/tests/ci-workflows/docs-remote-hub-claims.test.ts +++ b/tests/ci-workflows/docs-remote-hub-claims.test.ts @@ -14,11 +14,17 @@ * * These assertions are cheap and the guide is edited often, which is the whole reason the first * defect survived to a public URL. + * + * The third group (#4236) pins the one-port recipe. The manual + * `export OPENCODEX_API_AUTH_TOKEN=…` step is the one that has to stay gone: it is how the + * maintainer's hub ended up with a management admin token in the data-plane variable, and the + * service now provisions its own token, so re-adding the line would re-teach the incident. */ import { describe, expect, test } from "bun:test"; import { repoPath } from "../helpers/repo-root"; const GUIDE = repoPath("docs-site/src/content/docs/guides/remote-hub.md"); +const KO_GUIDE = repoPath("docs-site/src/content/docs/ko/guides/remote-hub.md"); describe("remote hub guide", () => { test("no nested config set runs before its parent object exists", async () => { @@ -86,3 +92,63 @@ describe("remote hub guide", () => { expect(source).not.toContain("--allow-insecure-http"); }); }); + +/** + * The one-port recipe (#4236). Both locales are in scope: Korean is the only translation this + * unit rewrote, and a translation that still tells the reader to export a token is worse than a + * missing one because it contradicts the English page it claims to mirror. + */ +describe("the one-port hub recipe", () => { + const LOCALES = [["en", GUIDE], ["ko", KO_GUIDE]] as const; + + test("both locales teach the port-less companion form", async () => { + for (const [locale, file] of LOCALES) { + const source = await Bun.file(file).text(); + // The companion form IS the recipe: `{"enabled":true}` with no port binds 127.0.0.1 on the + // proxy port, which is the address every local integration already writes. + expect(source, locale).toContain(`ocx config set unauthenticatedLoopbackListener '{"enabled":true}'`); + // The ported form stays documented as the alternative, because existing hubs run it. + expect(source, locale).toContain(`{"enabled":true,"port":10104}`); + } + }); + + test("no locale tells the operator to export a data-plane token by hand", async () => { + for (const [locale, file] of LOCALES) { + const source = await Bun.file(file).text(); + // Line-anchored, because that is the SHELL STEP the guide used to carry. Prose is still + // free to name the variable -- it has to, to say the step is gone and why the admin token + // is refused there. What must not come back is a line telling the reader to export it. + expect(source, locale).not.toMatch(/^\s*export\s+OPENCODEX_API_AUTH_TOKEN/m); + // Precedence has to be stated, or the reader cannot tell what an existing file will do. + expect(source, locale).toContain("service-api-token"); + } + }); + + test("both locales route a new machine through ocx hub invite", async () => { + for (const [locale, file] of LOCALES) { + const source = await Bun.file(file).text(); + expect(source, locale).toContain("ocx hub invite"); + // `invite` mints nothing until a loopback browser origin is admitted, and the fix is this + // exact command. Naming the flag without the precondition sends the operator to a refusal. + expect(source, locale).toContain(`ocx config set corsAllowOrigins '["http://localhost:10100"]'`); + expect(source, locale).toContain("--pairing-code-stdin"); + } + }); + + test("the English page keeps the macOS launchd semantics a repair changed", async () => { + const source = await Bun.file(GUIDE).text(); + // `restart` aliases `repair`, and a repair of a healthy job is now a no-op, so the kickstart + // line is the only way to actually bounce a launchd hub. + expect(source).toContain("launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy"); + // The fourth status state is the one that used to be reported as "not loaded" and sent + // operators to repair a serving hub. + expect(source).toContain("launchd state could not be verified"); + }); + + test("the English page says the companion listener is not a TLS target", async () => { + // It is a real socket on 127.0.0.1, so Serve will happily create the mapping -- and then the + // loopback Host check rejects the forwarded Host exactly as the plain-loopback trap does. + const source = await Bun.file(GUIDE).text(); + expect(source).toContain("Do not point Serve at the loopback companion listener"); + }); +}); From de8674f5d63c187650a9a1578d58e8a205c2ef41 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 11 Sep 2026 12:10:16 +0900 Subject: [PATCH 2/7] docs(skill): teach the ocx skill the one-port hub, invites, and launchd no-ops The skill described a hub whose only interesting decisions were pairing and disconnection. After #4236 three more things are wrong to guess, and each of them is a thing an agent would otherwise report as a bug. `SKILL.md` leads with the topology: one port, the companion listener, `ocx hub invite` instead of a hand-built `ocx connect`, `--management-url` as a confirmation, and "read the `Hub:` block in `ocx status` before asking the operator about ports or tokens". `05_remote_hub.md` gains the listener's two forms and the collision refusal, the exact list of admitted wires with an explicit "do not propose widening it to `/api/*`", the hub gate on the hub's own clients quoted verbatim, the invite flow including the `No loopback browser origin is admitted for pairing` refusal and the command that fixes it, and a section on the data token that says what NOT to do with it: never tell an operator to export one, never regenerate it to fix something, never copy the file to another machine. `04_failure_semantics.md` gains the two states that read as failures and are not. A repair printing `nothing to do` is success. `ocx service restart` aliases `repair`, so on a healthy macOS job it restarts nothing -- which matters precisely when a restart is the requirement. The four launchd verdicts get a per-row "repair?" column, because `launchd state could not be verified` is an unanswerable probe, not a down service, and treating it as one is what used to send operators to repair a serving hub. A hub-gated skip exits 0 having written nothing and has nothing to retry. `03_recipes.md` gains recipe 10: read the hub block, `ocx hub invite --json`, hand over `command`, and the two refusals that burn no code. `01_management_surface.md` is generated and was left alone; `skill:surface:check` reports it current. Co-Authored-By: Claude Fable 5.1 --- skills/ocx/SKILL.md | 19 ++- skills/ocx/references/03_recipes.md | 28 ++++ skills/ocx/references/04_failure_semantics.md | 45 ++++++ skills/ocx/references/05_remote_hub.md | 142 +++++++++++++++++- 4 files changed, 230 insertions(+), 4 deletions(-) diff --git a/skills/ocx/SKILL.md b/skills/ocx/SKILL.md index a5f19d861f..ecd834153c 100644 --- a/skills/ocx/SKILL.md +++ b/skills/ocx/SKILL.md @@ -127,7 +127,20 @@ Report the count and bytes from that output and get explicit approval before add `--mode quarantine` (the default) can be undone with `storage trash restore`; `--mode permanent` cannot. -## Remote hub: two things agents get wrong +## Remote hub: three things agents get wrong + +**A hub is one port, and `ocx hub invite` writes the join command for you.** Remote machines dial +`hostname:port` with their own per-client key; the hub's own processes dial `127.0.0.1:` with no credential, through the loopback companion listener +(`unauthenticatedLoopbackListener: {"enabled": true}`, no port). Run `ocx hub invite` on the hub +rather than assembling an `ocx connect` line: it mints a single-use code and prints the exact +command, with both origins already filled in. Its `--management-url` is a confirmation of +`hub.managementPublicOrigin`, not an override. Do not persist the code it prints. + +Two consequences that look like bugs and are not. `ocx status` on a hub prints a `Hub:` block — +read it before asking the operator anything about ports or tokens. And a hub does not rewrite its +**own** Codex/Grok/Claude configs unless that listener is enabled; the skip says so in those words, +and it is a gate, not the `clientIntegrations` toggle. **Pairing is not hub setup.** Configuring a hub — providers, accounts, routing, keys — never needs a pairing code. `GET /opencodex-session` mints a session by itself for a loopback @@ -150,7 +163,9 @@ When `disconnect` refuses, do not route around it. Each refusal means the unwind proven safe: another process owns the token, no journal records the pre-connect state, a different client key owns the journal, or the restore was only partial. -Details, including key rotation's two-step commit: `references/05_remote_hub.md`. +Details, including the one-port recipe, the invite flow and key rotation's two-step commit: +`references/05_remote_hub.md`. Service and launchd semantics, including why +`ocx service repair` can correctly do nothing: `references/04_failure_semantics.md`. ## References diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index d3a2e0b824..471978d9f2 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -261,6 +261,34 @@ provider sets `liveModels: false` deliberately — its authenticated roster incl models this Responses-agent provider cannot drive — so the absence of a live probe is a design decision, not a broken connection. +## 10. Invite one more machine onto a hub + +Run on the **hub**. This is the whole flow; do not assemble an `ocx connect` line by hand. + +```bash +ocx status # read the Hub: block first -- origins, listener, token source +ocx hub invite --json +``` + +`--json` gives `{ code, expiresAt, dataUrl, managementUrl, command }`. Hand the operator +`command` to run on the other machine; it already carries the data origin, the management +origin and `--pairing-code-stdin`. The code is a secret with a five-minute TTL and one use: +do not persist it, do not put it in a file, and prefer letting the operator copy it rather +than keeping it in a transcript. + +Two refusals are normal and neither burns a code: + +- `No loopback browser origin is admitted for pairing` — run the + `ocx config set corsAllowOrigins '["http://localhost:10100"]'` line the error prints, using + the **joining** machine's proxy port, then invite again. Grants are origin-bound and + `ocx connect` presents its own `http://localhost:`. +- A rejected `--management-url` — on `invite` that flag confirms + `hub.managementPublicOrigin` rather than overriding it. Drop the flag, or change the config. + +If `hub.dataPublicOrigin` is unset, `invite` falls back to `http://:`, which a +remote machine behind a TLS frontend usually cannot reach. Check that before handing the +command over. Full context: [05_remote_hub.md](05_remote_hub.md#inviting-a-machine-ocx-hub-invite). + ## Aside profiles These commands and the Aside refresh in `ocx sync` require a compatible running ocx proxy. diff --git a/skills/ocx/references/04_failure_semantics.md b/skills/ocx/references/04_failure_semantics.md index 17b66a92ac..044a509a7a 100644 --- a/skills/ocx/references/04_failure_semantics.md +++ b/skills/ocx/references/04_failure_semantics.md @@ -68,6 +68,51 @@ and repeating the call produces the same error indefinitely. The rule behind all of it: retry contention, never retry a broken state. A loop that retries a credential conflict looks like progress and produces nothing. +## Service and launchd semantics (macOS) + +Two states that read as failures and are not. Both come from the same change: a repair of a +healthy job must not be an outage. + +**`ocx service repair` printing `service is already loaded from the current plist; nothing to +do.` is success.** The repair renders the plist first and compares it. When the rendered +bytes match the file, the token file is unchanged, and `launchctl print` reports the job +loaded from that plist, launchd is not touched at all. Do not retry it, and do not escalate +to `ocx service uninstall`. + +**`ocx service restart` is an alias of `repair`, so on a healthy macOS job it restarts +nothing.** That matters whenever a restart is the actual requirement — after a change to +`unauthenticatedLoopbackListener`, `hostname` or `port`. To bounce the process, tell the +operator to run `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy`, or +`ocx service stop` followed by `ocx service start`. Reserve `ocx service repair` for a job +loaded from an older plist, or not loaded at all. + +`ocx service status` has four launchd verdicts, and only two of them call for a repair: + +| Summary | Meaning | Repair? | +|---|---|---| +| `installed and loaded` | A domain answers and runs the command this plist bakes | no | +| `installed and loaded from an OLDER plist` | Running, from a definition that no longer matches | yes | +| `installed, not loaded` | Every domain answered "absent" — proof the job is gone | yes | +| `installed; launchd state could not be verified` | `launchctl` could not be asked | **no** | + +The last row is the one to get right. It is not evidence the service is down: the command +itself recommends nothing, and a probe that could not run never marks a running proxy as +dead. Reporting it as "not loaded" is what used to send operators to repair a serving hub. +If the proxy answers `ocx ready`, the hub is up regardless of what the probe could see. + +## A hub-gated skip is not a failure + +`ocx sync`, `ocx sync-cache`, `ocx ensure` and `ocx restore back` on a `runtimeRole: "hub"` +can exit 0 having deliberately written nothing: + +> This machine is a hub; it does not rewrite its own Codex/Grok/Claude configs unless +> unauthenticatedLoopbackListener is enabled. + +That is the hub gate, not the operator's `clientIntegrations` toggle, and not a lock +conflict — there is nothing to retry. Either enable the listener and restart the proxy, or +report that this hub leaves its own clients native. Details: +[05_remote_hub.md](05_remote_hub.md#the-hub-gate-on-the-hubs-own-clients). + ## Destructive verbs fail closed `storage trash restore` and `storage policy run` exit 2 without `--yes` and send no mutating diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index 0c0596be07..d5614ac47f 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -1,9 +1,95 @@ # Remote hub: roles, sessions, and disconnection The remote hub lets one machine hold the models and credentials while other machines -and browsers use them. Three questions come up constantly, and two of them have +and browsers use them. Four questions come up constantly, and three of them have answers that are easy to guess wrong. +## One port, and what runs on it + +A hub's data plane is one port. Remote machines dial `hostname:port` with their own +per-client key. The hub's own processes dial `127.0.0.1:` with no +credential, through the **loopback companion listener**: + +```json +{ + "runtimeRole": "hub", + "hostname": "100.64.0.10", + "port": 10100, + "unauthenticatedLoopbackListener": { "enabled": true } +} +``` + +`port` on that listener is optional, and its absence is the whole design. Omitted means +"bind `127.0.0.1:`" — the address `ocx claude`, Claude Desktop, Cursor, the +`system-env` injection and the routed vision helper already write, so nothing on the hub +has to learn a new port. Setting a `port` (`{ "enabled": true, "port": 10104 }`) still +works and puts the two surfaces on separate ports; local integrations then follow the +listener's port. + +The port-less form is refused on a loopback or wildcard `hostname` — `127.0.0.1`, +`localhost`, `0.0.0.0`, `::` — because the public listener already holds that loopback +address. The refusal happens at write time and again at startup, naming the collision. On +those binds the listener is unnecessary: a loopback bind already admits local callers. + +The listener carries inference wires only: `POST /v1/responses` and its WebSocket +upgrade, `POST /v1/responses/compact`, `POST /v1/messages`, +`POST /v1/chat/completions`, `POST /v1/alpha/search`, `GET /v1/models`, and the realtime +voice surface. `POST /v1/messages/count_tokens`, `/api/*`, `/healthz`, `/readyz` and the +dashboard all answer `404` there. **That is why a management read never goes to it**: +local management calls use the authenticated management surface with a management +credential. Do not propose widening the listener to `/api/*` as a fix for anything. + +Changing this field needs a proxy restart — the sockets bind once at startup and the +exported client files are written from the resolved port. + +### The hub gate on the hub's own clients + +A hub does **not** rewrite its own Codex/Grok/Claude configuration unless that listener is +enabled. `ocx sync`, `ocx sync-cache`, `ocx ensure` and `ocx restore back` skip the write +and say exactly this: + +> This machine is a hub; it does not rewrite its own Codex/Grok/Claude configs unless +> unauthenticatedLoopbackListener is enabled. + +Read that as the gate, not as the operator's `clientIntegrations` toggle — it is claimed +only when the toggle is ON and the gate is what stopped the write. A gated `ocx ensure` +leaves an existing managed Grok block in place instead of stripping it, and a gated +`ocx restore back` reports the gate instead of blaming a competing writer. The fix is to +enable the listener and restart, or to accept that this hub leaves its own clients native. + +### The hub's data token is not yours to produce + +The hub's data-admission token provisions itself. `ocx service install` on a non-loopback +bind resolves it as: `OPENCODEX_API_AUTH_TOKEN` from the installing shell, then an existing +owner-only `service-api-token` file, then 32 fresh random bytes. The result is written +`0600` and the launch wrapper reads the file at start, so the value never enters a plist, a +unit file or argv. + +Three consequences for an agent: + +- **Never tell an operator to export a token before installing.** There is no such step, and + the one time it was recommended, a *management admin* token went into + `OPENCODEX_API_AUTH_TOKEN` and crash-looped the hub. The installer refuses an admin token + there and says to `unset OPENCODEX_API_AUTH_TOKEN` and rerun. +- **Never suggest regenerating it to fix something.** An existing file is reused on purpose; + replacing it invalidates every per-client key already exchanged. Rotation is + `ocx connect rotate`'s job, on the client. +- **Never copy the file to another machine.** Each client gets its own revocable key from the + pairing exchange. + +`unsafe (file)` in the status block means the file exists with permissions the installer +will not accept. Report it and let the operator fix the permissions; do not read, print, or +rewrite the file. + +### `ocx status` answers most hub questions + +On a hub, `ocx status` prints a `Hub:` block: the advertised data origin and whether it +came from `hub.dataPublicOrigin` or the bind address, the loopback listener's state +(`companion` / `ported` / `off`) and port, the management ingress, the management origin, +the data token's **source** (`present (env)`, `present (file)`, `unsafe (file)`, +`missing` — never its value), and the invite hint. Read it before asking an operator about +ports or tokens. + ## Which parts need pairing (the common misconception) **Pairing is not how you configure a hub.** It is how a *remote browser* gets a session @@ -41,7 +127,7 @@ neither loopback position nor Tailscale identity vouches for it. | `hub` | Holds models and credentials. Other machines connect to it. | | `client` | Connected to a hub. `ocx connect` puts a machine in this role. | -Minimum hub config: +Minimum hub config for a browser-reachable hub: ```json { @@ -50,6 +136,10 @@ Minimum hub config: } ``` +A hub that also serves its own local clients adds the loopback listener above. A hub that +hands out invites also needs `hub.dataPublicOrigin` unless `http://:` is +genuinely reachable from the joining machine. + `managementPublicOrigin` is the origin a browser actually reaches, which is the outside address when a TLS terminator or reverse proxy sits in front. `/readyz` advertises it as `managementUrl`. @@ -82,6 +172,7 @@ secret in argv; there is no flag for it and adding one would defeat the design. | `ocx disconnect [--keep-catalog]` | Restore local state and clear the connection | | `ocx gui` | Open the dashboard | | `ocx gui pair --origin ` | Issue a pairing grant for a remote browser | +| `ocx hub invite [--json]` | Hub-side: mint a code and print the whole `ocx connect` line for one more machine | Connect flags: `--clients codex,claude` (which client configs to point at the hub), `--management-url ` (when management lives at a different address), @@ -94,6 +185,53 @@ inactivity before failing; arriving bytes reset the deadline). `corsAllowOrigins`. Grants are single-use, expire in five minutes, are origin-bound, stored as digests, and rate-capped at 8/min. They are secrets: do not persist one. +## Inviting a machine (`ocx hub invite`) + +Run on the **hub**. It prints the command for the other machine: + +```bash +ocx hub invite +``` + +```text +# Run on the other machine: +echo '' | ocx connect https://host.ts.net:8443 --management-url https://host.ts.net --pairing-code-stdin +``` + +Origins: data from `hub.dataPublicOrigin`, `--data-url`, or `http://:` as a +last resort; management from `hub.managementPublicOrigin`. **`--management-url` is a +confirmation, not an override** — the grant records the configured management origin as its +own server origin and the exchange compares against it, so a differing value is refused with +both origins named rather than printed. `--data-url` really is an override, because nothing +is bound to it. + +`invite` needs no admin token and nothing exported into the shell: it drives the same +attested local route `ocx gui pair` uses, authorized by the running proxy's own attestation +secret. It requires a running hub. + +It refuses **before** minting anything when the setup cannot work: `runtimeRole` is not +`hub`, `hub.managementPublicOrigin` is missing, the management origin is non-loopback +plaintext, `--data-url` is malformed, there is no running attested proxy, or — the +non-obvious one — the hub admits no loopback browser origin. + +That last one is the refusal you will actually hit: + +```text +No loopback browser origin is admitted for pairing. Add the connecting machine's local origin: +ocx config set corsAllowOrigins '["http://localhost:10100"]' +``` + +`ocx connect` sends `Origin: http://localhost:` when it exchanges the +grant, and grants are origin-bound, so only `hub.managementPublicOrigin` itself or a +loopback entry of `corsAllowOrigins` can ever match. Run the command it prints, with the +**joining** machine's proxy port. Nothing was minted, so there is no burned code to clean up. + +`--json` emits `{ code, expiresAt, dataUrl, managementUrl, command }` with `expiresAt` as +ISO 8601; `--clients codex,claude` chooses which client configs the printed command points +at the hub. The code goes to stdout and the "secret, single-use" warning to stderr, matching +`ocx gui pair`. Treat it as a secret: five-minute TTL, one use, rate-capped. Do not persist +it, and do not echo it back into a transcript you are keeping. + ## Reading `ocx connect status` Disconnected is a single line. Connected prints hub, management URL and transport, From a3dde46b422799d31492689796c64aabae46711e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 11 Sep 2026 12:10:25 +0900 Subject: [PATCH 3/7] docs(cli): say that a macOS restart is a no-op and that --management-url only confirms Read as a first-time hub operator, PR4's help text is accurate; three things were not. `ocx service` had exactly one line about `restart`, and it was Windows-specific. An operator reading it infers that `restart` restarts the service, which on macOS is false after PR1: `restart` aliases `repair`, and a repair whose plist, token file and loaded job already agree returns early without touching launchd. The details now say so and name `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy` (or stop then start) for the case where a bounce is the actual requirement -- a change to hostname, port or `unauthenticatedLoopbackListener`. The same block now lists the four `ocx service status` verdicts, so `launchd state could not be verified` is read as an unanswerable probe rather than a down service. `ocx hub`'s usage line advertises `--management-url`, and nothing said it is a confirmation of `hub.managementPublicOrigin` rather than an override. An operator reading only the help would pass a different value and hit a refusal. `--clients` was undocumented too. The top-level banner's `ocx status` line now mentions the hub block, because nothing in `ocx --help` pointed at the one command that answers "what is this hub doing". No behavior change. Co-Authored-By: Claude Fable 5.1 --- src/cli/help.ts | 2 +- src/cli/registry.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cli/help.ts b/src/cli/help.ts index 4137dabda7..764029b63e 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -45,7 +45,7 @@ Usage: ocx sync [--restart-codex] Fetch models from providers and inject into Codex config ocx sync-cache [--restart-codex] Refresh Codex's model cache from the active catalog - ocx status Check proxy server status + ocx status Check proxy server status (on a hub: one block with its ports and token source) ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability) ocx doctor --reclaim-response-temps Reclaim abandoned response-state temp files (works without a running proxy) diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 7726fd1a47..f219043254 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -67,7 +67,9 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "With no subcommand, installs when absent or repairs an existing service.", "`repair` refreshes the definition and reloads the manager only when something changed, so repairing a healthy service is not an outage.", "`restart` is the same refresh but always restarts: on macOS an unchanged, already-loaded job is kickstarted in place. Healthy Windows tasks are reused, while stale definitions may re-register and elevate.", - "Use `ocx service status` to see diagnostics and log paths.", + "Use `ocx service status` to see diagnostics and log paths. On macOS it reports four states:", + "loaded from the current plist, loaded from an OLDER plist (repair), not loaded (repair), or", + "`launchd state could not be verified` -- which is an unanswerable probe, NOT a down service.", "Data-plane token: nothing has to be exported by hand. On a non-loopback hostname install/repair uses", "OPENCODEX_API_AUTH_TOKEN when set, otherwise reuses the existing owner-only ~/.opencodex/service-api-token,", "otherwise generates one; the launch wrapper reads that file at start and the value never enters a plist,", @@ -186,6 +188,9 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "the fallback is http://:. A loopback or wildcard bind has no such address, so", "invite refuses instead of advertising http://localhost:, which would tell the other machine", "to dial itself and spend the code.", + "--management-url is a CONFIRMATION, not an override: the grant is bound to", + "hub.managementPublicOrigin, so a differing value is refused instead of printed.", + "--clients chooses which client configs the printed ocx connect line will point at the hub.", "--json emits { code, expiresAt, dataUrl, managementUrl, command }. Do not persist the code.", "Hub state, including the data token and the companion listener, is reported by `ocx status`.", ], From 7bc6647b6d632ab364f532d49543d53b9ea62370 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 11 Sep 2026 12:10:30 +0900 Subject: [PATCH 4/7] docs(devlog): record the docs and skill unit (PR5) Records the en+ko rewrite, the reference-config additions, the skill pages, the help corrections, the extended guard test, and the two findings that changed the copy: a macOS `ocx service restart` does not bounce a healthy job, and the loopback companion listener must not be a Tailscale Serve target. Names the five translations left out of scope and exactly what they still claim. Co-Authored-By: Claude Fable 5.1 --- .../260911_hub_single_port/050_docs_skill.md | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 devlog/_plan/260911_hub_single_port/050_docs_skill.md diff --git a/devlog/_plan/260911_hub_single_port/050_docs_skill.md b/devlog/_plan/260911_hub_single_port/050_docs_skill.md new file mode 100644 index 0000000000..440bf35e72 --- /dev/null +++ b/devlog/_plan/260911_hub_single_port/050_docs_skill.md @@ -0,0 +1,249 @@ +# 050 — PR5: docs (en + ko), the `ocx` skill, and the help copy + +Unit: `devlog/_plan/260911_hub_single_port`. Stack position 5 of 5. Branch +`codex/260911-l7-hub-docs-skill`, based on `codex/260911-l4-hub-token-ux` = `fb1898e19` +(`test(service): drop the installLaunchd import the restack left unused`), which carries PR1 +(launchd repair), PR2 (loopback companion), PR3 (hub local clients) and PR4 (hub token UX) in its +ancestry — verified with `git log --oneline -12` before the first edit. Issue: +lidge-jun/opencodex#4236. The four devlogs `010`–`040` in this directory are the source of truth +for what the code does; nothing here was copied from the plan without checking it against `src/`. + +No runtime behaviour changes. The only `src/` edits are help/registry copy. + +## Scope: English and Korean only + +`docs-site/` carries seven translations (`fr`, `ja`, `ko`, `ru`, `tr`, `zh-cn`, `zh-tw`). This unit +rewrote **en + ko** and deliberately left the other five out of scope, matching #4241's precedent +(English first, translations as follow-ups) and the operator's instruction. Consequence recorded +honestly: `fr`, `ja`, `ru`, `tr`, `zh-cn` and `zh-tw` copies of `guides/remote-hub.md` and +`reference/configuration/server.md` still describe the pre-#4236 world — the manual +`export OPENCODEX_API_AUTH_TOKEN` step, the ported-only loopback listener, no `ocx hub invite`. Those +pages also still carry #4241's two defects (the nested `ocx config set` before its parent object, and +the retired `--allow-insecure-http`), which #4241 likewise left to a follow-up. That is one +translation-parity task, not six: the en page is now the only place the recipe is maintained. + +## What shipped + +### 1. `guides/remote-hub.md` (en), rewritten around the one-port recipe + +#4241's structure and every claim its guard test pins are intact. What changed: + +- **The setup block is the one-port recipe.** `hostname` = the tailnet IP, `hub.dataPublicOrigin` + beside `hub.managementPublicOrigin`, and + `ocx config set unauthenticatedLoopbackListener '{"enabled":true}'` — the port-less companion + form. The `export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)"` line is **gone**, and + `ocx status` was added to the end of the block because the `Hub:` block PR4 added summarizes + every line above it. +- **New `### The data-plane token provisions itself`.** The three-step precedence (env → existing + owner-only file → 32 fresh random bytes), that only the path is printed, that a foreground + `ocx start` reads the same file, that an **admin** token in `OPENCODEX_API_AUTH_TOKEN` is refused + with `unset … and rerun`, and the four token-source strings `ocx status` prints. +- **New `### One port, and the ported alternative`.** The companion form, the collision refusal on a + loopback or wildcard `hostname` (write time *and* startup), the ported form + `{"enabled":true,"port":10104}` kept as the documented alternative, and the restart requirement — + which is PR3's "a restart is required for ported-form hosts" caveat, stated where an operator will + hit it. +- **New `### The hub's own local clients`.** PR3's result: `ocx claude`, Claude Desktop, Cursor, + `system-env` and the vision helper now work on the hub. The exact admitted wire list, + `count_tokens` named as **not** admitted (Claude Code degrades to local estimation), and why + `/api/*` stays 404 there. The hub-gate sentence is quoted verbatim, with the note that it means the + gate and not `clientIntegrations`. +- **New `## Inviting another machine`.** `ocx hub invite` → the copy-paste + `echo '' | ocx connect … --pairing-code-stdin` block; the origin resolution; that + `--management-url` is a confirmation and not an override; the list of refusals that happen *before* + a code is minted; and the `corsAllowOrigins` precondition with the exact + `ocx config set corsAllowOrigins '["http://localhost:10100"]'` command and the reason (`ocx connect` + presents `Origin: http://localhost:`, grants are origin-bound). +- **New `## macOS service operations`.** The repair no-op and its exact log line; the status + four-state table with `launchd state could not be verified` explained as an unanswerable probe + rather than a down service; and the restart correction below. +- **Tailscale Serve kept, with two corrections.** The forwarder section survives intact (it is still + required: Serve proxies only to `127.0.0.1`). Its example port moved from `10100` to `10110`, + because on a companion hub `127.0.0.1:10100` is opencodex's own socket. And a new paragraph says + **do not point Serve at the companion listener** — see the finding below. The loopback-bind trap + table is unchanged and now says explicitly that it is about the *bind*, with the companion listener + named as the sanctioned way to get a `127.0.0.1` socket on a hub. +- Seven new troubleshooting rows for the new failure surfaces, each naming the command that fixes it. + +### 2. `ko/guides/remote-hub.md`, rewritten to mirror the en structure + +The ko page existed but was a condensed pre-#4241 copy: it set `hub.managementPublicOrigin` before +creating `hub` (the #4200 defect, in Korean), exported the token by hand, and offered the retired +`--allow-insecure-http`. It is now a section-for-section mirror of the en page, including the +parent-object warning with `config parent path not found: hub`, the one-port recipe, the token +precedence, both listener forms, the hub's own local clients, the invite flow, the Serve forwarder +and the `403 origin_rejected` trap, the macOS four-state table, and the same troubleshooting rows. + +### 3. `reference/configuration/server.md` (en + ko) + +- **en.** PR2's port-optional paragraphs were verified present and correct, and extended: the restart + requirement now applies to both forms and says why, and the hub gate on the hub's own clients is + named there. The `hostname` row no longer claims a non-loopback bind *requires* + `OPENCODEX_API_AUTH_TOKEN` — it states the real resolution order. `## Remote access` no longer says + "the proxy refuses a remote bind without this variable", which stopped being true in PR4. New + `hub.dataPublicOrigin` row, including that it is advisory, that it is **not** `.catch`ed (a typo is + rejected at write time, because falling back to the bind address is what the field exists to + avoid), and a paragraph on how the two public origins relate, why `--management-url` can only + confirm and `--data-url` can override, and that the companion form is refused on a loopback or + wildcard bind. +- **ko.** The ko page had no loopback-listener section at all and no Remote Hub key table. Both were + written: a new `### 토큰을 받을 수 없는 로컬 클라이언트` documenting both forms, the refusal, the + restart requirement, the admitted wire list and the hub gate; and a four-row key table including + `hub.dataPublicOrigin` with the same relationship paragraph. `hostname` and `## Remote access` got + the same token-precedence correction as en. + +### 4. `skills/ocx` + +- **`SKILL.md`** — "Remote hub: two things agents get wrong" became three. The new first item is the + one-port topology, `ocx hub invite` (with `--management-url` as a confirmation), "read the `Hub:` + block in `ocx status` before asking the operator anything", and the hub gate. The reference pointer + now also routes to `04_failure_semantics.md` for the service semantics. +- **`references/05_remote_hub.md`** — new `## One port, and what runs on it` (both listener forms, + the refusal, the admitted wires, an explicit "do not propose widening the listener to `/api/*`"), + `### The hub gate on the hub's own clients`, `### The hub's data token is not yours to produce` + (never tell an operator to export one; never regenerate; never copy the file), `### ocx status + answers most hub questions`, and `## Inviting a machine (ocx hub invite)` with the refusal text and + the `corsAllowOrigins` fix. `ocx hub invite` joined the command table. +- **`references/04_failure_semantics.md`** — new `## Service and launchd semantics (macOS)` (the + repair no-op is success; `restart` does not bounce a healthy job; the four-verdict table with + "repair?" per row) and `## A hub-gated skip is not a failure` (exit 0 with nothing written, nothing + to retry). +- **`references/03_recipes.md`** — new recipe 10, "Invite one more machine onto a hub": read the + `Hub:` block, `ocx hub invite --json`, hand over `command`, and the two normal refusals that burn + no code. +- **`references/01_management_surface.md`** was **not** hand-edited. `bun run skill:surface:check` + reported it current before and after (PR4 had already regenerated it for `ocx hub invite`), so + `bun run skill:surface` was not needed. + +### 5. Help copy (`src/cli/registry.ts`, `src/cli/help.ts`) + +Read as a first-time hub operator. PR4's `ocx help hub` and `ocx help service` text is accurate, so +only three things were touched — each of them something that was wrong or missing: + +- **`service` details gained the macOS restart and status semantics.** The only line about + `restart` was Windows-specific, and after PR1 the statement an operator would infer — "restart + restarts it" — is false on macOS. See the finding below. +- **`hub` details now say `--management-url` is a confirmation, not an override**, and explain + `--clients`. The usage line lists the flag, so an operator reading only the help would have + assumed it overrides and then hit a refusal. PR4's devlog records the decision; the help did not. +- **The top-level banner's `ocx status` line** now mentions the hub block, because nothing in + `ocx --help` pointed at the one command that answers "what is this hub doing". + +Nothing else in the banner was reworded. `tests/cli/cli-help.test.ts` and +`tests/cli/cli-registry.test.ts` are green. + +### 6. `tests/ci-workflows/docs-remote-hub-claims.test.ts` extended + +New `describe("the one-port hub recipe")`, five tests, run over **both** locales where the claim is +locale-independent: + +- both locales carry `ocx config set unauthenticatedLoopbackListener '{"enabled":true}'` **and** + `{"enabled":true,"port":10104}` (the recipe and the documented alternative); +- neither locale carries a line telling the reader to export the token. The assertion is + **line-anchored** (`/^\s*export\s+OPENCODEX_API_AUTH_TOKEN/m`) rather than a substring: the prose + has to be free to name the variable, because the page must say the step is gone and that an admin + token is refused there. A substring ban would have forced the page to be vaguer than the truth — + and the first draft of this test failed for exactly that reason, which is how the anchor got + chosen; +- both locales route a joining machine through `ocx hub invite`, name the `corsAllowOrigins` + precondition with its exact command, and show `--pairing-code-stdin`; +- the en page keeps `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy` and the + `launchd state could not be verified` state; +- the en page says `Do not point Serve at the loopback companion listener`. + +No new test file, so no `scripts/test-layout/layout.json` registration was needed. + +## Findings while writing this + +Two corrections that the code supports and the plan's own wording did not. + +**`ocx service restart` does not restart a healthy macOS job.** The assignment (and the design doc) +said to document "restart with `ocx service restart` or `launchctl kickstart -k …`". On darwin, +`serviceCommand` maps `restart` → `repair` (`src/service.ts:4862`), `repairService` calls +`installLaunchd` (`:3587`), and after PR1 `installLaunchd` returns early — printing +`service is already loaded from the current plist; nothing to do.` — whenever the rendered plist +equals the file, the token file is unchanged, and `launchctl print` agrees (`:2637`-`2649`). There is +no stop/start anywhere in that path. So on a healthy hub `ocx service restart` is a no-op, and after +changing `unauthenticatedLoopbackListener` the operator needs +`launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy` (or `ocx service stop` then +`ocx service start`). The guide, the skill and `ocx help service` all say this instead of the +easier-to-write claim. PR1's no-op is right; the copy just has to stop implying otherwise. + +**The companion listener must not be a Tailscale Serve target.** It is a real socket on +`127.0.0.1:`, so `tailscale serve --https=… http://127.0.0.1:10100` would be accepted — +and then fail. The companion substitutes a `RequestPolicyView` with `hostname: 127.0.0.1` +(`src/server/index.ts:790`-`800`), which makes `isApiAuthRequired` false, and in that branch +`isAllowedRequestOrigin` requires a **loopback `Host` header** +(`src/server/auth-cors.ts:90`-`94`). Serve forwards `Host: hub-name.tailnet-name.ts.net`, so the +data routes answer `403 origin_rejected` — the exact trap #4241 documented for a plain loopback +bind, reachable a second way. The guide now states this beside the forwarder, and the forwarder's +example port moved off `10100` so the two sockets cannot be confused. This is a documentation fix, +not a code change: the companion is for processes *on* the hub, which send their own loopback +`Host`. + +## Decisions + +- **Keep #4241's structure and every guarded claim.** The loopback-bind trap, the forwarder section + and the Serve constraints are still true and still the thing operators get wrong; the one-port + recipe is additive. The guard test was extended, never relaxed. +- **en + ko only**, recorded above with the exact list of what the other five locales still claim. +- **The `hostname` row and `## Remote access` in `server.md` had to change**, even though they are + outside this stack's nominal surface: after PR4 they state a refusal that no longer happens. A + reference page that tells you the proxy will refuse to start is not a cosmetic inaccuracy. +- **`--management-url` is documented as a confirmation in all four places** (guide, reference, + skill, `ocx help hub`). PR4 made the refusal deliberate; the only way an operator learns it + without hitting it is if every surface says so. +- **`count_tokens` is documented as not admitted**, in the guide and the skill, rather than being + left silent. PR3 pinned the 404 with a test so widening it is a deliberate act; documenting the + current answer is the other half of that. +- **The one-port recipe sets `hub.dataPublicOrigin`** in the setup block rather than only mentioning + it. Without it, `ocx hub invite` falls back to `http://:`, which is exactly the + address a machine behind the TLS frontend cannot use — the guide would be handing out a broken + command. +- **The Docker section was not converted to the companion form.** The container binds `0.0.0.0`, + where the companion form is refused and unnecessary; the section now says so in one sentence + instead of growing a second recipe. + +## Verification (exact commands, this branch) + +``` +bun test tests/ci-workflows/docs-remote-hub-claims.test.ts \ + tests/ci-workflows/skill-ocx.test.ts \ + tests/cli/cli-help.test.ts tests/cli/cli-registry.test.ts # 57 pass, 0 fail, 515 expect() +bun test tests/ci-workflows/docs-remote-hub-claims.test.ts # 12 pass (was 7) +bun test tests/ci-workflows/skill-ocx.test.ts # 16 pass +bun test tests/cli/cli-help.test.ts tests/cli/cli-registry.test.ts # 29 pass +bun test tests/cli/cli-capabilities.test.ts # 17 pass +bun test tests/ci-workflows/docs-429-failover-claims.test.ts \ + tests/ci-workflows/docs-provider-billing-claims.test.ts \ + tests/ci-workflows/docs-readme-translation-parity.test.ts \ + tests/ci-workflows/docs-bun-source-requirement.test.ts # 64 pass (the other docs-claims gates) +bun run typecheck # clean +bun run privacy:scan # Privacy scan passed +bun run skill:surface:check # 01_management_surface.md is current +cd docs-site && bun install --frozen-lockfile && bun run build # 425 pages built, Complete! +``` + +`docs-site/AGENTS.md` requires that build for any `docs-site/` change, and it passed. On top of it, +every in-page anchor was checked against the generated HTML rather than by eye: all six new English +ids exist in `dist/guides/remote-hub/index.html`, and a script compared every `href="#…"` against +every `id="…"` in `dist/ko/guides/remote-hub/index.html` and both `server/index.html` pages — +zero unresolved, including the percent-encoded Korean anchors +(`#한-포트-그리고-포트를-지정하는-대안`, `#토큰을-받을-수-없는-로컬-클라이언트`). + +Read-only CLI checks on this machine (a live hub): `bun run src/cli/index.ts help hub` and +`… help service` were run to read the rendered copy. No `ocx service …`, `ocx start/stop/ensure/ +sync/restore` and no `launchctl` command was run, per the operator's instruction. + +No repository-wide suite (operator instruction); hosted CI at the exact pushed head is the proof. + +## Left over + +- **Five translations** (`fr`, `ja`, `ru`, `tr`, `zh-cn`, `zh-tw`) of `guides/remote-hub.md` and + `reference/configuration/server.md`, which still carry both #4241's defects and the pre-#4236 + recipe. One follow-up. +- `POST /v1/messages/count_tokens` on the loopback listener is documented as a 404 in two places. If + PR3's open question is answered yes, both sentences move together. +- Cursor's `apiKeyMode` copy (PR3's note) is GUI text and is not documented here either way. +- `ocx hub` has one subcommand. If hub-side key listing/revocation ever lands, the guide's + "revoke from **Integrations → API Keys**" sentences are the ones to revisit. From 1db81369b9b095bd78b73189a5d0bd1628c5ec4e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 11 Sep 2026 12:16:13 +0900 Subject: [PATCH 5/7] docs(remote-hub): reconcile with PR4's review round across both locales and the skill The base branch grew a four-commit review round while this unit was being written, and four of its findings made sentences here false. Reading src/ at the new base rather than trusting the first draft is what caught them. `ocx hub invite` now REFUSES a loopback- or wildcard-derived data origin instead of advertising `http://localhost:`, which would tell the other machine to dial itself and spend the single-use code. So the resolution order is `--data-url`, then hub.dataPublicOrigin, then the bind address -- and that last step only works when the bind is an address another machine can dial. "...or http://: as a last resort" would have walked an operator on a loopback-bound hub into the refusal with no idea why. An explicit override is never second-guessed, because a loopback data origin is legitimate over an SSH tunnel. The reused `service-api-token` file is re-checked for the admin token now, with a different remedy: delete the file and run `ocx service repair`, because unsetting a variable says nothing about a file. Both collision checks run ahead of the loopback short-circuit, since the wrapper reads that file into the variable whatever the hostname. Every page that said the existing file is reused now says it is re-checked, not trusted. The `ocx status` token states changed. `present (env)` is gone -- the state is always about the file (`present (file)`, `unsafe (file)`, `admin-collision (file)`, `missing`) and the shell's variable is a separate sub-line. Listing `present (env)` as a state was exactly the honesty defect the review round fixed in the code. Every successful invite prints a `Bound browser origin:` line on stderr, and it is NOT in the --json envelope. A grant is bound to one origin while a remote `ocx connect` presents `Origin: http://localhost:`, so a non-default bound origin means the other machine must already be on that port or the exchange is refused and the code is spent. The skill's recipe now says to relay that line, which an agent reading only --json would drop. One smaller correction rode along: the `corsAllowOrigins` fix command preserves existing entries, so all three places that quote it say to run the line `invite` prints rather than a hand-written one-element array. Co-Authored-By: Claude Fable 5.1 --- .../260911_hub_single_port/050_docs_skill.md | 61 +++++++++++++-- .../src/content/docs/guides/remote-hub.md | 74 ++++++++++++++----- .../src/content/docs/ko/guides/remote-hub.md | 21 ++++-- .../docs/ko/reference/configuration/server.md | 6 +- .../docs/reference/configuration/server.md | 17 +++-- skills/ocx/references/03_recipes.md | 30 +++++--- skills/ocx/references/05_remote_hub.md | 47 +++++++++--- 7 files changed, 195 insertions(+), 61 deletions(-) diff --git a/devlog/_plan/260911_hub_single_port/050_docs_skill.md b/devlog/_plan/260911_hub_single_port/050_docs_skill.md index 440bf35e72..51ca677a5e 100644 --- a/devlog/_plan/260911_hub_single_port/050_docs_skill.md +++ b/devlog/_plan/260911_hub_single_port/050_docs_skill.md @@ -1,12 +1,23 @@ # 050 — PR5: docs (en + ko), the `ocx` skill, and the help copy Unit: `devlog/_plan/260911_hub_single_port`. Stack position 5 of 5. Branch -`codex/260911-l7-hub-docs-skill`, based on `codex/260911-l4-hub-token-ux` = `fb1898e19` -(`test(service): drop the installLaunchd import the restack left unused`), which carries PR1 -(launchd repair), PR2 (loopback companion), PR3 (hub local clients) and PR4 (hub token UX) in its -ancestry — verified with `git log --oneline -12` before the first edit. Issue: -lidge-jun/opencodex#4236. The four devlogs `010`–`040` in this directory are the source of truth -for what the code does; nothing here was copied from the plan without checking it against `src/`. +`codex/260911-l7-hub-docs-skill`, on `codex/260911-l4-hub-token-ux` = `b26eee311` +(`docs(devlog): record the PR4 review round`), which carries PR1 (launchd repair), PR2 (loopback +companion), PR3 (hub local clients) and PR4 (hub token UX). Issue: lidge-jun/opencodex#4236. The +four devlogs `010`–`040` in this directory are the source of truth for what the code does; nothing +here was copied from the plan without checking it against `src/`. + +**Restacked mid-work, and it changed the copy.** The branch was cut from `fb1898e19`, the base tip +at the time. While this unit was being written the base was rebased onto PR1 and then grew a +four-commit review round (`1211759bf`, `0cfcfd284`, `75d4c5a5b`, `b26eee311`), so `fb1898e19` left +its history entirely. The rebase onto `b26eee311` had one conflict, in `src/cli/registry.ts`'s `hub` +details — both sides had edited the `--data-url` paragraph — resolved by keeping the base's new +refusal text and appending this unit's `--management-url` / `--clients` lines. + +The review round was not only a text change, and four of its findings made sentences in this unit's +first draft false. All four were reconciled across all six pages (see +"Reconciled with PR4's review round" below). Every count in the Verification section is from the +rebased, reconciled tree. No runtime behaviour changes. The only `src/` edits are help/registry copy. @@ -153,6 +164,40 @@ locale-independent: No new test file, so no `scripts/test-layout/layout.json` registration was needed. +## Reconciled with PR4's review round + +The base's review round changed four behaviours this unit had already documented. Reading `src/` at +the new base rather than trusting the first draft is what caught them. + +1. **`ocx hub invite` now refuses a loopback- or wildcard-derived data origin** instead of + advertising `http://localhost:` (which would tell the other machine to dial itself and + spend the single-use code). The resolution order is `--data-url` → `hub.dataPublicOrigin` → the + bind address, and the last step only works when the bind is an address another machine can dial. + An explicit override is never second-guessed, because a loopback data origin is legitimate over + an SSH tunnel. Documented in the invite section, the reference table, the skill, and as a + troubleshooting row in both locales. The first draft's "…or `http://:` as a last + resort" would have sent an operator on a loopback-bound hub straight into the refusal with no + idea why. +2. **The reused `service-api-token` file is re-checked for the admin token**, with a *different* + remedy: delete the file and run `ocx service repair`, because `unset OPENCODEX_API_AUTH_TOKEN` + says nothing about a file. Both collision checks now run ahead of the loopback short-circuit, + since the launch wrapper reads that file into the variable whatever the hostname. Every page that + said "the existing file is reused" now says it is re-checked, not trusted. +3. **The `ocx status` token states changed.** `present (env)` no longer exists — the state is always + about the file (`present (file)`, `unsafe (file)`, `admin-collision (file)`, `missing`), and the + shell's variable is a separate sub-line. The first draft listed `present (env)` as a state, which + is exactly the honesty defect the review round fixed in the code. Both guides, the reference + pages and the skill now list the four real states and explain why the file wins. +4. **Every successful invite prints a `Bound browser origin:` line on stderr**, and it is not in the + `--json` envelope. It matters because a grant is bound to one origin while a remote `ocx connect` + presents `Origin: http://localhost:`, so a non-default bound origin means the other + machine must already be on that port. The skill's recipe now says to relay that line, which an + agent reading only `--json` would otherwise drop. + +One smaller correction rode along: the `corsAllowOrigins` fix command now preserves the hub's +existing entries, so all three places that quote it say to run the line `invite` prints rather than +a hand-written one-element array. + ## Findings while writing this Two corrections that the code supports and the plan's own wording did not. @@ -224,7 +269,9 @@ bun run skill:surface:check # 01_management_s cd docs-site && bun install --frozen-lockfile && bun run build # 425 pages built, Complete! ``` -`docs-site/AGENTS.md` requires that build for any `docs-site/` change, and it passed. On top of it, +Every command above was re-run after the restack and the four reconciliations; the counts are from +the final tree. `docs-site/AGENTS.md` requires that build for any `docs-site/` change, and it +passed (twice: once before the restack, once after). On top of it, every in-page anchor was checked against the generated HTML rather than by eye: all six new English ids exist in `dist/guides/remote-hub/index.html`, and a script compared every `href="#…"` against every `id="…"` in `dist/ko/guides/remote-hub/index.html` and both `server/index.html` pages — diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 705e2f5964..79e35a267c 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -136,7 +136,8 @@ the owner-only `service-api-token` file, mode `0600`: own that value keeps owning it. 2. **The existing `service-api-token` file.** Reusing it is what makes `ocx service install`, `ocx service repair` and a restart idempotent; regenerating would silently invalidate every - per-client key already exchanged against the old value. + per-client key already exchanged against the old value. A reused file is re-checked, not + trusted — see the admin-token paragraph below. 3. **32 fresh random bytes, hex.** This is the branch that removes the manual step. The command prints the **path**, never the value. The launchd plist and the systemd user unit read @@ -145,16 +146,25 @@ value into `ocx config show`, unit/plist output, screenshots, or support bundles `ocx start` on the hub reads the same file, so it binds the non-loopback hostname without an exported token either. -A **management admin token** in `OPENCODEX_API_AUTH_TOKEN` is refused, and the refusal names the -fix: `unset OPENCODEX_API_AUTH_TOKEN` and rerun. They are different credentials — the data token -admits `/v1/*` callers and administers nothing — and exporting the admin token as the data token -fails the hub's own admission check at every start. Since the service provisions its own token, -there is no reason to export either one. `ocx service repair` never demands the variable again once -the file exists. - -`ocx status` reports the token's source without its value: `present (env)`, `present (file)`, -`unsafe (file)` (the file exists but is not owner-only — fix the permissions; install refuses it), -or `missing`. +A **management admin token** is refused wherever it turns up, and the refusal names the remedy for +that place. In `OPENCODEX_API_AUTH_TOKEN`: `unset OPENCODEX_API_AUTH_TOKEN` and rerun. In the +reused `service-api-token` file — the shape of the original incident, and still reachable on a +machine where the admin token was once pasted there by hand — delete the file and run +`ocx service repair`, because unsetting a variable says nothing about a file. Both checks run +ahead of the loopback short-circuit, so a loopback install is checked too: the launch wrapper +reads that file into `OPENCODEX_API_AUTH_TOKEN` whatever the hostname, which is what fences the +management API closed at boot. + +The two planes are different credentials — the data token admits `/v1/*` callers and administers +nothing. Since the service provisions its own token, there is no reason to export either one. +`ocx service repair` never demands the variable again once the file exists. + +`ocx status` reports the token's state without its value: `present (file)`, `unsafe (file)` (it +exists but is not owner-only — fix the permissions), `admin-collision (file)` (the incident shape; +the block adds the consequence and the fix), or `missing`. The state is always about the **file**, +because the launch wrapper overwrites the environment from it before exec — a separate sub-line +reports `OPENCODEX_API_AUTH_TOKEN` being set in your shell, since that is what a foreground +`ocx start` in that shell would use. ### One port, and the ported alternative @@ -361,15 +371,29 @@ It mints a single-use, short-lived pairing code and prints the command to run on echo '' | ocx connect https://hub-name.tailnet-name.ts.net:8443 --management-url https://hub-name.tailnet-name.ts.net --pairing-code-stdin ``` -The data origin comes from `hub.dataPublicOrigin`, or `--data-url`, or `http://:` as a -last resort. The management origin is `hub.managementPublicOrigin`, and on `invite` the -`--management-url` flag is a **confirmation, not an override**: the grant is bound to the configured -origin and the exchange compares against it, so a value that differs is refused with both origins -named rather than printing a code the hub would then reject. +The data origin comes from `--data-url`, then `hub.dataPublicOrigin`, then the bind address. That +last fallback only works when the bind **is** an address another machine can dial: on a loopback or +wildcard bind it would resolve to `http://localhost:`, which tells the other machine to dial +itself and spends the single-use code for nothing, so `invite` refuses instead and prints the +`ocx config set hub.dataPublicOrigin` line (plus the per-invite `--data-url` form). An explicit +`--data-url` or `hub.dataPublicOrigin` is never second-guessed — a loopback data origin is +legitimate over an SSH tunnel. + +The management origin is `hub.managementPublicOrigin`, and on `invite` the `--management-url` flag +is a **confirmation, not an override**: the grant is bound to the configured origin and the +exchange compares against it, so a value that differs is refused with both origins named rather +than printing a code the hub would then reject. + +Every successful invite also prints the **bound browser origin** on stderr. A grant is bound to one +origin, and a remote `ocx connect` presents `Origin: http://localhost:`, +so if the bound origin is not the default `http://localhost:10100` the other machine has to already +be running on that port before it runs the line — otherwise the hub refuses the exchange and the +code is spent. The note says which port, and offers admitting the default origin instead. `invite` refuses *before* minting anything when the setup cannot work — a `runtimeRole` that is not `hub`, a missing `hub.managementPublicOrigin`, a plaintext non-loopback management origin, a -malformed `--data-url`, or no running attested proxy. One precondition deserves its own paragraph. +malformed `--data-url`, a data origin that would be this machine's own loopback, or no running +attested proxy. One precondition deserves its own paragraph. **`corsAllowOrigins` has to name the joining machine's local browser origin.** `ocx connect` sends `Origin: http://localhost:` when it exchanges the grant, and grants are @@ -382,7 +406,9 @@ ocx config set corsAllowOrigins '["http://localhost:10100"]' ``` Use the port the **joining** machine's proxy listens on; `10100` is the default. The setup block -above already sets it. +above already sets it. A whole-array set replaces the array, so when the hub already has entries +run the line `invite` prints — it carries the existing ones plus the new origin. `ocx config get +corsAllowOrigins` shows what is there now. `ocx hub invite --json` emits `{ code, expiresAt, dataUrl, managementUrl, command }` with `expiresAt` as ISO 8601. The code is a secret: single-use, five-minute lifetime, rate-limited at the hub, and not @@ -627,6 +653,14 @@ For a service rollback, stop the branch service and repair the prior release aga loopback browser origin, so an origin-bound grant could never match. Nothing was minted. Run the `ocx config set corsAllowOrigins` line the error prints, with the joining machine's proxy port. See [Inviting another machine](#inviting-another-machine). +- **`ocx hub invite` says the advertised data origin would be this machine's own loopback:** the + bind is loopback-only or a wildcard and `hub.dataPublicOrigin` is unset, so there is no address + to advertise and nothing guesses a tailnet or LAN one. Nothing was minted. Set + `hub.dataPublicOrigin`, or pass `--data-url` for this invite only. +- **The joining machine's exchange is refused and the code is spent:** the grant was bound to an + origin that machine does not present. Re-read the `Bound browser origin:` line from the invite — + it names the port the other machine must be running on, or offers admitting + `http://localhost:10100` on the hub instead. - **`ocx hub invite` refuses a `--management-url`:** on a hub that flag confirms `hub.managementPublicOrigin` rather than overriding it, because the grant is bound to the configured value. Change the config, or drop the flag. @@ -642,6 +676,10 @@ For a service rollback, stop the branch service and repair the prior release aga - **`ocx service install` refuses `OPENCODEX_API_AUTH_TOKEN`:** that value is a management admin token. `unset OPENCODEX_API_AUTH_TOKEN` and rerun; the service provisions its own data-plane token. See [The data-plane token provisions itself](#the-data-plane-token-provisions-itself). +- **The hub crash-loops at boot and `ocx status` shows `admin-collision (file)`:** the + `service-api-token` file holds the management token, so the hub fences its management API closed. + Delete the file and run `ocx service repair` to provision a data-plane token. Unsetting the + environment variable does not help here — the file is the source. - **Plain HTTP refused:** pairing over non-loopback HTTP is refused outright, and there is no flag that opts out of it. Put the management origin behind HTTPS, or pair over loopback. Admin tokens are never sent over HTTP. diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index 630915c562..4c9a6a30a6 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -81,14 +81,16 @@ ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}' `ocx service install` 앞에 `export OPENCODEX_API_AUTH_TOKEN=…` 단계는 없습니다. 루프백이 아닌 바인드에서 설치 과정이 다음 우선순위로 데이터 admission 토큰을 결정하고, 결과를 owner-only `service-api-token` 파일(모드 `0600`)에 기록합니다. 1. **`OPENCODEX_API_AUTH_TOKEN`** — 설치하는 셸이 내보낸 값이 있을 때. 값을 직접 관리하고 싶은 운영자는 계속 직접 관리할 수 있습니다. -2. **기존 `service-api-token` 파일.** 이 재사용이 `ocx service install`, `ocx service repair`, 재시작을 멱등하게 만듭니다. 새로 만들면 이미 교환된 클라이언트 키가 모두 조용히 무효가 됩니다. +2. **기존 `service-api-token` 파일.** 이 재사용이 `ocx service install`, `ocx service repair`, 재시작을 멱등하게 만듭니다. 새로 만들면 이미 교환된 클라이언트 키가 모두 조용히 무효가 됩니다. 재사용하는 파일도 그냥 믿지 않고 다시 검사합니다. 아래 관리자 토큰 단락을 보세요. 3. **무작위 32바이트(hex) 새 값.** 손으로 하던 단계를 없애는 분기입니다. 명령은 **경로**만 출력하고 값은 절대 출력하지 않습니다. launchd plist와 systemd user unit은 프로세스가 시작할 때 그 보호된 파일을 읽으며, 토큰 문자열이 정의 파일에 들어가지 않습니다. 값을 `ocx config show`, unit/plist 출력, 스크린샷, 지원 번들에 붙여 넣지 마세요. 포그라운드 `ocx start`도 같은 파일을 읽으므로, 토큰을 내보내지 않아도 루프백이 아닌 hostname에 바인드합니다. -`OPENCODEX_API_AUTH_TOKEN`에 **관리자 토큰**이 들어 있으면 거부하며, 해결책을 함께 알려 줍니다: `unset OPENCODEX_API_AUTH_TOKEN` 후 다시 실행하세요. 둘은 다른 자격 증명입니다. 데이터 토큰은 `/v1/*` 호출자를 허용할 뿐 관리 권한이 없고, 관리자 토큰을 데이터 토큰으로 내보내면 시작할 때마다 허브 자신의 admission 검사에서 실패합니다. 서비스가 토큰을 직접 준비하므로 어느 쪽도 내보낼 이유가 없습니다. 파일이 한 번 만들어진 뒤에는 `ocx service repair`가 환경 변수를 다시 요구하지 않습니다. +**관리자 토큰**은 어디에서 발견되든 거부하며, 그 자리에 맞는 해결책을 알려 줍니다. `OPENCODEX_API_AUTH_TOKEN`에 있으면 `unset OPENCODEX_API_AUTH_TOKEN` 후 다시 실행하세요. 재사용하는 `service-api-token` 파일에 있으면(원래 사고의 형태이고, 과거에 관리자 토큰을 그 파일에 손으로 붙여 넣은 컴퓨터에서는 여전히 나타날 수 있습니다) 파일을 삭제하고 `ocx service repair`를 실행하세요. 변수를 unset하는 것은 파일에 대해 아무 의미가 없습니다. 두 검사는 루프백 단축 경로보다 앞에서 실행되므로 루프백 설치도 검사합니다. 실행 래퍼는 hostname과 무관하게 그 파일을 `OPENCODEX_API_AUTH_TOKEN`으로 읽어 들이고, 그것이 부팅 시 관리 API를 닫아 버리는 원인입니다. -`ocx status`는 값 없이 출처만 보고합니다: `present (env)`, `present (file)`, `unsafe (file)`(파일은 있지만 권한이 owner-only가 아님 — 권한을 고치세요. 설치는 이 상태를 거부합니다), `missing`. +두 평면은 서로 다른 자격 증명입니다. 데이터 토큰은 `/v1/*` 호출자를 허용할 뿐 관리 권한이 없습니다. 서비스가 토큰을 직접 준비하므로 어느 쪽도 내보낼 이유가 없습니다. 파일이 한 번 만들어진 뒤에는 `ocx service repair`가 환경 변수를 다시 요구하지 않습니다. + +`ocx status`는 값 없이 상태만 보고합니다: `present (file)`, `unsafe (file)`(파일은 있지만 owner-only가 아님 — 권한을 고치세요), `admin-collision (file)`(사고의 형태이며, 블록이 결과와 해결책을 함께 출력합니다), `missing`. 상태는 항상 **파일**에 관한 것입니다. 실행 래퍼가 exec 전에 파일로 환경 변수를 덮어쓰기 때문입니다. 현재 셸에 `OPENCODEX_API_AUTH_TOKEN`이 설정되어 있는지는 별도 하위 줄로 보고합니다. 그 값은 같은 셸에서 포그라운드 `ocx start`가 쓰게 되는 값이기 때문입니다. ### 한 포트, 그리고 포트를 지정하는 대안 @@ -199,9 +201,13 @@ ocx hub invite echo '' | ocx connect https://hub-name.tailnet-name.ts.net:8443 --management-url https://hub-name.tailnet-name.ts.net --pairing-code-stdin ``` -데이터 Origin은 `hub.dataPublicOrigin`, 또는 `--data-url`, 마지막 수단으로 `http://:`에서 옵니다. 관리 Origin은 `hub.managementPublicOrigin`이며, `invite`에서 `--management-url`은 **덮어쓰기가 아니라 확인**입니다. grant는 설정된 Origin에 묶이고 교환 시 그 값과 비교하므로, 다른 값을 주면 허브가 거부할 코드를 출력하는 대신 두 Origin을 모두 지목하며 거부합니다. +데이터 Origin은 `--data-url` → `hub.dataPublicOrigin` → 바인드 주소 순서로 결정됩니다. 마지막 대체는 바인드가 다른 컴퓨터가 실제로 호출할 수 있는 주소일 때만 쓸 수 있습니다. 루프백이나 와일드카드 바인드에서는 `http://localhost:`가 되어 상대 컴퓨터가 자기 자신을 호출하게 되고 일회용 코드가 헛되게 소모되므로, `invite`는 대신 거부하고 `ocx config set hub.dataPublicOrigin` 줄(그리고 이번 초대에만 적용하는 `--data-url` 형태)을 출력합니다. 명시한 `--data-url`이나 `hub.dataPublicOrigin`은 되묻지 않습니다. SSH 터널에서는 루프백 데이터 Origin이 정당합니다. + +관리 Origin은 `hub.managementPublicOrigin`이며, `invite`에서 `--management-url`은 **덮어쓰기가 아니라 확인**입니다. grant는 설정된 Origin에 묶이고 교환 시 그 값과 비교하므로, 다른 값을 주면 허브가 거부할 코드를 출력하는 대신 두 Origin을 모두 지목하며 거부합니다. + +초대가 성공할 때마다 **묶인 브라우저 Origin**도 stderr에 출력합니다. grant는 Origin 하나에 묶이고 원격 `ocx connect`는 `Origin: http://localhost:<자기 설정 포트>`를 보내므로, 묶인 Origin이 기본값 `http://localhost:10100`이 아니면 상대 컴퓨터가 그 포트에서 이미 실행 중이어야 합니다. 그렇지 않으면 허브가 교환을 거부하고 코드가 소모됩니다. 출력되는 안내가 어떤 포트인지 알려 주고, 대신 기본 Origin을 허용하는 방법도 제시합니다. -동작할 수 없는 상태라면 `invite`는 코드를 만들기 **전에** 거부합니다: `runtimeRole`이 `hub`가 아님, `hub.managementPublicOrigin` 없음, 루프백이 아닌 평문 관리 Origin, 잘못된 `--data-url`, attested 프록시가 실행 중이 아님. 전제 조건 하나는 따로 적을 만합니다. +동작할 수 없는 상태라면 `invite`는 코드를 만들기 **전에** 거부합니다: `runtimeRole`이 `hub`가 아님, `hub.managementPublicOrigin` 없음, 루프백이 아닌 평문 관리 Origin, 잘못된 `--data-url`, 데이터 Origin이 이 컴퓨터의 루프백이 될 상황, attested 프록시가 실행 중이 아님. 전제 조건 하나는 따로 적을 만합니다. **`corsAllowOrigins`에 참가할 컴퓨터의 로컬 브라우저 Origin이 있어야 합니다.** `ocx connect`는 grant를 교환할 때 `Origin: http://localhost:<자기 프록시 포트>`를 보내고 grant는 Origin에 묶이므로, `hub.managementPublicOrigin` 자신이나 `corsAllowOrigins`의 루프백 항목만 일치할 수 있습니다. 둘 다 없으면 `invite`는 0이 아닌 코드로 끝나고 아무것도 발급하지 않으며 정확한 명령을 알려 줍니다. @@ -209,7 +215,7 @@ echo '' | ocx connect https://hub-name.tailnet-name.ts.net:8443 --manageme ocx config set corsAllowOrigins '["http://localhost:10100"]' ``` -**참가하는** 컴퓨터의 프록시 포트를 쓰세요. 기본값은 `10100`입니다. 위의 설치 블록에 이미 들어 있습니다. +**참가하는** 컴퓨터의 프록시 포트를 쓰세요. 기본값은 `10100`입니다. 위의 설치 블록에 이미 들어 있습니다. 배열 전체를 설정하면 기존 배열을 교체하므로, 허브에 이미 항목이 있다면 `invite`가 출력하는 줄을 그대로 실행하세요. 그 줄에는 기존 항목과 새 Origin이 함께 들어 있습니다. 현재 값은 `ocx config get corsAllowOrigins`로 확인합니다. `ocx hub invite --json`은 `{ code, expiresAt, dataUrl, managementUrl, command }`를 출력하며 `expiresAt`은 ISO 8601입니다. 코드는 비밀입니다. 일회용이고 수명은 5분이며 허브에서 요청 수를 제한하고, 저장하거나 로그·이슈에 붙여 넣으면 안 됩니다. `--clients codex,claude`로 출력된 명령이 어떤 클라이언트 설정을 허브로 향하게 할지 고릅니다. @@ -311,11 +317,14 @@ docker compose up -d - `hub-too-new` 또는 `hub-too-old`가 나오면 메시지가 가리키는 오래된 쪽을 업그레이드하세요. 불일치는 로컬 파일을 쓰기 전에 차단됩니다. - pairing 코드를 잃었거나 소진했다면 `ocx hub invite`를 다시 실행하세요. grant는 일회용이고 반복 실패는 코드 존재 여부를 드러내지 않는 방식으로 제한됩니다. - `ocx hub invite`가 `No loopback browser origin is admitted for pairing`이라고 하면 허브가 허용하는 루프백 브라우저 Origin이 없다는 뜻이며 아무것도 발급되지 않았습니다. 오류가 출력한 `ocx config set corsAllowOrigins` 줄을 참가할 컴퓨터의 프록시 포트로 실행하세요. +- `ocx hub invite`가 광고할 데이터 Origin이 이 컴퓨터의 루프백이 된다고 하면, 바인드가 루프백 전용이거나 와일드카드이고 `hub.dataPublicOrigin`이 설정되지 않은 상태입니다. 광고할 주소가 없고 tailnet/LAN 주소를 추측하지도 않습니다. 아무것도 발급되지 않았습니다. `hub.dataPublicOrigin`을 설정하거나 이번 초대에만 `--data-url`을 주세요. +- 참가하는 컴퓨터의 교환이 거부되고 코드가 소모되면, grant가 그 컴퓨터가 보내지 않는 Origin에 묶였던 것입니다. 초대 출력의 `Bound browser origin:` 줄을 다시 보세요. 상대 컴퓨터가 실행해야 하는 포트를 알려 주거나, 허브에서 `http://localhost:10100`을 허용하는 방법을 제시합니다. - `ocx hub invite`가 `--management-url`을 거부하면, 허브에서 그 플래그는 `hub.managementPublicOrigin`을 덮어쓰는 것이 아니라 확인하는 것입니다. 설정을 바꾸거나 플래그를 빼세요. - 허브에서 `ocx claude`가 native로 실행되거나 허브가 자기 클라이언트 설정을 쓰지 않으면 `unauthenticatedLoopbackListener`가 꺼져 있습니다. 건너뛴 메시지가 게이트를 지목합니다. 리스너를 켜고 프록시를 재시작하세요. - 허브에서 `ocx claude`가 리스너로부터 `404`를 받으면, 리스너 경로가 생기기 전이나 포트가 바뀌기 전에 시작된 프로세스가 아직 돌고 있는 것입니다. [macOS 서비스 운영](#macos-서비스-운영)을 보고 재시작하세요. - macOS에서 `ocx service restart`가 `nothing to do`를 출력하고 프로세스가 바뀌지 않는 것은 정상입니다. `restart`는 `repair`의 별칭이고 정상 작업의 repair는 의도적으로 no-op입니다. `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy`를 쓰세요. - `ocx service install`이 `OPENCODEX_API_AUTH_TOKEN`을 거부하면 그 값은 관리자 토큰입니다. `unset OPENCODEX_API_AUTH_TOKEN` 후 다시 실행하세요. 서비스가 데이터 플레인 토큰을 직접 준비합니다. +- 허브가 부팅에서 계속 죽고 `ocx status`가 `admin-collision (file)`을 보이면, `service-api-token` 파일에 관리자 토큰이 들어 있어서 허브가 관리 API를 닫은 상태입니다. 파일을 삭제하고 `ocx service repair`를 실행해 데이터 플레인 토큰을 준비하세요. 이 경우 환경 변수를 unset해도 해결되지 않습니다. 원인은 파일입니다. - 루프백이 아닌 평문 HTTP로는 pairing을 할 수 없고, 이를 우회하는 플래그도 없습니다. 관리 Origin을 HTTPS 뒤에 두거나 루프백에서 pairing하세요. 관리자 토큰은 HTTP로 보내지 않습니다. - `/v1/catalog`가 `403 origin_rejected`인데 `/readyz`가 `200`이면 데이터 리스너가 TLS 프런트엔드 뒤에서 루프백에 바인드되어 있습니다. [데이터 리스너에 TLS 붙이기](#데이터-리스너에-tls-붙이기)를 보세요. - 브라우저 로그아웃/만료는 해당 원격 세션만 끊습니다. 데이터 키와는 별개입니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 7830d6a020..163685a645 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -38,7 +38,9 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, 토큰을 직접 만들 필요는 없습니다. 루프백이 아닌 바인드에서 `ocx service install`이 다음 순서로 토큰을 준비합니다: 설치하는 셸의 `OPENCODEX_API_AUTH_TOKEN` → 기존 owner-only `service-api-token` 파일 → 무작위 32바이트 새 값. 결과는 `0600`으로 기록되고 실행 래퍼(launchd plist, systemd unit, Windows 래퍼)가 시작할 때 그 파일을 읽으므로, 값이 서비스 정의나 argv에 들어가지 않습니다. 포그라운드 `ocx start`도 같은 우선순위(환경 변수 → `OCX_API_TOKEN_FILE` → 설치된 `service-api-token`)를 적용하므로 토큰을 내보내지 않아도 루프백이 아닌 hostname에 바인드합니다. -`OPENCODEX_API_AUTH_TOKEN`에 **관리자 토큰**이 들어 있으면 거부합니다. 두 평면은 서로 다른 자격 증명이며, 거부 메시지는 대체 값을 제안하는 대신 `unset OPENCODEX_API_AUTH_TOKEN` 후 다시 실행하라고 알려 줍니다. 값을 직접 관리하려는 운영자는 여전히 변수를 설정할 수 있습니다: +**관리자 토큰**은 나타날 수 있는 두 곳 모두에서 거부합니다. 환경 변수이거나 재사용하는 `service-api-token` 파일이며, 메시지는 그 자리에 맞는 해결책을 알려 줍니다. 변수를 unset하거나, 파일을 삭제하고 `ocx service repair`를 실행하세요. 두 검사는 루프백 단축 경로보다 앞에서 실행됩니다. 실행 래퍼가 hostname과 무관하게 파일을 `OPENCODEX_API_AUTH_TOKEN`으로 읽기 때문에, 관리자 토큰이 든 파일은 루프백 바인드에서도 관리 API를 닫아 버립니다. 허브에서는 `ocx status`가 이 상태를 `admin-collision (file)`로 보고합니다. + +값을 직접 관리하려는 운영자는 여전히 변수를 설정할 수 있습니다: ```bash export OPENCODEX_API_AUTH_TOKEN="your-secret-token" @@ -219,7 +221,7 @@ Anthropic OAuth 사이드카는 opencodex의 기존 Claude Code OAuth fingerprin | `hub.managementIngress` | `{enabled:false}` 또는 `{enabled:true, port}` | `{enabled:false}` | 로컬 HTTPS 프런트엔드용 관리 전용 리스너입니다. hostname은 설정할 수 없고, 켜면 항상 `127.0.0.1`에 바인드하며 GUI·세션 부트스트랩·관리 API 경로만 허용합니다. 데이터 플레인 경로는 dispatch 전에 거부합니다. | | `remoteGui.allowedTailscaleUsers` | string[] | `[]`(아무도 없음) | 자동 원격 GUI 세션을 발급받을 수 있는 정확한 Tailscale 로그인 ID입니다. `Tailscale-User-Login` 헤더는 별도 관리 인그레스에서**만** 신뢰합니다. 빈 목록은 실수가 아니라 안전한 기본값입니다. 정확히 비교하므로 오타는 조용히 거부됩니다. | -`dataPublicOrigin`과 `managementPublicOrigin`은 서로 독립적인 광고이며, 실제 배포에서는 서로 다른 소켓입니다. 관리는 443에 공개하는 루프백 전용 인그레스이고, 데이터는 자체 HTTPS 포트에 공개하는 tailnet 바인드입니다. 둘은 `ocx hub invite`가 출력하는 명령의 두 조각이고, 그중 `managementPublicOrigin`이 더 엄격합니다. pairing grant가 이 값을 grant 자신의 server origin으로 기록하고 교환 시 비교하므로, `ocx hub invite --management-url`은 설정값을 *확인*할 수만 있고 다른 값은 거부합니다. `--data-url`은 아무것도 바인드되어 있지 않으므로 실제로 덮어쓰기입니다. +`dataPublicOrigin`과 `managementPublicOrigin`은 서로 독립적인 광고이며, 실제 배포에서는 서로 다른 소켓입니다. 관리는 443에 공개하는 루프백 전용 인그레스이고, 데이터는 자체 HTTPS 포트에 공개하는 tailnet 바인드입니다. 둘은 `ocx hub invite`가 출력하는 명령의 두 조각이고, 그중 `managementPublicOrigin`이 더 엄격합니다. pairing grant가 이 값을 grant 자신의 server origin으로 기록하고 교환 시 비교하므로, `ocx hub invite --management-url`은 설정값을 *확인*할 수만 있고 다른 값은 거부합니다. `--data-url`은 아무것도 바인드되어 있지 않으므로 실제로 덮어쓰기입니다. `dataPublicOrigin`과 `--data-url`이 모두 없으면 `invite`는 바인드 주소로 대체하는데, 루프백이나 와일드카드 바인드에서는 그것이 이 컴퓨터 자신의 루프백이 되므로 상대가 쓸 수 없는 주소를 광고하는 대신 거부합니다. 허브가 자기 로컬 클라이언트까지 서비스하려면 [`unauthenticatedLoopbackListener`](#토큰을-받을-수-없는-로컬-클라이언트)도 설정합니다. `port` 없는 companion 형태가 허브를 단일 포트 배포로 만들어 주며, 공개 리스너가 이미 `127.0.0.1:`를 쓰는 루프백·와일드카드 `hostname`에서는 거부됩니다. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 1ec7d4ab4b..d4cd1e5625 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -125,10 +125,14 @@ enters a service definition or argv. A foreground `ocx start` applies the same p environment, then `OCX_API_TOKEN_FILE`, then the installed `service-api-token` — so it binds a non-loopback hostname without an exported token too. -A **management admin token** in `OPENCODEX_API_AUTH_TOKEN` is refused: the two planes are different -credentials, and the refusal says to `unset OPENCODEX_API_AUTH_TOKEN` and rerun rather than -suggesting a substitute value. Setting the variable yourself is still supported for an operator who -wants to own the value: +A **management admin token** is refused in either place it can appear — the environment variable or +a reused `service-api-token` file — and the message names the remedy for that place: unset the +variable, or delete the file and run `ocx service repair`. Both checks run before the loopback +short-circuit, because the launch wrapper reads the file into `OPENCODEX_API_AUTH_TOKEN` whatever +the hostname, so an admin-token file fences the management API closed even on a loopback bind. +`ocx status` reports that state as `admin-collision (file)` on a hub. + +Setting the variable yourself is still supported for an operator who wants to own the value: ```bash export OPENCODEX_API_AUTH_TOKEN="your-secret-token" @@ -539,7 +543,10 @@ data is the tailnet bind published on its own HTTPS port. They are the two halve `ocx hub invite` prints, and `managementPublicOrigin` is the stricter of the two — a pairing grant records it as the grant's own server origin and the exchange compares against it, which is why `ocx hub invite --management-url` can only *confirm* the configured value and refuses one that -differs. `--data-url` really is an override, because nothing is bound to it. +differs. `--data-url` really is an override, because nothing is bound to it. With neither +`dataPublicOrigin` nor `--data-url` set, `invite` falls back to the bind address — and on a +loopback or wildcard bind, where that would resolve to this machine's own loopback, it refuses +rather than advertising an address the other machine cannot use. A hub that serves its own local clients also sets [`unauthenticatedLoopbackListener`](#local-clients-that-cannot-receive-the-token). Its port-less diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 471978d9f2..14c16cdf53 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -270,24 +270,30 @@ ocx status # read the Hub: block first -- origins, listener, tok ocx hub invite --json ``` -`--json` gives `{ code, expiresAt, dataUrl, managementUrl, command }`. Hand the operator -`command` to run on the other machine; it already carries the data origin, the management -origin and `--pairing-code-stdin`. The code is a secret with a five-minute TTL and one use: -do not persist it, do not put it in a file, and prefer letting the operator copy it rather -than keeping it in a transcript. +`--json` gives `{ code, expiresAt, dataUrl, managementUrl, command }` on stdout. Hand the +operator `command` to run on the other machine; it already carries the data origin, the +management origin and `--pairing-code-stdin`. The code is a secret with a five-minute TTL and +one use: do not persist it, do not put it in a file, and prefer letting the operator copy it +rather than keeping it in a transcript. -Two refusals are normal and neither burns a code: +**Also relay the `Bound browser origin:` line from stderr.** It is not in the JSON envelope, +and when the bound origin is not `http://localhost:10100` the joining machine has to already +be running on that port or the exchange is refused and the code is spent. + +Three refusals are normal and none of them burns a code: - `No loopback browser origin is admitted for pairing` — run the - `ocx config set corsAllowOrigins '["http://localhost:10100"]'` line the error prints, using - the **joining** machine's proxy port, then invite again. Grants are origin-bound and - `ocx connect` presents its own `http://localhost:`. + `ocx config set corsAllowOrigins '["http://localhost:10100"]'` line the error prints, as + printed (it preserves the hub's existing entries) and with the **joining** machine's proxy + port. Grants are origin-bound and `ocx connect` presents its own `http://localhost:`. +- A data origin that would be this machine's own loopback — the bind is loopback-only or a + wildcard and `hub.dataPublicOrigin` is unset, so there is nothing honest to advertise. Set + `hub.dataPublicOrigin`, or pass `--data-url` for one invite. Do not work around it by + sending `http://localhost:`; that is the thing it is refusing. - A rejected `--management-url` — on `invite` that flag confirms `hub.managementPublicOrigin` rather than overriding it. Drop the flag, or change the config. -If `hub.dataPublicOrigin` is unset, `invite` falls back to `http://:`, which a -remote machine behind a TLS frontend usually cannot reach. Check that before handing the -command over. Full context: [05_remote_hub.md](05_remote_hub.md#inviting-a-machine-ocx-hub-invite). +Full context: [05_remote_hub.md](05_remote_hub.md#inviting-a-machine-ocx-hub-invite). ## Aside profiles diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index d5614ac47f..a90064df53 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -70,7 +70,10 @@ Three consequences for an agent: - **Never tell an operator to export a token before installing.** There is no such step, and the one time it was recommended, a *management admin* token went into `OPENCODEX_API_AUTH_TOKEN` and crash-looped the hub. The installer refuses an admin token - there and says to `unset OPENCODEX_API_AUTH_TOKEN` and rerun. + in either place it can appear — the variable, or a reused `service-api-token` file — and + the remedy differs: unset the variable, or delete the file and run `ocx service repair`. + Both checks run even on a loopback bind, because the wrapper reads that file into the + variable whatever the hostname. - **Never suggest regenerating it to fix something.** An existing file is reused on purpose; replacing it invalidates every per-client key already exchanged. Rotation is `ocx connect rotate`'s job, on the client. @@ -86,10 +89,17 @@ rewrite the file. On a hub, `ocx status` prints a `Hub:` block: the advertised data origin and whether it came from `hub.dataPublicOrigin` or the bind address, the loopback listener's state (`companion` / `ported` / `off`) and port, the management ingress, the management origin, -the data token's **source** (`present (env)`, `present (file)`, `unsafe (file)`, +the data token's state (`present (file)`, `unsafe (file)`, `admin-collision (file)`, `missing` — never its value), and the invite hint. Read it before asking an operator about ports or tokens. +The token state is always about the **file**, because the launch wrapper overwrites the +environment from it before exec. A separate sub-line reports `OPENCODEX_API_AUTH_TOKEN` +being set in the invoking shell, which decides only what a foreground `ocx start` in that +shell would admit. `admin-collision (file)` is the incident shape: that file holds the +management token, the hub fences its management API closed at boot, and the fix is to +delete the file and run `ocx service repair` — **not** to unset anything. + ## Which parts need pairing (the common misconception) **Pairing is not how you configure a hub.** It is how a *remote browser* gets a session @@ -198,12 +208,23 @@ ocx hub invite echo '' | ocx connect https://host.ts.net:8443 --management-url https://host.ts.net --pairing-code-stdin ``` -Origins: data from `hub.dataPublicOrigin`, `--data-url`, or `http://:` as a -last resort; management from `hub.managementPublicOrigin`. **`--management-url` is a -confirmation, not an override** — the grant records the configured management origin as its -own server origin and the exchange compares against it, so a differing value is refused with -both origins named rather than printed. `--data-url` really is an override, because nothing -is bound to it. +Origins: data from `--data-url`, then `hub.dataPublicOrigin`, then the bind address; +management from `hub.managementPublicOrigin`. **`--management-url` is a confirmation, not an +override** — the grant records the configured management origin as its own server origin and +the exchange compares against it, so a differing value is refused with both origins named +rather than printed. `--data-url` really is an override, because nothing is bound to it. + +The bind-address fallback only works when the bind is an address another machine can dial. On +a loopback or wildcard bind it would resolve to `http://localhost:`, which tells the +other machine to dial itself and spends the code for nothing, so `invite` refuses and prints +the `hub.dataPublicOrigin` fix. An explicit override is never second-guessed: a loopback data +origin is legitimate over an SSH tunnel. + +Every successful invite prints a `Bound browser origin:` line on stderr. A grant is bound to +one origin and a remote `ocx connect` presents `Origin: http://localhost:`, so when the bound origin is not the default the other machine must already be running +on that port. Relay that line; it is the difference between a working exchange and a spent +code. `invite` needs no admin token and nothing exported into the shell: it drives the same attested local route `ocx gui pair` uses, authorized by the running proxy's own attestation @@ -214,7 +235,9 @@ It refuses **before** minting anything when the setup cannot work: `runtimeRole` plaintext, `--data-url` is malformed, there is no running attested proxy, or — the non-obvious one — the hub admits no loopback browser origin. -That last one is the refusal you will actually hit: +Two of those are the refusals you will actually hit. The data-origin one names which shape +the hub has (wildcard, or loopback-only) and prints both the persistent and the per-invite +fix. The browser-origin one looks like this: ```text No loopback browser origin is admitted for pairing. Add the connecting machine's local origin: @@ -223,8 +246,10 @@ ocx config set corsAllowOrigins '["http://localhost:10100"]' `ocx connect` sends `Origin: http://localhost:` when it exchanges the grant, and grants are origin-bound, so only `hub.managementPublicOrigin` itself or a -loopback entry of `corsAllowOrigins` can ever match. Run the command it prints, with the -**joining** machine's proxy port. Nothing was minted, so there is no burned code to clean up. +loopback entry of `corsAllowOrigins` can ever match. Run the command it prints rather than a +hand-written one: a whole-array set replaces the array, so the printed line carries the hub's +existing entries plus the new origin. Nothing was minted, so there is no burned code to clean +up. `--json` emits `{ code, expiresAt, dataUrl, managementUrl, command }` with `expiresAt` as ISO 8601; `--clients codex,claude` chooses which client configs the printed command points From 7d6b5b4a882d73486e1b60070c14f6f1fac68a97 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 11 Sep 2026 12:34:48 +0900 Subject: [PATCH 6/7] docs(remote-hub): ocx service restart now restarts a healthy launchd job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base (#4249) gained `ee6a20a0e` after this unit was written, which inverts the claim five of these files had just landed. `restart` no longer folds into `repair`: it runs the same refresh and, when nothing was reloaded, runs `launchctl kickstart -k gui//com.opencodex.proxy` in place, verifies with the launchd probe, and prints `service restarted (launchctl kickstart -k …)`. `repair` keeps the no-op -- a repair of a healthy service must not be an outage -- and a bare `ocx service` still selects `repair`. Linux always restarted (`systemctl --user restart`); Windows is unchanged. So every passage saying restart aliases repair / restarts nothing / telling the operator to kickstart by hand now names `ocx service restart`, and `launchctl kickstart -k` is demoted to the manual fallback the failure path itself prints. Both guides, the two skill references and `SKILL.md` also distinguish `ocx restart` (the proxy process you started) from `ocx service restart` (the service the manager supervises) wherever a restart is prescribed; `src/cli/help.ts`'s `ocx restart` is a different verb and is untouched. `src/cli/registry.ts`'s `service` entry was already reconciled in the base -- the docs were made to match it, not the reverse. The docs-claims gate pins the new claim and forbids the old "is an alias of `repair`" sentence, while keeping the kickstart line pinned only alongside the words "manual fallback", so the page cannot quietly promote it back to the recommended route. No `src/` change. Verified: the four focused test files (57 pass, 0 fail), `bun run typecheck` clean, `bun run privacy:scan` passed, and the docs-site build (425 pages, Complete!). No `ocx service …` and no `launchctl` command was run on this host, per the operator's instruction. Co-Authored-By: Claude Fable 5.1 --- .../260911_hub_single_port/050_docs_skill.md | 42 +++++++++++++++- .../src/content/docs/guides/remote-hub.md | 48 +++++++++++++------ .../src/content/docs/ko/guides/remote-hub.md | 21 +++++--- skills/ocx/SKILL.md | 4 +- skills/ocx/references/04_failure_semantics.md | 23 ++++++--- skills/ocx/references/05_remote_hub.md | 13 +++-- .../docs-remote-hub-claims.test.ts | 12 ++++- 7 files changed, 126 insertions(+), 37 deletions(-) diff --git a/devlog/_plan/260911_hub_single_port/050_docs_skill.md b/devlog/_plan/260911_hub_single_port/050_docs_skill.md index 51ca677a5e..e860d989d3 100644 --- a/devlog/_plan/260911_hub_single_port/050_docs_skill.md +++ b/devlog/_plan/260911_hub_single_port/050_docs_skill.md @@ -202,7 +202,10 @@ a hand-written one-element array. Two corrections that the code supports and the plan's own wording did not. -**`ocx service restart` does not restart a healthy macOS job.** The assignment (and the design doc) +**`ocx service restart` does not restart a healthy macOS job.** *(Superseded — see +[Restart wording](#restart-wording-after-ee6a20a0e) below. The finding was right and the base fixed +the code rather than keeping the copy; this paragraph is kept as the reason the fix exists.)* The +assignment (and the design doc) said to document "restart with `ocx service restart` or `launchctl kickstart -k …`". On darwin, `serviceCommand` maps `restart` → `repair` (`src/service.ts:4862`), `repairService` calls `installLaunchd` (`:3587`), and after PR1 `installLaunchd` returns early — printing @@ -226,6 +229,43 @@ example port moved off `10100` so the two sockets cannot be confused. This is a not a code change: the companion is for processes *on* the hub, which send their own loopback `Host`. +## Restart wording after `ee6a20a0e` + +The base (#4249) gained `ee6a20a0e` *fix(service): make `ocx service restart` restart a healthy +launchd job* after this unit was written, which inverts the claim the section above had just landed +in five files. `restart` no longer folds into `repair`: it runs the same refresh and, when nothing +was reloaded, runs `launchctl kickstart -k gui//com.opencodex.proxy` in place, verifies with +`probeLaunchdLoadState`, and prints `ℹ️ service restarted (launchctl kickstart -k …)`. `repair` +keeps the no-op — a repair of a healthy service must not be an outage — and a bare `ocx service` +still selects `repair`. Linux always restarted (`systemctl --user restart`); Windows is unchanged. + +So every passage that said *restart aliases repair* / *restarts nothing* / *run kickstart yourself* +was rewritten to name `ocx service restart` as the verb, and `launchctl kickstart -k` demoted to the +documented manual fallback that the failure path itself prints: + +- `guides/remote-hub.md` — the macOS service-operations block now leads with `ocx service restart` + and its one output line, states that a bare `ocx service` still picks `repair`, and notes that + Linux/Windows never had the gap. The `unauthenticatedLoopbackListener` "restart the proxy" prose + and the two troubleshooting rows name the command; the no-op row is now about `repair`, which is + the verb that still correctly does nothing. +- `ko/guides/remote-hub.md` — the same three places, mirrored. +- `skills/ocx/references/04_failure_semantics.md` — the second "reads as a failure and is not" entry + is inverted: `restart` is NOT an alias, so an agent asked for a restart says `ocx service restart` + rather than writing a launchctl line for the operator. +- `skills/ocx/references/05_remote_hub.md` — the listener field, the hub-gate fix and the + standalone-rollback step each name the command. +- `skills/ocx/SKILL.md` — the pointer line now carries both halves of the pair. +- `tests/ci-workflows/docs-remote-hub-claims.test.ts` — the launchd-semantics gate pins the new + claim (`ocx service restart` … always restarts), forbids the old "is an alias of `repair`" + sentence, and keeps the kickstart line pinned only alongside the words "manual fallback", so the + page cannot quietly promote it back to the recommended route. + +Both locales now also distinguish `ocx restart` (the proxy process you started) from +`ocx service restart` (the service the manager supervises) wherever a restart is prescribed — +`src/cli/help.ts:62`'s verb is a different one and was left alone. `src/cli/registry.ts`'s `service` +entry was already reconciled in the base by `ee6a20a0e`; the docs were made to match it, not the +reverse. + ## Decisions - **Keep #4241's structure and every guarded claim.** The loopback-bind trap, the forwarder section diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 79e35a267c..00feaa58a3 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -192,8 +192,10 @@ change across restarts while already-running app-servers kept the previous `base **Restart the proxy after changing this field.** The sockets are bound once at startup and the local client files are written from the resolved value, so a running hub keeps its old answer. On a ported -hub that is the difference between `ocx claude` reaching the listener and getting a `404` from it. -See [macOS service operations](#macos-service-operations) for how to actually bounce a launchd job. +hub that is the difference between `ocx claude` reaching the listener and getting a `404` from it. On +a background service the verb is `ocx service restart`, which always restarts — see +[macOS service operations](#macos-service-operations). `ocx restart` is a different verb: it bounces +the proxy process you started yourself, not the service the manager supervises. ### The hub's own local clients @@ -429,17 +431,30 @@ file is unchanged, and `launchctl print` reports the job loaded from that plist, nothing to do.` and returns — launchd is never touched. Earlier builds evicted a healthy job unconditionally, which made a diagnostic command an outage. -That no-op has one consequence worth knowing: **`ocx service restart` is an alias of `repair`, so on -a healthy macOS job it restarts nothing.** To actually bounce the process — which is what you need -after changing `unauthenticatedLoopbackListener`, `hostname` or `port` — kick the job: +**`ocx service restart` is the verb that always restarts.** It is no longer an alias of `repair`. It +runs the same refresh, and when that reloaded nothing — the healthy, unchanged case above — it +restarts the already-loaded job in place with `launchctl kickstart -k`, re-reads `launchctl print` to +confirm the job survived, and prints one line: ```bash -launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy +ocx service restart +# ℹ️ service restarted (launchctl kickstart -k gui/501/com.opencodex.proxy). ``` -`ocx service stop` followed by `ocx service start` is the equivalent through the CLI. Use -`ocx service repair` for the case it is actually for: a job loaded from an older plist, or not loaded -at all. +That is what to run after changing `unauthenticatedLoopbackListener`, `hostname` or `port`. The +kickstart opens no eviction window, so it is not the outage the old unconditional repair was. + +A bare `ocx service` still selects `repair`, not `restart`: it is an idempotent "make it current", +not a request to bounce a healthy hub. Use `ocx service repair` for the case it is actually for — a +job loaded from an older plist, or not loaded at all — and expect it to keep doing nothing on a +healthy one. + +`launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy` by hand, or `ocx service stop` followed by +`ocx service start`, both still work and the error path names the first one as a fallback. Neither is +the recommended route any more. + +Linux and Windows never had this gap: `ocx service restart` there ends in `systemctl --user restart` +and a stop-then-start of the scheduled task respectively, whichever verb asked. `ocx service status` distinguishes four launchd states, and the last one is the one people misread: @@ -666,13 +681,16 @@ For a service rollback, stop the branch service and repair the prior release aga configured value. Change the config, or drop the flag. - **`ocx claude` on the hub launches native Codex/Claude, or the hub refuses to write its own client configs:** `unauthenticatedLoopbackListener` is off. The skip message names the gate. Enable the - listener and restart the proxy. + listener and restart the proxy (`ocx service restart` on a service install). - **`ocx claude` on the hub gets `404` from the listener:** the proxy is still the process that - started before the listener's wires existed, or before the port changed. Restart it — - see [macOS service operations](#macos-service-operations). -- **`ocx service restart` printed `nothing to do` and the process did not bounce (macOS):** expected. - `restart` aliases `repair`, and a repair of a healthy job is deliberately a no-op. Use - `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy`. + started before the listener's wires existed, or before the port changed. Restart it with + `ocx service restart` — see [macOS service operations](#macos-service-operations). +- **`ocx service repair` printed `nothing to do` and the process did not bounce (macOS):** expected. + A repair of a healthy job is deliberately a no-op. When you wanted a new process, run + `ocx service restart`, which kickstarts the loaded job in place and reports + `service restarted (launchctl kickstart -k …)`. Only if that fails is + `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy` the manual fallback — the failure + message names it. - **`ocx service install` refuses `OPENCODEX_API_AUTH_TOKEN`:** that value is a management admin token. `unset OPENCODEX_API_AUTH_TOKEN` and rerun; the service provisions its own data-plane token. See [The data-plane token provisions itself](#the-data-plane-token-provisions-itself). diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index 4c9a6a30a6..c46bb91b18 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -106,7 +106,7 @@ ocx config set unauthenticatedLoopbackListener '{"enabled":true,"port":10104}' `port`를 지정하면 로컬 통합이 리스너를 따라 `http://127.0.0.1:10104`를 기록합니다. 이 포트는 프록시 포트와 달라야 하고 OS가 자동 할당하지 않습니다. 임시 포트는 재시작 때마다 바뀌는데 이미 실행 중인 app-server는 예전 `base_url`을 들고 있기 때문입니다. -**이 필드를 바꾸면 프록시를 재시작하세요.** 소켓은 시작할 때 한 번 바인드되고 로컬 클라이언트 파일도 그때 결정된 값으로 기록되므로, 실행 중인 허브는 예전 답을 유지합니다. 포트 지정 허브에서는 이것이 `ocx claude`가 리스너에 닿는지 `404`를 받는지의 차이입니다. launchd 작업을 실제로 재시작하는 방법은 [macOS 서비스 운영](#macos-서비스-운영)을 보세요. +**이 필드를 바꾸면 프록시를 재시작하세요.** 소켓은 시작할 때 한 번 바인드되고 로컬 클라이언트 파일도 그때 결정된 값으로 기록되므로, 실행 중인 허브는 예전 답을 유지합니다. 포트 지정 허브에서는 이것이 `ocx claude`가 리스너에 닿는지 `404`를 받는지의 차이입니다. 백그라운드 서비스라면 명령은 항상 재시작하는 `ocx service restart`입니다. [macOS 서비스 운영](#macos-서비스-운영)을 보세요. `ocx restart`는 다른 명령입니다. 직접 띄운 프록시 프로세스를 재시작하며, 서비스 관리자가 감독하는 서비스를 다루지 않습니다. ### 허브 자신의 로컬 클라이언트 @@ -225,13 +225,20 @@ ocx config set corsAllowOrigins '["http://localhost:10100"]' `ocx service install`과 `ocx service repair`는 실행 중인 허브에 다시 실행해도 안전합니다. repair는 plist를 먼저 렌더링해 비교합니다. 렌더링 결과가 디스크의 바이트와 같고, 토큰 파일도 그대로이며, `launchctl print`가 그 plist에서 로드된 작업을 보고하면 repair는 `0600`을 다시 확인하고 설치 상태를 갱신한 뒤 `service is already loaded from the current plist; nothing to do.`를 출력하고 끝냅니다. launchd를 전혀 건드리지 않습니다. 이전 빌드는 정상 작업도 무조건 bootout해서 진단 명령이 장애가 되었습니다. -이 no-op에는 알아 둘 결과가 하나 있습니다. **`ocx service restart`는 `repair`의 별칭이므로, 정상인 macOS 작업에서는 아무것도 재시작하지 않습니다.** `unauthenticatedLoopbackListener`, `hostname`, `port`를 바꾼 뒤처럼 프로세스를 실제로 교체해야 할 때는 작업을 kick하세요. +**항상 재시작하는 명령은 `ocx service restart`입니다.** 더 이상 `repair`의 별칭이 아닙니다. 같은 갱신을 수행하고, 그 결과 아무것도 reload되지 않았다면 — 위의 정상·무변경 경우 — 이미 로드된 작업을 `launchctl kickstart -k`로 제자리에서 재시작하고, `launchctl print`로 작업이 살아 있는지 다시 확인한 뒤 한 줄을 출력합니다. ```bash -launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy +ocx service restart +# ℹ️ service restarted (launchctl kickstart -k gui/501/com.opencodex.proxy). ``` -CLI로는 `ocx service stop` 다음 `ocx service start`가 같은 일을 합니다. `ocx service repair`는 원래 용도, 즉 더 오래된 plist에서 로드된 작업이나 로드되지 않은 작업에 쓰세요. +`unauthenticatedLoopbackListener`, `hostname`, `port`를 바꾼 뒤에 실행할 명령이 이것입니다. kickstart는 eviction 구간을 만들지 않으므로, 예전의 무조건 repair와 달리 장애가 아닙니다. + +서브커맨드 없는 `ocx service`는 여전히 `restart`가 아니라 `repair`를 고릅니다. "현재 상태로 맞춘다"는 멱등한 동작이고, 정상인 허브를 바운스하라는 요청이 아니기 때문입니다. `ocx service repair`는 원래 용도, 즉 더 오래된 plist에서 로드된 작업이나 로드되지 않은 작업에 쓰고, 정상 작업에서는 계속 아무것도 하지 않는다고 기대하세요. + +`launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy`를 직접 실행하거나 `ocx service stop` 다음 `ocx service start`를 실행하는 방법도 여전히 동작하고, 실패 경로가 앞의 명령을 대안으로 알려 줍니다. 다만 둘 다 이제 권장 경로는 아닙니다. + +Linux와 Windows에는 이 공백이 없었습니다. `ocx service restart`는 각각 `systemctl --user restart`와 예약 작업의 stop 후 start로 끝나며, 어떤 동사로 요청했든 재시작했습니다. `ocx service status`는 네 가지 launchd 상태를 구분하고, 마지막 것이 자주 잘못 읽힙니다. @@ -320,9 +327,9 @@ docker compose up -d - `ocx hub invite`가 광고할 데이터 Origin이 이 컴퓨터의 루프백이 된다고 하면, 바인드가 루프백 전용이거나 와일드카드이고 `hub.dataPublicOrigin`이 설정되지 않은 상태입니다. 광고할 주소가 없고 tailnet/LAN 주소를 추측하지도 않습니다. 아무것도 발급되지 않았습니다. `hub.dataPublicOrigin`을 설정하거나 이번 초대에만 `--data-url`을 주세요. - 참가하는 컴퓨터의 교환이 거부되고 코드가 소모되면, grant가 그 컴퓨터가 보내지 않는 Origin에 묶였던 것입니다. 초대 출력의 `Bound browser origin:` 줄을 다시 보세요. 상대 컴퓨터가 실행해야 하는 포트를 알려 주거나, 허브에서 `http://localhost:10100`을 허용하는 방법을 제시합니다. - `ocx hub invite`가 `--management-url`을 거부하면, 허브에서 그 플래그는 `hub.managementPublicOrigin`을 덮어쓰는 것이 아니라 확인하는 것입니다. 설정을 바꾸거나 플래그를 빼세요. -- 허브에서 `ocx claude`가 native로 실행되거나 허브가 자기 클라이언트 설정을 쓰지 않으면 `unauthenticatedLoopbackListener`가 꺼져 있습니다. 건너뛴 메시지가 게이트를 지목합니다. 리스너를 켜고 프록시를 재시작하세요. -- 허브에서 `ocx claude`가 리스너로부터 `404`를 받으면, 리스너 경로가 생기기 전이나 포트가 바뀌기 전에 시작된 프로세스가 아직 돌고 있는 것입니다. [macOS 서비스 운영](#macos-서비스-운영)을 보고 재시작하세요. -- macOS에서 `ocx service restart`가 `nothing to do`를 출력하고 프로세스가 바뀌지 않는 것은 정상입니다. `restart`는 `repair`의 별칭이고 정상 작업의 repair는 의도적으로 no-op입니다. `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy`를 쓰세요. +- 허브에서 `ocx claude`가 native로 실행되거나 허브가 자기 클라이언트 설정을 쓰지 않으면 `unauthenticatedLoopbackListener`가 꺼져 있습니다. 건너뛴 메시지가 게이트를 지목합니다. 리스너를 켜고 프록시를 재시작하세요. 서비스 설치라면 `ocx service restart`입니다. +- 허브에서 `ocx claude`가 리스너로부터 `404`를 받으면, 리스너 경로가 생기기 전이나 포트가 바뀌기 전에 시작된 프로세스가 아직 돌고 있는 것입니다. `ocx service restart`로 재시작하세요. [macOS 서비스 운영](#macos-서비스-운영)을 보세요. +- macOS에서 `ocx service repair`가 `nothing to do`를 출력하고 프로세스가 바뀌지 않는 것은 정상입니다. 정상 작업의 repair는 의도적으로 no-op입니다. 새 프로세스가 필요했다면 `ocx service restart`를 실행하세요. 로드된 작업을 제자리에서 kickstart하고 `service restarted (launchctl kickstart -k …)`를 보고합니다. 그것이 실패할 때에만 `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy`가 수동 대안이며, 실패 메시지가 그 명령을 알려 줍니다. - `ocx service install`이 `OPENCODEX_API_AUTH_TOKEN`을 거부하면 그 값은 관리자 토큰입니다. `unset OPENCODEX_API_AUTH_TOKEN` 후 다시 실행하세요. 서비스가 데이터 플레인 토큰을 직접 준비합니다. - 허브가 부팅에서 계속 죽고 `ocx status`가 `admin-collision (file)`을 보이면, `service-api-token` 파일에 관리자 토큰이 들어 있어서 허브가 관리 API를 닫은 상태입니다. 파일을 삭제하고 `ocx service repair`를 실행해 데이터 플레인 토큰을 준비하세요. 이 경우 환경 변수를 unset해도 해결되지 않습니다. 원인은 파일입니다. - 루프백이 아닌 평문 HTTP로는 pairing을 할 수 없고, 이를 우회하는 플래그도 없습니다. 관리 Origin을 HTTPS 뒤에 두거나 루프백에서 pairing하세요. 관리자 토큰은 HTTP로 보내지 않습니다. diff --git a/skills/ocx/SKILL.md b/skills/ocx/SKILL.md index ecd834153c..f1f24b1b98 100644 --- a/skills/ocx/SKILL.md +++ b/skills/ocx/SKILL.md @@ -165,7 +165,9 @@ different client key owns the journal, or the restore was only partial. Details, including the one-port recipe, the invite flow and key rotation's two-step commit: `references/05_remote_hub.md`. Service and launchd semantics, including why -`ocx service repair` can correctly do nothing: `references/04_failure_semantics.md`. +`ocx service repair` can correctly do nothing while `ocx service restart` always restarts — +so a restart is never a hand-written `launchctl kickstart`: +`references/04_failure_semantics.md`. ## References diff --git a/skills/ocx/references/04_failure_semantics.md b/skills/ocx/references/04_failure_semantics.md index 044a509a7a..b0755fd271 100644 --- a/skills/ocx/references/04_failure_semantics.md +++ b/skills/ocx/references/04_failure_semantics.md @@ -79,12 +79,20 @@ bytes match the file, the token file is unchanged, and `launchctl print` reports loaded from that plist, launchd is not touched at all. Do not retry it, and do not escalate to `ocx service uninstall`. -**`ocx service restart` is an alias of `repair`, so on a healthy macOS job it restarts -nothing.** That matters whenever a restart is the actual requirement — after a change to -`unauthenticatedLoopbackListener`, `hostname` or `port`. To bounce the process, tell the -operator to run `launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy`, or -`ocx service stop` followed by `ocx service start`. Reserve `ocx service repair` for a job -loaded from an older plist, or not loaded at all. +**`ocx service restart` is NOT an alias of `repair` — it always restarts.** It runs the same +refresh, and when nothing was reloaded (the healthy, unchanged job above) it restarts the +loaded job in place with `launchctl kickstart -k gui//com.opencodex.proxy`, verifies the +job with the same probe, and prints `service restarted (launchctl kickstart -k …)`. So when a +restart is the actual requirement — after a change to `unauthenticatedLoopbackListener`, +`hostname` or `port` — tell the operator `ocx service restart`, not a hand-written launchctl +command. Linux restarts through `systemctl --user restart` and Windows stops then starts the +task, on either verb. + +A bare `ocx service` still selects `repair`, so it will not bounce a healthy hub. Reserve +`ocx service repair` for a job loaded from an older plist, or not loaded at all. +`launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy` is still a correct manual fallback +and the failure path names it, but do not lead with it. `ocx restart` is a different verb +entirely: it restarts a proxy process, not the service the manager supervises. `ocx service status` has four launchd verdicts, and only two of them call for a repair: @@ -109,7 +117,8 @@ can exit 0 having deliberately written nothing: > unauthenticatedLoopbackListener is enabled. That is the hub gate, not the operator's `clientIntegrations` toggle, and not a lock -conflict — there is nothing to retry. Either enable the listener and restart the proxy, or +conflict — there is nothing to retry. Either enable the listener and restart the proxy +(`ocx service restart` on a service install), or report that this hub leaves its own clients native. Details: [05_remote_hub.md](05_remote_hub.md#the-hub-gate-on-the-hubs-own-clients). diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index a90064df53..5fa086d5f4 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -40,7 +40,10 @@ local management calls use the authenticated management surface with a managemen credential. Do not propose widening the listener to `/api/*` as a fix for anything. Changing this field needs a proxy restart — the sockets bind once at startup and the -exported client files are written from the resolved port. +exported client files are written from the resolved port. On a background service the command +is `ocx service restart`, which always restarts (on macOS it kickstarts an unchanged, +already-loaded job in place); `ocx service repair` would correctly no-op and leave the old +process serving. `ocx restart` is the separate verb for a proxy you started yourself. ### The hub gate on the hub's own clients @@ -55,7 +58,8 @@ Read that as the gate, not as the operator's `clientIntegrations` toggle — it only when the toggle is ON and the gate is what stopped the write. A gated `ocx ensure` leaves an existing managed Grok block in place instead of stripping it, and a gated `ocx restore back` reports the gate instead of blaming a competing writer. The fix is to -enable the listener and restart, or to accept that this hub leaves its own clients native. +enable the listener and restart (`ocx service restart` on a service install), or to accept +that this hub leaves its own clients native. ### The hub's data token is not yours to produce @@ -302,8 +306,9 @@ Revocation is the other half: - **Device lost, already disconnected, or unreachable:** delete the key in the hub dashboard under Integrations → API Keys. -To return the hub itself to a normal install, set `runtimeRole` to `standalone` and -restart. Leftover `hub` and `remoteGui` blocks are inert outside the hub role. +To return the hub itself to a normal install, set `runtimeRole` to `standalone` and restart +with `ocx service restart` (or `ocx restart` for a proxy you run yourself). Leftover `hub` and +`remoteGui` blocks are inert outside the hub role. A remote browser logging itself out (`/api/session/logout`) is a third, separate action. It ends a browser session; it does not disconnect a client or revoke a key. diff --git a/tests/ci-workflows/docs-remote-hub-claims.test.ts b/tests/ci-workflows/docs-remote-hub-claims.test.ts index 1ad087378e..23ef5bd0c8 100644 --- a/tests/ci-workflows/docs-remote-hub-claims.test.ts +++ b/tests/ci-workflows/docs-remote-hub-claims.test.ts @@ -137,9 +137,17 @@ describe("the one-port hub recipe", () => { test("the English page keeps the macOS launchd semantics a repair changed", async () => { const source = await Bun.file(GUIDE).text(); - // `restart` aliases `repair`, and a repair of a healthy job is now a no-op, so the kickstart - // line is the only way to actually bounce a launchd hub. + // `repair` of a healthy job is a no-op, and `restart` is no longer an alias of it (#4249): + // `ocx service restart` refreshes the definition and, when nothing was reloaded, kickstarts + // the loaded job in place. Naming the no-op without naming the verb that DOES restart is what + // sent operators to a hand-written launchctl command. + expect(source).toContain("ocx service restart"); + expect(source).toMatch(/`ocx service restart`[^\n]*always restarts/); + expect(source).not.toMatch(/`ocx service restart` is an alias of `repair`/); + // The kickstart line stays pinned, but only as the documented manual fallback -- the page has + // to say so, or it reads as the recommended route again. expect(source).toContain("launchctl kickstart -k gui/$(id -u)/com.opencodex.proxy"); + expect(source).toContain("manual fallback"); // The fourth status state is the one that used to be reported as "not loaded" and sent // operators to repair a serving hub. expect(source).toContain("launchd state could not be verified"); From eff72044679d1ea2461a6d2f4bd6462b40327953 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 11 Sep 2026 12:37:22 +0900 Subject: [PATCH 7/7] docs(cli): lifecycle reference no longer calls restart an alias of repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repo-wide grep for the old claim found two pages outside the previous commit's file list, and both quote code that `ee6a20a0e` changed: - `reference/cli/lifecycle.md` (en + ko) had `| restart | Alias of repair. |` in the `ocx service` subcommand table. It now describes the two verbs separately, matching `src/cli/registry.ts`: `repair` reloads the manager only when something changed, so a healthy unchanged macOS job keeps running; `restart` is the same refresh but always restarts, kickstarting an unchanged loaded job in place. - The `ocx status` version-skew paragraph on both pages still printed the pre-`ee6a20a0e` advice, `ocx service repair (ocx service restart is an alias)`, which `src/cli/version-skew.ts` no longer emits: a skew leaves the definition byte-identical, so repair reloads nothing and keeps the old process serving. Both now say `ocx service restart` and why. The bare-`ocx service` row and the `repair` row also stop claiming an unconditional restart — a gap PR1 left rather than one this round created. The other five locales of `lifecycle.md` still carry both old rows and are recorded in the devlog's Left over, with the same translation-parity follow-up as the rest of this unit. Verified: the four focused test files (57 pass, 0 fail, 519 expect()), `bun run privacy:scan` passed, and `cd docs-site && bun run build` → 425 pages, Complete! No `src/` change. No `ocx service …` and no `launchctl` command was run on this host. Co-Authored-By: Claude Fable 5.1 --- .../_plan/260911_hub_single_port/050_docs_skill.md | 14 +++++++++++++- .../src/content/docs/ko/reference/cli/lifecycle.md | 11 ++++++----- .../src/content/docs/reference/cli/lifecycle.md | 12 +++++++----- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/devlog/_plan/260911_hub_single_port/050_docs_skill.md b/devlog/_plan/260911_hub_single_port/050_docs_skill.md index e860d989d3..bfbce5e792 100644 --- a/devlog/_plan/260911_hub_single_port/050_docs_skill.md +++ b/devlog/_plan/260911_hub_single_port/050_docs_skill.md @@ -266,6 +266,16 @@ Both locales now also distinguish `ocx restart` (the proxy process you started) entry was already reconciled in the base by `ee6a20a0e`; the docs were made to match it, not the reverse. +A repo-wide grep for the old claim found two more pages outside this unit's original file list, and +both were fixed in the same commit because they quote the code verbatim and the code changed: +`reference/cli/lifecycle.md` (en + ko) had `| restart | Alias of repair. |` in the `ocx service` +subcommand table, and the `ocx status` version-skew paragraph still printed the pre-`ee6a20a0e` +advice (`ocx service repair (ocx service restart is an alias)`) that `src/cli/version-skew.ts` no +longer emits. The `repair` row and the bare-`ocx service` row there are now honest about the +conditional reload too, which was a gap PR1 left rather than one this round created. The other five +locales of `lifecycle.md` (`fr`, `ja`, `ru`, `tr`, `zh-cn`, `zh-tw`) still carry both old rows — +added to Left over, same follow-up as the other translation parity work. + ## Decisions - **Keep #4241's structure and every guarded claim.** The loopback-bind trap, the forwarder section @@ -328,7 +338,9 @@ No repository-wide suite (operator instruction); hosted CI at the exact pushed h - **Five translations** (`fr`, `ja`, `ru`, `tr`, `zh-cn`, `zh-tw`) of `guides/remote-hub.md` and `reference/configuration/server.md`, which still carry both #4241's defects and the pre-#4236 - recipe. One follow-up. + recipe. One follow-up. The same five copies of `reference/cli/lifecycle.md` join it: their + `ocx service` table still says `restart` is an alias of `repair`, and their `ocx status` paragraph + still prints the pre-`ee6a20a0e` skew advice. en + ko are fixed. - `POST /v1/messages/count_tokens` on the loopback listener is documented as a 404 in two places. If PR3's open question is answered yes, both sentences move together. - Cursor's `apiKeyMode` copy (PR3's note) is GUI text and is not documented here either way. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 925e6b363a..7f9d741a37 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -102,8 +102,9 @@ dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 ### `ocx status [--json]` status와 `ocx doctor`는 현재 CLI와 실행 중인 프록시의 버전을 비교합니다. CLI가 더 새로우면 -원하는 최신 설치로 프록시를 재시작하십시오. 백그라운드 서비스라면 `ocx service repair`를 -실행합니다(`ocx service restart`는 별칭). 프록시가 더 새로우면 CLI를 업그레이드하거나 +원하는 최신 설치로 프록시를 재시작하십시오. 백그라운드 서비스라면 `ocx service restart`를 +실행합니다. 버전 불일치는 서비스 정의를 그대로 두기 때문에 `ocx service repair`는 아무것도 +reload하지 않고 예전 프로세스가 계속 서비스합니다. 프록시가 더 새로우면 CLI를 업그레이드하거나 `PATH`가 원하는 설치를 가리키도록 수정하십시오. 이 진단은 서비스를 복구하거나 요청 허용 여부를 바꾸지 않습니다. @@ -247,10 +248,10 @@ Windows 작업 스케줄러로 설치하는 서비스는 보통 프로세스 우 | 하위 명령 | 동작 | | --- | --- | -| 없음 | 서비스가 없으면 설치하고 시작하며, 이미 있으면 새로 고쳐 재시작합니다. 정상인 Windows 작업 스케줄러 정의는 재사용하지만, 오래된 정의는 다시 등록되어 관리자 권한 승인이 필요할 수 있습니다. | +| 없음 | 서비스가 없으면 설치하고 시작하며, 이미 있으면 `repair`를 수행합니다. 정상인 Windows 작업 스케줄러 정의는 재사용하지만, 오래된 정의는 다시 등록되어 관리자 권한 승인이 필요할 수 있습니다. | | `install` | 서비스를 생성하고 시작합니다. | -| `repair` | 설치된 서비스를 제자리에서 새로 고친 뒤 재시작합니다. 정상인 Windows 작업 스케줄러 정의는 재사용하지만, 오래된 정의는 다시 등록되어 관리자 권한 승인이 필요할 수 있습니다. | -| `restart` | `repair`의 별칭입니다. | +| `repair` | 설치된 서비스를 제자리에서 새로 고치고, 바뀐 것이 있을 때만 관리자를 reload합니다. macOS에서 정상이고 변경이 없는 작업은 그대로 실행된 채 남으므로 repair가 장애가 되지 않습니다. 정상인 Windows 작업 스케줄러 정의는 재사용하지만, 오래된 정의는 다시 등록되어 관리자 권한 승인이 필요할 수 있습니다. | +| `restart` | 같은 갱신이지만 항상 재시작합니다. macOS에서 변경이 없고 이미 로드된 작업은 제자리에서 kickstart됩니다. `repair`의 별칭이 아닙니다. | | `start` | 설치된 서비스를 시작합니다. | | `stop` | 서비스를 중지하고 기본 Codex를 복원합니다. | | `status` | 서비스와 프록시 진단, 로그 경로를 보고합니다. | diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 24ccd5c094..bc0c03c57f 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -121,7 +121,8 @@ are left in place. Status and `ocx doctor` compare this CLI's version with the running proxy. If the CLI is newer, restart the proxy using the intended current installation; for a background service, run -`ocx service repair` (`ocx service restart` is an alias). If the proxy is newer, upgrade the CLI +`ocx service restart` — a version skew leaves the service definition byte-identical, so +`ocx service repair` would reload nothing and keep the old process serving. If the proxy is newer, upgrade the CLI or resolve `PATH` to the intended installation. These diagnostics do not repair the service or change whether requests are allowed. @@ -310,16 +311,17 @@ Definitions installed before this change still carry the old versioned paths and themselves — once the old executable is deleted, no opencodex code runs to fix it. Run `ocx service repair` once after upgrading; after that, each service start follows the launcher. An already-running proxy is not replaced by an external upgrade: when the installed CLI is newer -than the running proxy, restart the service (or run `ocx service repair`) so the new build serves. +than the running proxy, run `ocx service restart` so the new build serves. `repair` is not enough +there: the definition did not change, and a repair that changes nothing reloads nothing. If the proxy is newer instead, check the CLI installation and `PATH` as described under [`ocx status`](#ocx-status---json). | Subcommand | Action | | --- | --- | -| none | Install and start when absent; otherwise refresh and restart the existing service. A healthy Windows scheduler definition is reused; a stale definition may be re-registered and require elevation. | +| none | Install and start when absent; otherwise `repair` the existing service. A healthy Windows scheduler definition is reused; a stale definition may be re-registered and require elevation. | | `install` | Create and start the service. Registers it, which on Windows needs elevation. | -| `repair` | Refresh an installed service in place and restart it. A healthy Windows scheduler definition is reused; a stale definition may be re-registered and require elevation. | -| `restart` | Alias of `repair`. | +| `repair` | Refresh an installed service in place, reloading the manager only when something changed — so on macOS a healthy, unchanged job keeps running and the repair is not an outage. A healthy Windows scheduler definition is reused; a stale definition may be re-registered and require elevation. | +| `restart` | The same refresh, but it always restarts. On macOS an unchanged, already-loaded job is kickstarted in place. Not an alias of `repair`. | | `start` | Start an installed service. | | `stop` | Stop the service and restore native Codex. | | `status` | Report service and proxy diagnostics plus log paths. |