From 09cf866f0e06080f99b875a24b0bb12b7c4028fb Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 11 Oct 2026 19:36:29 -0400 Subject: [PATCH 01/13] docs(server): design the compass gateway boot wiring (RIG-2862) Wires the fork boot entrypoint to the Compass Server doors: VerifyAgentToken authorizer, per-owner credential store pool with the pending-write pin and request leases, a provider/id bundled catalog, and the stack's llm-gateway service bearer, server address and start order. Co-authored-by: Matt Wilkinson --- .../server/compass-gateway-boot-wiring.md | 594 ++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 docs/designs/server/compass-gateway-boot-wiring.md diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md new file mode 100644 index 000000000..899ece1a3 --- /dev/null +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -0,0 +1,594 @@ +# Compass gateway boot wiring + +## Problem / Intent + +Tracking: RIG-2862 (remainder: boot wiring). Addendum to +[`compass-server-llm-gateway`](./compass-server-llm-gateway/design.md) §T1 and +to the §Global Constraints "Compass boot entrypoint" bullet of +[`compass-gateway-credential-door-agent-token`](./compass-gateway-credential-door-agent-token.md). + +The fork's compass boot entrypoint (`packages/coding-agent/src/cli/gateway-boot.ts` +in RigelBuild/oh-my-pi) calls `runAuthGatewayCommand({ action: "serve" })`, +whose `runServe` throws without `OMP_AUTH_BROKER_URL`. So the published image +cannot serve under the stack. This record wires that entrypoint to the Compass +Server's doors: agent tokens are verified by `GatewayAuth.VerifyAgentToken`, +each owner gets its own `AuthStorage` over `CompassAuthCredentialStore`, and +the stack mints, registers and mounts the `llm-gateway` service bearer those +door calls need. + +## Approach + +The work has two halves: a fork boot that talks to the Server, and a stack +spec that gives the boot what it needs. + +### Fork: a compass boot, not a reshaped `runServe` + +`gateway-boot.ts` stops calling `runAuthGatewayCommand`. It calls +`startAuthGateway` itself with compass adapters. `runServe` in +`packages/coding-agent/src/cli/auth-gateway-cli.ts` stays the broker path for +upstream `omp auth-gateway serve`. The boot reuses `registerGatewayShutdown` +and the `GATEWAY_MODEL_KINDS` list (exported by this change). Two new modules +go in `packages/ai/src/compass/`, next to the store, so the managed plane can +reuse them: + +- **`agent-verifier.ts`: the authorizer.** It sends the presented bearer to + `POST /compass.v1.GatewayAuth/VerifyAgentToken`, using + the same Connect JSON transport and service bearer as `#request` in + `credential-store.ts`. The `AuthGatewayAuthorizer` doc in + `auth-gateway/types.ts` sets the result mapping: "`null` to answer 401. A + throw answers 503 … bound any remote call; a timeout should throw." + - No bearer → `null`, with no call made. + - 200 → `{ kind: "agent", agentAccountId, ownerUserId }`. An empty id + throws. + - `unauthenticated` with message `invalid agent token` → `null`. That + message is `errGatewayTokenInvalid` in `go/server/gateway_auth.go`; S0 + pins it as a wire contract (OQ4). + - Any other `unauthenticated` → throw (503). A bad service bearer gets + `errServiceUnauthenticated` ("unauthenticated") from + `ServiceBearerInterceptor` (`go/internal/auth/service_door.go`). + Mapped to 401, a broken gateway would look like a bad agent token. + - `internal`, `unavailable`, a timeout or a transport error → throw. + - The deadline is `AbortSignal.any([req.signal, AbortSignal.timeout(5_000)])`. + - **No cache.** One loopback call per admitted request, so a revoked token + fails at once (OQ3). +- **`owner-pool.ts`: the per-owner store pool.** `resolveStorage(caller)` + calls `pool.get(caller.ownerUserId)`. A missing `ownerUserId` throws, which + the router answers with 503 `credential pool unavailable` + (`createAuthGatewayRouter` in `server.ts`). + - **Lazy, single-flight open.** On first use the pool opens, per owner: + 1. `CompassAuthCredentialStore.open({ baseUrl, token, fetch })`, with no + `agentAccountId`; + 2. `new AuthStorage(store, { sourceLabel: "compass" })`; + 3. `storage.credentials.reload()`. + + Concurrent first requests share one open, and a failed open is not + cached. The open runs inside `routeScope`, because on `rigel-release` + `routeScope` wraps the router `body`, `resolveStorage` included. So + `open()` sees the agent token. + - **Per-request leases.** A stream outlives `route`: Bun idles a response up + to 255 s (`idleTimeout: 255` in `startAuthGateway`). A mid-stream OAuth + refresh then writes back through the store. A store closed under it throws + `closed` (`#request` in `credential-store.ts`), and the rotated refresh + token is lost. So: + - `routeScope` runs each routed request in a lease scope. + - Each `get` inside it takes a lease on the entry. + - The lease ends when the response body ends, errors or is cancelled. The + boot wraps the body: a `TransformStream` `flush`/`cancel` pair, or a + `ReadableStream` wrapper if Bun lacks the transformer `cancel` hook. + - A body-less result or a throw ends the lease at once. + - **Eviction.** Each `get` stamps `lastUsedMs`. A sweep (60 s timer, + `unref`) closes entries that meet all of: + - `leases === 0`; + - `store.hasPendingWrites()` is false; + - idle longer than `idleMs` (15 min). + + Over `maxOwners` (256), the sweep also closes least-recently-used entries + that are idle longer than 60 s and pass the same two checks. The cap is + soft. The `hasPendingWrites()` pin is the door record's rule. + - **`close()` on shutdown** closes every entry, pinned or leased. A parked + write is lost at exit. The door record accepts this residual. + - **No credential-poll timer** (door record: "A credential-poll timer is not + needed"). +- **`routeScope`.** This is the door record's line, plus the lease scope: + `(req, run) => { const t = presentedBearer(req); const leased = () => pool.withRequestLease(run); return t ? runWithAgentToken(t, leased) : leased(); }`. +- **Catalog: the bundled catalog, by `provider/id` only (OQ1).** + `ModelResolver` is `(modelId) => Model | undefined` (`dispatch.ts`). It + never sees the caller, and `/v1/models` "skips the pool lookup". So no + per-owner catalog is possible without a router-core change. At start the + boot builds one map from `getBundledProviders()` and `getBundledModels()` + (`@oh-my-pi/pi-catalog`), keeping kinds in `GATEWAY_MODEL_KINDS`, keyed + `` `${provider}/${id}` `` only. + - **No bare-id fallback.** `indexModelsByRequestId` adds one (`if + (!modelById.has(model.id)) modelById.set(model.id, model);`). Across every + bundled provider, that fallback would route a bare id to the first + provider in catalog order, which the owner may hold no credential for. + A bare id gets the router's 404 `Unknown model` instead (`resolveModel` + callers in `server.ts`). + - A model whose provider is not in the owner's pool fails in dispatch with + 401 `No credential available for provider X` (`resolveGatewayApiKey`). + - `pickProbeCandidates` already sends `getBundledModels` output straight to + providers, so bundled `Model` objects are callable without + `ModelRegistry`. + - There is no refresh timer. Stable-name P1 replaces this resolver. +- **Authorizer composition.** `authorize = verifier`. When + `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` is set, it becomes + `withEnrollToken(enrollToken, verifier)` (OQ5). +- **No inbound shared bearer.** `bearerTokenAuthorizer` is not used. Agents + carry per-agent tokens (parent T3/T5), and no Go code reads + `gateway.token` except the stack mount (`gatewayContainerSpec`) (OQ2). +- **TLS to the door.** The store and the verifier share one compass fetch: + `(input, init) => fetch(input, { ...init, tls: { ca } })`, with `ca` read + from `COMPASS_SERVER_CA_FILE`. Provider calls keep the default trust store. + `NODE_EXTRA_CA_CERTS` is not used: `wrapFetchForExtraCa` + (`packages/utils/src/tls-fetch.ts`) would apply the CA to every fetch. +- **Fail-fast door probe.** `/healthz` answers 200 with no checks + (`startAuthGateway`), so `waitGateway` passes even when every door call + fails. Before bind, the boot calls `VerifyAgentToken` with `token: ""`. It + expects `unauthenticated` with the message `invalid agent token`. On the + Server, `GatewayTokens.Verify` (`go/internal/auth/gateway_token.go`) returns + `ErrGatewayTokenInvalid` for a malformed token before any store read + (`if !wellFormedToken(token)`). Any other result exits non-zero. The probe proves network + reach, TLS and a live service bearer. It reads no credentials, so door + rule 1 ("no door call with no request behind it") still holds. After the + bounded restarts the container stays down, and `waitGateway` fails `up` + loudly. + +### Stack: service bearer, server address, start order + +- **The service bearer.** Its file is `/gateway/service.token`, + mounted at `/run/compass/service.token` and named by + `COMPASS_SERVICE_TOKEN_FILE`. S1 deletes the old `/gateway/gateway.token`. + The rename keeps the old hex inbound secret from being adopted through the + "unknown → re-register" arm. It also makes version skew fail before bind: + an old image reads the missing `gateway.token`, and a new image finds no + `COMPASS_SERVER_URL`. + + `EnsureServiceToken` uses the file-before-store shape of `ensure` in + `adapters/token.go`. S1 makes the status check generic over + `store.Subject`; `RunnerTokenStatus` hardcodes `subj.Kind == + store.SubjectRunner && subj.ID == runnerID`. The want is + `{Kind: store.SubjectService, ID: auth.LLMGatewayServiceID}`: + - **Registered under that subject:** keep the token. + - **Unknown** (database replaced): re-register the same token. + - **Revoked, or another subject:** mint a fresh one. The runner instead + keeps a revoked token, but no operator CLI mints service tokens. + - **Minting:** `runnerhub.GenerateRunnerToken`, then write the file at 0600 + atomically, then `PutTokenHash`. If the hash commit fails, remove the + file. + + `ensureGatewayToken` (`adapters/gateway_container.go`) is deleted. + + Residuals: + - a revoke while the stack runs means 503 on every request until the next + `up`; + - with `ExternalDatabase` and a wiped state dir, each `up` leaves the old + `llm-gateway` hash live, with no revoke-by-subject. The runner path has + the same flaw. +- **Server address (Linux).** The container runs with + `--network pasta:-T,` and dials + `https://127.0.0.1:`. The cert's `127.0.0.1` SAN matches + (`certHosts` in `adapters/cert.go`). By default pasta does not forward + container loopback to the host (`pastaHostGateway` comment in + `go/e2e/fixture.go`). Local rootless podman 5.8.6 probes showed: + - the default network is refused; + - `-T` reaches the host listener, and `-p` publishing still works. + + `/tls.crt` (0644, `certgen.WriteFiles`) is mounted read-only at + `/run/compass/server-ca.crt`. On darwin the server runs on the Mac and the + container runs in the podman-machine VM (`internal/embedded/machine.go`). + There `-T` would reach the VM's loopback [INFERENCE]: OQ7. The spec carries + the URL and network args as fields, so the OQ7 choice changes only S2 and + `certHosts`. +- **Start order.** Today `spawnChain` (`stack.go`) starts the gateway at step + 1e, before the TLS anchor (step 2) and the server (step 3). On a first run + that leaves no CA file and no store to register the token in. The gateway + moves to after `waitReady` and before `startRunner`, and in-process + `drainChildren` becomes runner → gateway → server. The drain gain is small. + By then the runner and its agents are gone, and once T5 revokes tokens on + stop, a late write-back parks and is lost anyway (OQ6). + +## Alternatives considered + +- **Reshape `runServe` with a `--compass` mode.** This mixes broker and + compass wiring in one upstream command. Rejected. +- **Bare-id index over all providers.** Misroutes shared bare ids. Rejected + for `provider/id` only. +- **Keep the shared bearer as a second admitted class (OQ2).** Anyone holding + the file would get a pool-less `"\u0000shared"` caller. Rejected. +- **Reuse `gateway.token` for the service bearer.** It adopts the old inbound + secret, and version skew is silent about which meaning the file has. + Rejected for the rename. +- **`--network host`.** Drops `-p` and network isolation. Rejected. + `host.containers.internal` is OQ7 option A. +- **ErrorInfo reason on `VerifyAgentToken`.** The verifier would have to + decode protobuf error details from Connect JSON. Rejected for S0's literal + pin (OQ4). +- **Cache `VerifyAgentToken` results.** A revoked token would keep working. + Deferred (OQ3). + +## Global Constraints + +- **Public repo.** This record and every compass task cite only compass and + RigelBuild/oh-my-pi. +- **Fork changes stay seam-shaped.** Fork changes are limited to: + - new files in `packages/ai/src/compass/`; + - the boot (`gateway-boot.ts`); + - one export from `auth-gateway-cli.ts` (`GATEWAY_MODEL_KINDS`). + + No change to `auth-storage.ts`, `packages/ai/src/auth/`, or + `auth-gateway/server.ts` or `dispatch.ts`. +- **Fork base.** `rigel-release` with RIG-5205 (`routeScope`, `d0ac08ac56cc`) + and RIG-5206 (fork #146). #146 must first be rebased onto a `rigel-release` + that contains `d0ac08ac56cc`; its workspace has no `routeScope`. +- **Server dependency.** A default-pinned image needs RIG-5204. On main, + `ListCredentialPoolRequest` still requires `agent_account_id` + (`gateway_credentials.proto`: "empty is INVALID_ARGUMENT"). +- **Fail before bind.** The boot exits non-zero and never binds when: + - `COMPASS_SERVER_URL` is unset or fails `assertSecureBaseUrl`; + - an https URL has no readable `COMPASS_SERVER_CA_FILE`; + - `COMPASS_SERVICE_TOKEN_FILE` is unset or fails `readGatewayToken`; + - `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` is set but unreadable, or fails + `withEnrollToken`; + - the door probe gets anything but `invalid agent token`. + + The compass values have no defaults. +- **Secrets never logged.** The service bearer, agent tokens and enroll token + never appear in a log line or an error. Only `errorName(error)` reaches + logs. +- **Env contract.** The stack passes these with `-e`, never as image `ENV`. + I1 removes the `COMPASS_GATEWAY_TOKEN_FILE` `ENV` from `Dockerfile.gateway` + and its entry from `SECRET_NAME_ALLOWLIST` (`tools/gateway-image/core.ts`). + + | Name | Value | Supplier | + |---|---|---| + | `COMPASS_SERVICE_TOKEN_FILE` | `/run/compass/service.token` | stack (S2), new mount | + | `COMPASS_SERVER_URL` | `https://127.0.0.1:` (Linux; darwin: OQ7) | stack (S2) | + | `COMPASS_SERVER_CA_FILE` | `/run/compass/server-ca.crt` | stack (S2), new mount | + | `COMPASS_GATEWAY_BIND` | `0.0.0.0:4000` | stack, existing | + | `COMPASS_GATEWAY_DRAIN_MS` | `20000` | stack, existing | + | `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` | optional | E2/E3 lane, later | + +- **Door listen host.** S2 accepts any `ListenAddr` host for which + `overlapsLoopbackPublish` (`config.go`) is true: `127.0.0.1`, an + unspecified address, or a hostname. Any other host errors in the spec. +- **Order.** Each line below is linear. + - Compass: S0 → S1 → S2 → P1. S2 calls `EnsureServiceToken`. + - Fork: #146 rebased → F1 → F2 → F3. F1 has no code dependency on #146. + - I1 follows F3's merge to `rigel-release`, because `build.ts` checks + ancestry. + - P1 also waits on I1's digest and RIG-5204. + + No Pulumi state is involved. + +## Plan + +### S0 — Pin the verify message (stack) + +Lane: compass-server (owns `go/server`). + +Interfaces: none new. Add a test in `go/server/gateway_auth_test.go` that +asserts `connect.CodeOf(err) == connect.CodeUnauthenticated` and +`err.(*connect.Error).Message() == "invalid agent token"`. Its comment names +the gateway verifier and boot probe as consumers. + +Test cycle: `go test ./server -run VerifyAgentToken`. + +### F1 — Agent verifier (fork) + +Lane: compass-gateway. + +```ts +// packages/ai/src/compass/agent-verifier.ts (exported from ./compass) +export interface CompassAgentVerifierOptions { + baseUrl: string; // same rules as CompassAuthCredentialStoreOptions.baseUrl + token: string; // llm-gateway service bearer + fetch?: typeof fetch; + timeoutMs?: number; // default 5_000 +} +export function createCompassAgentVerifier(opts: CompassAgentVerifierOptions): AuthGatewayAuthorizer; +/** The boot's pre-bind probe: resolves when the door answers `invalid agent token` to "". */ +export function probeCompassDoor(opts: CompassAgentVerifierOptions): Promise; +``` + +Consumes `compassRpcErrorFromResponse` and `decodeCompassJson` (`wire.ts`) and +`presentedBearer` (`auth-gateway/http.ts`). + +Test cycle: `bun test` against a fake `fetch`. Cases: + +- no bearer → `null`, with no call made; +- 200 → `AgentCaller`, and the request carries the service bearer plus + `{ token }`; +- `invalid agent token` → `null`; +- other `unauthenticated` → throws; +- `internal` → throws; +- timeout → throws; +- `req.signal` abort cancels the call; +- an empty id in the response → throws; +- the probe resolves only on `invalid agent token`; +- no token appears in any error. + +### F2 — Owner pool (fork) + +Lane: compass-gateway. + +```ts +// packages/ai/src/compass/owner-pool.ts (exported from ./compass) +export interface CompassOwnerEntry { storage: AuthStorage; store: { hasPendingWrites(): boolean } } +export interface CompassOwnerPoolOptions { + open: (ownerUserId: string) => Promise; + maxOwners?: number; // default 256 (soft) + idleMs?: number; // default 900_000 + minIdleMs?: number; // default 60_000; floor for cap eviction + sweepIntervalMs?: number; // default 60_000; 0 = no timer (tests) + now?: () => number; +} +export class CompassOwnerPool { + constructor(opts: CompassOwnerPoolOptions); + /** Inside withRequestLease, also leases the entry until that request's lease ends. */ + get(ownerUserId: string): Promise; + /** Runs one routed request. Its leases end when a Response body ends, errors or is cancelled; at once for a body-less result or a throw. */ + withRequestLease(run: () => Promise): Promise; + sweep(): void; + close(): void; +} +export function openCompassOwner(opts: CompassAuthCredentialStoreOptions): Promise; +``` + +Test cycle: `bun test` with fake entries and a fake clock. Cases: + +- same instance per owner; +- concurrent first `get` opens once; +- a failed open is retried; +- an idle entry closes; +- **a streamed response held open past `minIdleMs` and `idleMs`, with the + cap exceeded, is not evicted, and it is evictable once the body ends**; +- a cancelled body and a thrown `run` both release the lease; +- **an idle owner with a parked write survives the sweep, and the next + request under a live token commits it.** This uses a real + `CompassAuthCredentialStore` over a fake door (door record requirement); +- the cap evicts LRU entries only when they are idle, unleased and have no + pending writes; +- `close()` closes all entries. + +### F3 — Compass boot (fork) + +Lane: compass-gateway. + +```ts +// packages/coding-agent/src/cli/gateway-boot.ts +export interface GatewayBootConfig { + serviceTokenFile: string; serverUrl: string; caFile?: string; + bind: string; drainMs: number; enrollTokenFile?: string; +} +export function getGatewayBootConfig(env: Readonly>): GatewayBootConfig; +export function bundledGatewayModels(): Map>; // keyed provider/id only +export async function runGatewayBoot(env?: Readonly>): Promise; +// auth-gateway-cli.ts: `export const GATEWAY_MODEL_KINDS` (was module-private) +``` + +`runGatewayBoot` steps: + +1. Read the config. +2. Read the token, the CA and the optional enroll token. +3. `installGlobalProxyFetch()`. +4. Build the compass fetch. +5. `probeCompassDoor`. +6. `startAuthGateway({ bind, authorize, storage: placeholder, resolveStorage, routeScope, resolveModel, listModels, version })`. +7. `registerGatewayShutdown(handle, drainMs, () => pool.close())`. + +`storage` is still required by `AuthGatewayRouteOptions`. The placeholder is +an `AuthStorage` over a store that throws on every call, so any accidental +use answers 500 and logs. Delete the "still requires OMP_AUTH_BROKER_URL" +comment. + +Test cycle: extend `packages/coding-agent/test/cli/gateway-boot.test.ts`. + +- Each fail-before-bind rule throws before any listen. +- The model map holds only routable kinds and no bare ids. +- An end-to-end boot against a fake Server on loopback `http`: + - `/healthz` → 200; + - a good token → `/v1/models` 200; + - a bad token → 401; + - the Server answering `internal` → 503; + - a bare model id → 404; + - a chat request for a provider absent from the pool → 401. +- The fork gateway suite stays green. + +### S1 — Service bearer ensure (stack) + +Lane: compass-server (owner of `go/internal/stack/adapters`; reviewer of +S1, S2 and P1). + +```go +// go/internal/runnerhub/mint.go +func TokenStatus(ctx context.Context, r TokenHashResolver, token string, want store.Subject) (TokenState, store.Subject, error) +// RunnerTokenStatus becomes TokenStatus(ctx, r, token, store.Subject{Kind: store.SubjectRunner, ID: runnerID}) +// go/internal/stack/deps.go +type ServiceTokenEnsurer interface { + EnsureServiceToken(ctx context.Context, path, serviceID string) error +} +// Deps gains: ServiceTokens ServiceTokenEnsurer +// go/internal/stack/adapters/token.go +func (e *TokenEnsurer) EnsureServiceToken(ctx context.Context, path, serviceID string) error +``` + +`TokenStatus` compares only `Kind` and `ID`. `ensure` takes the want subject, +a keep-revoked flag and a register func. The runner keeps +`StoreRunnerTokenHash`; the service path calls `PutTokenHash` with +`sha256(token)`. `cmd/compass-stack/main.go` wires one +`adapters.NewTokenEnsurer(cfg.DatabaseDSN)` into both fields. + +Test cycle: `token_test.go` against the fake store: + +- absent → mint, file first; +- registered → kept; +- unknown → re-registered; +- revoked → rotated; +- other subject → rotated; +- hash commit fails → file removed; +- mode 0600; +- the runner cases stay green. + +### S2 — Gateway spec, network and order (stack) + +Lane: compass-server. Depends on S1. + +```go +// go/internal/stack/gateway_container.go +const ( + GatewayServiceTokenMountPath = "/run/compass/service.token" + GatewayCAMountPath = "/run/compass/server-ca.crt" +) +type GatewayContainerSpec struct { /* existing fields */ ServerURL string; NetworkArgs []string; CAFile string } +func gatewayContainerSpec(cfg Config, listenAddr string, cert CertResult) (GatewayContainerSpec, error) +``` + +- **Spec.** `TokenFile` becomes `/gateway/service.token`. Env as in + the table. A `listenAddr` whose host fails `overlapsLoopbackPublish` errors. +- **Run args.** `gatewayRunArgs` appends `NetworkArgs` + (`--network pasta:-T,` on Linux) and mounts the token at + `GatewayServiceTokenMountPath` and the CA at `GatewayCAMountPath` (`:ro,z`; + the host server reads the same CA file). +- **Start.** `Stack.startGateway` removes the old `gateway.token`, then calls + `ServiceTokens.EnsureServiceToken(ctx, spec.TokenFile, auth.LLMGatewayServiceID)`, + then `GatewayContainer.Start`. `ensureGatewayToken` is deleted. +- **Order.** `spawnChain` moves the gateway after `waitReady`; + `drainChildren` order becomes runner, gateway, server. + +Test cycle: + +- `gateway_container_test.go`: spec env, mounts and run args pinned; a + non-loopback door host errors; `0.0.0.0` is accepted. +- Harness: server ready before gateway start; gateway drained before server; + service token ensured and old file removed before the container starts. +- `downdetached` tests stay green. + +### I1 — Fork pin, image env and smoke (image lane) + +Lane: compass-gateway. + +- Bump `tools/gateway-image/fork-pin.json` from `57f41121ff5e…` to the + `rigel-release` commit holding F1–F3. +- Drop the `COMPASS_GATEWAY_TOKEN_FILE` `ENV` and its allowlist entry. +- Rewrite `smoke.ts`: + - drop the broker; + - run a Bun fake Server on host loopback `http` that answers + `VerifyAgentToken` (`invalid agent token` for `""`) and an empty + `ListCredentialPool`; + - boot with `--network pasta:-T,`; + - assert `/healthz` 200, `/v1/models` with the good token `modelsListOk`, + and a bad token 401; + - fault `no-server-env` (replaces `no-broker-env`) exits before `/healthz` + answers. + +Test cycle: `core.test.ts`, plus the lane's `smoke` task on local podman. + +### P1 — Default image pin (stack) + +Lane: compass-server. Depends on S2, I1's digest and RIG-5204. + +Set `DefaultGatewayImage` (`go/internal/stack/gateway_image.go`) to I1's +`repo@sha256:…`. + +Test cycle: one stack podman test that boots the bundled gateway with no +`--gateway-external` and gets 200 on `/v1/models`. Its agent token is minted +through `auth.NewGatewayTokens(st).Mint` directly. No production code calls +`Mint` today; it is used only by tests. So until T5, no real agent sends +traffic through the gateway, and this test is not end-to-end proof. + +## Tasks + +- [ ] S0 — compass-server: Go test pins `invalid agent token` as the + `VerifyAgentToken` wire contract. +- [ ] S1 — compass-server: `TokenStatus` over `store.Subject`; + `EnsureServiceToken` for `llm-gateway`. +- [ ] S2 — compass-server: `service.token` + delete `gateway.token`; env, + mounts, `pasta:-T`; loopback-covering door host; gateway after server + ready; drain gateway before server. +- [ ] F1 — compass-gateway: `createCompassAgentVerifier` + + `probeCompassDoor`. +- [ ] F2 — compass-gateway: `CompassOwnerPool` (single-flight open, leases, + `hasPendingWrites` pin, idle sweep, soft cap, close) + stream and + parked-write tests. +- [ ] F3 — compass-gateway: compass `runGatewayBoot` (env contract, door + probe, `provider/id` catalog, throwing placeholder, CA-scoped fetch). +- [ ] I1 — compass-gateway: fork pin bump, image env cleanup, broker-free + smoke. +- [ ] P1 — compass-server: pin `DefaultGatewayImage`. +- [ ] Ledger: claim these with `bun tools/dl-claim` when this record's PR is + ready. The design pass does not claim them. + - DL (server): the stack's `llm-gateway` service bearer is + `/gateway/service.token`. The compass boot admits agents only + through `GatewayAuth.VerifyAgentToken`, with no inbound shared token. + Record: `#approach`. + - DL (server): the compass boot serves the bundled catalog by + `provider/id` until stable-name P1. Record: `#approach`. Claim after + Matt rules on OQ1. + +## Open Questions + +**OQ1 (load-bearing): catalog under per-owner pools.** + +- **A (recommended):** every bundled provider, indexed by `provider/id`, with + no credential gating. A missing provider gets a dispatch 401. +- **B:** a per-owner `ModelRegistry`. Needs a caller-aware `ModelResolver`, a + router-core change. +- **C:** a union over live pools. It leaks between owners and is + nondeterministic. +- **D:** A, limited to the parent record's day-1 set ("anthropic (OAuth + + api_key), openai/openai-codex (OAuth + api_key), google (api_key)"). This + bounds `/v1/models`, but it turns a new provider into a code change. Parent + RD-6 says "Additional providers are configuration, not code". +- **E:** read the Server registry now (`GetGatewayModelRegistry` is mounted, + `go/server/gateway_registry_service.go`). Stable-name P1 owns that swap and + plans an async, caller-aware `resolveModel`. Today `ModelResolver` is sync, + so E would need a boot-time fetch, a version-poll timer and a + registry-to-`Model` mapping. That duplicates P1 before P1 lands. + +**Recommendation: A.** Decision needed: accept A (or D) as the interim +catalog. + +**OQ2 (load-bearing): drop the inbound shared bearer.** The record admits no +shared caller. The service bearer moves to `service.token` and +`COMPASS_SERVICE_TOKEN_FILE`, and `gateway.token` is deleted. This reverses +the T1 contract ("token file = inbound bearer"). + +**Recommendation: drop it.** Decision needed: confirm there is no operator or +debug use for a shared inbound token. + +**OQ7 (load-bearing): reaching the Server on darwin.** On darwin, pasta runs +in the podman-machine VM, so `-T` would reach the VM's loopback, not the Mac +[INFERENCE; not probed, no Mac host]. + +- **A:** `https://host.containers.internal:` on both OSes, with Linux + adding `--network pasta:--map-host-loopback,169.254.1.2` (probed: + reaches the host). Costs: + - a DNS SAN `host.containers.internal` in `certHosts`; + - a SAN check in `needsRotation`, which today checks only the key, parsing + and expiry, so existing anchors rotate once. +- **B:** per OS: `-T` and `127.0.0.1` on Linux, `host.containers.internal` on + darwin. This still needs A's SAN and adds a second path to test. +- **C:** `-T` everywhere. The bundled gateway is unsupported on darwin, and + the spec errors there, pointing to `--gateway-external`. + +**Recommendation: A, if a darwin probe shows `host.containers.internal` +reaching a Mac `127.0.0.1` listener; otherwise C for now.** The door probe +(Approach) makes any wrong choice fail `up` loudly instead of failing every +request. Decision needed: A or C, and who runs the darwin probe before S2. + +**OQ3 (not load-bearing): verifier cache.** None for now. Revisit after dev +measures VerifyAgentToken load, alongside the door-call rate (door record +Q1). + +**OQ4 (resolved by design): the 401 vs 503 split depends on a message.** No +Go test pinned the literal. `TestVerifyAgentTokenInvalidIsUnauthenticated` +checks only the code, plus equal text across tokens. S0 pins the literal. +ErrorInfo was rejected (Alternatives). + +**OQ5 (not load-bearing): enroll token supply.** No compass or fork code reads +or writes `COMPASS_GATEWAY_ENROLL_TOKEN_FILE`. Minting and mounting it belong +to E2/E3 of `compass-gateway-oauth-enrollment`. + +**OQ6 (not load-bearing): detached down.** `drainTargets` signals the whole +consumer tier (runner, server, gateway) at once. A write-back during the +gateway's drain can park and be lost. The door record accepts this residual. From f96812a0be9175d333abacb4224857ba36a27294 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 11 Oct 2026 20:41:15 -0400 Subject: [PATCH 02/13] docs(server): fix boot-wiring review findings (RIG-2862) Restrict the door listen host to addresses the fixed loopback URL reaches, drop the managed-plane pointer, and quote the source behind each code claim. Co-authored-by: Matt Wilkinson --- .../server/compass-gateway-boot-wiring.md | 92 +++++++++++-------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index 899ece1a3..21476a9d6 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -8,9 +8,11 @@ to the §Global Constraints "Compass boot entrypoint" bullet of [`compass-gateway-credential-door-agent-token`](./compass-gateway-credential-door-agent-token.md). The fork's compass boot entrypoint (`packages/coding-agent/src/cli/gateway-boot.ts` -in RigelBuild/oh-my-pi) calls `runAuthGatewayCommand({ action: "serve" })`, -whose `runServe` throws without `OMP_AUTH_BROKER_URL`. So the published image -cannot serve under the stack. This record wires that entrypoint to the Compass +in RigelBuild/oh-my-pi) calls `runAuthGatewayCommand({ action: "serve" })`. +Its `runServe` (`packages/coding-agent/src/cli/auth-gateway-cli.ts`) throws +without a broker, under `if (!brokerConfig) {`: +`` "`omp auth-gateway serve` requires OMP_AUTH_BROKER_URL …" ``. So the +published image cannot serve under the stack. This record wires that entrypoint to the Compass Server's doors: agent tokens are verified by `GatewayAuth.VerifyAgentToken`, each owner gets its own `AuthStorage` over `CompassAuthCredentialStore`, and the stack mints, registers and mounts the `llm-gateway` service bearer those @@ -28,8 +30,7 @@ spec that gives the boot what it needs. `packages/coding-agent/src/cli/auth-gateway-cli.ts` stays the broker path for upstream `omp auth-gateway serve`. The boot reuses `registerGatewayShutdown` and the `GATEWAY_MODEL_KINDS` list (exported by this change). Two new modules -go in `packages/ai/src/compass/`, next to the store, so the managed plane can -reuse them: +go in `packages/ai/src/compass/`, next to the store, as gateway adapters: - **`agent-verifier.ts`: the authorizer.** It sends the presented bearer to `POST /compass.v1.GatewayAuth/VerifyAgentToken`, using @@ -41,11 +42,13 @@ reuse them: - 200 → `{ kind: "agent", agentAccountId, ownerUserId }`. An empty id throws. - `unauthenticated` with message `invalid agent token` → `null`. That - message is `errGatewayTokenInvalid` in `go/server/gateway_auth.go`; S0 + message is `errGatewayTokenInvalid` in `go/server/gateway_auth.go` + (`var errGatewayTokenInvalid = errors.New("invalid agent token")`); S0 pins it as a wire contract (OQ4). - Any other `unauthenticated` → throw (503). A bad service bearer gets - `errServiceUnauthenticated` ("unauthenticated") from - `ServiceBearerInterceptor` (`go/internal/auth/service_door.go`). + `errServiceUnauthenticated` from `ServiceBearerInterceptor` + (`go/internal/auth/service_door.go`: + `connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated"))`). Mapped to 401, a broken gateway would look like a bad agent token. - `internal`, `unavailable`, a timeout or a transport error → throw. - The deadline is `AbortSignal.any([req.signal, AbortSignal.timeout(5_000)])`. @@ -62,14 +65,18 @@ reuse them: 3. `storage.credentials.reload()`. Concurrent first requests share one open, and a failed open is not - cached. The open runs inside `routeScope`, because on `rigel-release` - `routeScope` wraps the router `body`, `resolveStorage` included. So - `open()` sees the agent token. - - **Per-request leases.** A stream outlives `route`: Bun idles a response up - to 255 s (`idleTimeout: 255` in `startAuthGateway`). A mid-stream OAuth - refresh then writes back through the store. A store closed under it throws - `closed` (`#request` in `credential-store.ts`), and the rotated refresh - token is lost. So: + cached. The open runs inside `routeScope`. On `rigel-release`, + `createAuthGatewayRouter` (`packages/ai/src/auth-gateway/server.ts`) wraps + the router body, `resolveStorage` included: + `return opts.routeScope ? await opts.routeScope(req, body) : await body();`. + So `open()` sees the agent token. + - **Per-request leases.** A stream outlives `route`. Bun idles a response up + to 255 s (`startAuthGateway` in `server.ts`: `idleTimeout: 255,`). A + mid-stream OAuth refresh then writes back through the store. A store + closed under it throws (`#request` in + `packages/ai/src/compass/credential-store.ts`: + `if (this.#closed) throw new CompassRpcError("closed", "Compass credential store is closed");`), + and the rotated refresh token is lost. So: - `routeScope` runs each routed request in a lease scope. - Each `get` inside it takes a lease on the entry. - The lease ends when the response body ends, errors or is cancelled. The @@ -98,12 +105,12 @@ reuse them: boot builds one map from `getBundledProviders()` and `getBundledModels()` (`@oh-my-pi/pi-catalog`), keeping kinds in `GATEWAY_MODEL_KINDS`, keyed `` `${provider}/${id}` `` only. - - **No bare-id fallback.** `indexModelsByRequestId` adds one (`if - (!modelById.has(model.id)) modelById.set(model.id, model);`). Across every - bundled provider, that fallback would route a bare id to the first - provider in catalog order, which the owner may hold no credential for. - A bare id gets the router's 404 `Unknown model` instead (`resolveModel` - callers in `server.ts`). + - **No bare-id fallback.** `indexModelsByRequestId` (`auth-gateway-cli.ts`) + adds one: `if (!modelById.has(model.id)) modelById.set(model.id, model);`. + Across every bundled provider, that fallback would route a bare id to the + first provider in catalog order, which the owner may hold no credential + for. A bare id gets the router's 404 instead (`server.ts`: + `` formatError(404, "invalid_request_error", `Unknown model: ${modelId}`) ``). - A model whose provider is not in the owner's pool fails in dispatch with 401 `No credential available for provider X` (`resolveGatewayApiKey`). - `pickProbeCandidates` already sends `getBundledModels` output straight to @@ -127,7 +134,8 @@ reuse them: expects `unauthenticated` with the message `invalid agent token`. On the Server, `GatewayTokens.Verify` (`go/internal/auth/gateway_token.go`) returns `ErrGatewayTokenInvalid` for a malformed token before any store read - (`if !wellFormedToken(token)`). Any other result exits non-zero. The probe proves network + (`if !wellFormedToken(token) { return store.GatewayCaller{}, ErrGatewayTokenInvalid }`). + Any other result exits non-zero. The probe proves network reach, TLS and a live service bearer. It reads no credentials, so door rule 1 ("no door call with no request behind it") still holds. After the bounded restarts the container stays down, and `waitGateway` fails `up` @@ -138,15 +146,16 @@ reuse them: - **The service bearer.** Its file is `/gateway/service.token`, mounted at `/run/compass/service.token` and named by `COMPASS_SERVICE_TOKEN_FILE`. S1 deletes the old `/gateway/gateway.token`. - The rename keeps the old hex inbound secret from being adopted through the - "unknown → re-register" arm. It also makes version skew fail before bind: + The rename keeps the old hex inbound secret (`ensureGatewayToken`: + `os.WriteFile(path, []byte(hex.EncodeToString(raw[:])), 0o600)`) from being + adopted through the "unknown → re-register" arm. It also makes version skew fail before bind: an old image reads the missing `gateway.token`, and a new image finds no `COMPASS_SERVER_URL`. `EnsureServiceToken` uses the file-before-store shape of `ensure` in - `adapters/token.go`. S1 makes the status check generic over - `store.Subject`; `RunnerTokenStatus` hardcodes `subj.Kind == - store.SubjectRunner && subj.ID == runnerID`. The want is + `go/internal/stack/adapters/token.go`. S1 makes the status check generic + over `store.Subject`. `RunnerTokenStatus` (`go/internal/runnerhub/mint.go`) + hardcodes `if subj.Kind == store.SubjectRunner && subj.ID == runnerID {`. The want is `{Kind: store.SubjectService, ID: auth.LLMGatewayServiceID}`: - **Registered under that subject:** keep the token. - **Unknown** (database replaced): re-register the same token. @@ -167,9 +176,11 @@ reuse them: - **Server address (Linux).** The container runs with `--network pasta:-T,` and dials `https://127.0.0.1:`. The cert's `127.0.0.1` SAN matches - (`certHosts` in `adapters/cert.go`). By default pasta does not forward - container loopback to the host (`pastaHostGateway` comment in - `go/e2e/fixture.go`). Local rootless podman 5.8.6 probes showed: + (`go/internal/stack/adapters/cert.go`: + `var certHosts = []string{"127.0.0.1", "::1", "localhost"}`). By default pasta + does not forward container loopback to the host (`pastaHostGateway` comment + in `go/e2e/fixture.go`: "pasta does not forward 127.0.0.1"). Local rootless + podman 5.8.6 probes showed: - the default network is refused; - `-T` reaches the host listener, and `-p` publishing still works. @@ -179,6 +190,12 @@ reuse them: There `-T` would reach the VM's loopback [INFERENCE]: OQ7. The spec carries the URL and network args as fields, so the OQ7 choice changes only S2 and `certHosts`. + + The door host must be one that `-T` forwards to the fixed + `127.0.0.1` URL. `overlapsLoopbackPublish` (`go/internal/stack/config.go`) is + too wide for this, because it also passes every DNS name: + `return ip == nil || ip.IsUnspecified() || ip.Equal(net.IPv4(127, 0, 0, 1))`. + S2 therefore adds its own check (Global Constraints, Door listen host). - **Start order.** Today `spawnChain` (`stack.go`) starts the gateway at step 1e, before the TLS anchor (step 2) and the server (step 3). On a first run that leaves no CA file and no store to register the token in. The gateway @@ -248,9 +265,11 @@ reuse them: | `COMPASS_GATEWAY_DRAIN_MS` | `20000` | stack, existing | | `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` | optional | E2/E3 lane, later | -- **Door listen host.** S2 accepts any `ListenAddr` host for which - `overlapsLoopbackPublish` (`config.go`) is true: `127.0.0.1`, an - unspecified address, or a hostname. Any other host errors in the spec. +- **Door listen host.** S2 accepts only a `ListenAddr` host of `127.0.0.1`, + `localhost`, or an unspecified IP (`0.0.0.0`, `::`). Each one accepts on + `127.0.0.1`, which is the URL the gateway dials. Any other host, including + any other DNS name, errors in the spec and names `--gateway-external`. + Option A of OQ7 changes the URL, not this host rule. - **Order.** Each line below is linear. - Compass: S0 → S1 → S2 → P1. S2 calls `EnsureServiceToken`. - Fork: #146 rebased → F1 → F2 → F3. F1 has no code dependency on #146. @@ -444,7 +463,7 @@ func gatewayContainerSpec(cfg Config, listenAddr string, cert CertResult) (Gatew ``` - **Spec.** `TokenFile` becomes `/gateway/service.token`. Env as in - the table. A `listenAddr` whose host fails `overlapsLoopbackPublish` errors. + the table. A `listenAddr` host outside the Door listen host set errors. - **Run args.** `gatewayRunArgs` appends `NetworkArgs` (`--network pasta:-T,` on Linux) and mounts the token at `GatewayServiceTokenMountPath` and the CA at `GatewayCAMountPath` (`:ro,z`; @@ -458,7 +477,8 @@ func gatewayContainerSpec(cfg Config, listenAddr string, cert CertResult) (Gatew Test cycle: - `gateway_container_test.go`: spec env, mounts and run args pinned; a - non-loopback door host errors; `0.0.0.0` is accepted. + DNS hostname (`compass.example`) and a non-loopback IP both error; + `127.0.0.1`, `localhost`, `0.0.0.0` and `::` are accepted. - Harness: server ready before gateway start; gateway drained before server; service token ensured and old file removed before the container starts. - `downdetached` tests stay green. From 2f1f1f1ee2cd54146923bb39255af0d450e002b0 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 11 Oct 2026 21:22:19 -0400 Subject: [PATCH 03/13] docs(server): fold boot-wiring review round 2 (RIG-2862) Create the gateway dir before the service-token ensure, match F1's scope to the shared wire transport, scope the listener to host loopback with TLS deferred to T5, add the two-owner isolation test, and cut execution detail. Co-authored-by: Matt Wilkinson --- .../server/compass-gateway-boot-wiring.md | 815 +++++++----------- 1 file changed, 321 insertions(+), 494 deletions(-) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index 21476a9d6..1bc27b4ae 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -9,343 +9,261 @@ to the §Global Constraints "Compass boot entrypoint" bullet of The fork's compass boot entrypoint (`packages/coding-agent/src/cli/gateway-boot.ts` in RigelBuild/oh-my-pi) calls `runAuthGatewayCommand({ action: "serve" })`. -Its `runServe` (`packages/coding-agent/src/cli/auth-gateway-cli.ts`) throws -without a broker, under `if (!brokerConfig) {`: +`runServe` (`packages/coding-agent/src/cli/auth-gateway-cli.ts`) throws under +`if (!brokerConfig) {` with `` "`omp auth-gateway serve` requires OMP_AUTH_BROKER_URL …" ``. So the -published image cannot serve under the stack. This record wires that entrypoint to the Compass -Server's doors: agent tokens are verified by `GatewayAuth.VerifyAgentToken`, -each owner gets its own `AuthStorage` over `CompassAuthCredentialStore`, and -the stack mints, registers and mounts the `llm-gateway` service bearer those -door calls need. +published image cannot serve under the stack. This record wires the +entrypoint to the Compass Server's doors: -## Approach +- agent tokens are verified by `GatewayAuth.VerifyAgentToken`; +- each owner gets its own `AuthStorage` over `CompassAuthCredentialStore`; +- the stack mints, registers and mounts the `llm-gateway` service bearer that + those door calls need. -The work has two halves: a fork boot that talks to the Server, and a stack -spec that gives the boot what it needs. +## Approach ### Fork: a compass boot, not a reshaped `runServe` -`gateway-boot.ts` stops calling `runAuthGatewayCommand`. It calls -`startAuthGateway` itself with compass adapters. `runServe` in -`packages/coding-agent/src/cli/auth-gateway-cli.ts` stays the broker path for -upstream `omp auth-gateway serve`. The boot reuses `registerGatewayShutdown` -and the `GATEWAY_MODEL_KINDS` list (exported by this change). Two new modules -go in `packages/ai/src/compass/`, next to the store, as gateway adapters: - -- **`agent-verifier.ts`: the authorizer.** It sends the presented bearer to - `POST /compass.v1.GatewayAuth/VerifyAgentToken`, using - the same Connect JSON transport and service bearer as `#request` in - `credential-store.ts`. The `AuthGatewayAuthorizer` doc in - `auth-gateway/types.ts` sets the result mapping: "`null` to answer 401. A - throw answers 503 … bound any remote call; a timeout should throw." - - No bearer → `null`, with no call made. +`gateway-boot.ts` calls `startAuthGateway` itself with compass adapters. +`runServe` stays the broker path for upstream `omp auth-gateway serve`. The +boot reuses `registerGatewayShutdown` and `GATEWAY_MODEL_KINDS`, which this +change exports. The gateway adapters live in `packages/ai/src/compass/`, next +to the store. + +- **Transport (`wire.ts`).** The verifier and the store share two helpers: + - `normalizeCompassBaseUrl` replaces `assertSecureBaseUrl`. It allows https, + or http on loopback only. + - `fetchCompassRpcResponse` sends Connect JSON with the service bearer and + `redirect: "error"`. +- **`agent-verifier.ts`: the authorizer.** It posts the presented bearer to + `GatewayAuth/VerifyAgentToken`. The mapping follows the + `AuthGatewayAuthorizer` doc (`auth-gateway/types.ts`): "`null` to answer + 401. A throw answers 503 … bound any remote call". + - No bearer → `null`, with no call. - 200 → `{ kind: "agent", agentAccountId, ownerUserId }`. An empty id throws. - - `unauthenticated` with message `invalid agent token` → `null`. That - message is `errGatewayTokenInvalid` in `go/server/gateway_auth.go` - (`var errGatewayTokenInvalid = errors.New("invalid agent token")`); S0 - pins it as a wire contract (OQ4). - - Any other `unauthenticated` → throw (503). A bad service bearer gets - `errServiceUnauthenticated` from `ServiceBearerInterceptor` - (`go/internal/auth/service_door.go`: + - `unauthenticated` with message `invalid agent token` → `null`. The + message is `errGatewayTokenInvalid` (`go/server/gateway_auth.go`: + `var errGatewayTokenInvalid = errors.New("invalid agent token")`), and + S0 pins it. + - Any other result or error → throw (503). A bad service bearer gets + `ServiceBearerInterceptor`'s message (`go/internal/auth/service_door.go`: `connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated"))`). Mapped to 401, a broken gateway would look like a bad agent token. - - `internal`, `unavailable`, a timeout or a transport error → throw. - The deadline is `AbortSignal.any([req.signal, AbortSignal.timeout(5_000)])`. - - **No cache.** One loopback call per admitted request, so a revoked token - fails at once (OQ3). -- **`owner-pool.ts`: the per-owner store pool.** `resolveStorage(caller)` - calls `pool.get(caller.ownerUserId)`. A missing `ownerUserId` throws, which - the router answers with 503 `credential pool unavailable` - (`createAuthGatewayRouter` in `server.ts`). - - **Lazy, single-flight open.** On first use the pool opens, per owner: - 1. `CompassAuthCredentialStore.open({ baseUrl, token, fetch })`, with no - `agentAccountId`; - 2. `new AuthStorage(store, { sourceLabel: "compass" })`; - 3. `storage.credentials.reload()`. - - Concurrent first requests share one open, and a failed open is not - cached. The open runs inside `routeScope`. On `rigel-release`, - `createAuthGatewayRouter` (`packages/ai/src/auth-gateway/server.ts`) wraps - the router body, `resolveStorage` included: + There is no cache (OQ3). +- **`owner-pool.ts`: one `AuthStorage` per owner.** + `resolveStorage(caller)` calls `pool.get(caller.ownerUserId)`. A missing + owner throws, which the router answers with 503. + - **Open.** On first use the pool opens, per owner: + 1. `CompassAuthCredentialStore.open(...)`, with no `agentAccountId`; + 2. `new AuthStorage(store)`; + 3. `credentials.reload()`. + + The open is single-flight, and a failure is not cached. It runs inside + `routeScope`, because `createAuthGatewayRouter` + (`packages/ai/src/auth-gateway/server.ts`) wraps the whole body: `return opts.routeScope ? await opts.routeScope(req, body) : await body();`. So `open()` sees the agent token. - - **Per-request leases.** A stream outlives `route`. Bun idles a response up - to 255 s (`startAuthGateway` in `server.ts`: `idleTimeout: 255,`). A - mid-stream OAuth refresh then writes back through the store. A store - closed under it throws (`#request` in - `packages/ai/src/compass/credential-store.ts`: - `if (this.#closed) throw new CompassRpcError("closed", "Compass credential store is closed");`), - and the rotated refresh token is lost. So: - - `routeScope` runs each routed request in a lease scope. - - Each `get` inside it takes a lease on the entry. - - The lease ends when the response body ends, errors or is cancelled. The - boot wraps the body: a `TransformStream` `flush`/`cancel` pair, or a - `ReadableStream` wrapper if Bun lacks the transformer `cancel` hook. - - A body-less result or a throw ends the lease at once. - - **Eviction.** Each `get` stamps `lastUsedMs`. A sweep (60 s timer, - `unref`) closes entries that meet all of: - - `leases === 0`; - - `store.hasPendingWrites()` is false; - - idle longer than `idleMs` (15 min). - - Over `maxOwners` (256), the sweep also closes least-recently-used entries - that are idle longer than 60 s and pass the same two checks. The cap is - soft. The `hasPendingWrites()` pin is the door record's rule. - - **`close()` on shutdown** closes every entry, pinned or leased. A parked - write is lost at exit. The door record accepts this residual. - - **No credential-poll timer** (door record: "A credential-poll timer is not - needed"). -- **`routeScope`.** This is the door record's line, plus the lease scope: + - **Leases.** A response stream outlives `route`: `startAuthGateway` sets + `idleTimeout: 255,`. A mid-stream OAuth refresh writes back through the + store, and a closed store throws (`#request` in `credential-store.ts`: + `if (this.#closed) throw new CompassRpcError("closed", "Compass credential store is closed");`). + The rotated refresh token would then be lost. So `routeScope` runs each + request in a lease scope, and every `get` inside it leases the entry. + The lease ends when the body ends, errors or is cancelled. A body-less + result or a throw ends it at once. + - **Eviction.** A 60 s sweep closes an entry only when all of these hold: + - it has no leases; + - `store.hasPendingWrites()` is false (the door record's pin); + - it has been idle 15 min. + + Over 256 owners, the sweep also closes LRU entries idle more than 60 s + that pass the first two checks. `close()` at shutdown closes every + entry; a parked write is lost then, a residual the door record accepts. + There is no credential-poll timer. +- **`routeScope`.** The door record's line, plus the lease: `(req, run) => { const t = presentedBearer(req); const leased = () => pool.withRequestLease(run); return t ? runWithAgentToken(t, leased) : leased(); }`. -- **Catalog: the bundled catalog, by `provider/id` only (OQ1).** - `ModelResolver` is `(modelId) => Model | undefined` (`dispatch.ts`). It - never sees the caller, and `/v1/models` "skips the pool lookup". So no - per-owner catalog is possible without a router-core change. At start the - boot builds one map from `getBundledProviders()` and `getBundledModels()` - (`@oh-my-pi/pi-catalog`), keeping kinds in `GATEWAY_MODEL_KINDS`, keyed - `` `${provider}/${id}` `` only. - - **No bare-id fallback.** `indexModelsByRequestId` (`auth-gateway-cli.ts`) - adds one: `if (!modelById.has(model.id)) modelById.set(model.id, model);`. - Across every bundled provider, that fallback would route a bare id to the - first provider in catalog order, which the owner may hold no credential - for. A bare id gets the router's 404 instead (`server.ts`: - `` formatError(404, "invalid_request_error", `Unknown model: ${modelId}`) ``). - - A model whose provider is not in the owner's pool fails in dispatch with - 401 `No credential available for provider X` (`resolveGatewayApiKey`). - - `pickProbeCandidates` already sends `getBundledModels` output straight to - providers, so bundled `Model` objects are callable without - `ModelRegistry`. - - There is no refresh timer. Stable-name P1 replaces this resolver. -- **Authorizer composition.** `authorize = verifier`. When - `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` is set, it becomes - `withEnrollToken(enrollToken, verifier)` (OQ5). -- **No inbound shared bearer.** `bearerTokenAuthorizer` is not used. Agents - carry per-agent tokens (parent T3/T5), and no Go code reads - `gateway.token` except the stack mount (`gatewayContainerSpec`) (OQ2). -- **TLS to the door.** The store and the verifier share one compass fetch: - `(input, init) => fetch(input, { ...init, tls: { ca } })`, with `ca` read - from `COMPASS_SERVER_CA_FILE`. Provider calls keep the default trust store. - `NODE_EXTRA_CA_CERTS` is not used: `wrapFetchForExtraCa` - (`packages/utils/src/tls-fetch.ts`) would apply the CA to every fetch. -- **Fail-fast door probe.** `/healthz` answers 200 with no checks - (`startAuthGateway`), so `waitGateway` passes even when every door call - fails. Before bind, the boot calls `VerifyAgentToken` with `token: ""`. It - expects `unauthenticated` with the message `invalid agent token`. On the - Server, `GatewayTokens.Verify` (`go/internal/auth/gateway_token.go`) returns - `ErrGatewayTokenInvalid` for a malformed token before any store read - (`if !wellFormedToken(token) { return store.GatewayCaller{}, ErrGatewayTokenInvalid }`). - Any other result exits non-zero. The probe proves network - reach, TLS and a live service bearer. It reads no credentials, so door - rule 1 ("no door call with no request behind it") still holds. After the - bounded restarts the container stays down, and `waitGateway` fails `up` - loudly. +- **Catalog: bundled, `provider/id` keys only (OQ1).** The `ModelResolver` + type `(modelId) => Model | undefined` never sees the caller, and + `/v1/models` skips the pool. The boot indexes `getBundledModels()` for + every bundled provider, keeping `GATEWAY_MODEL_KINDS` only. + - **No bare ids.** `indexModelsByRequestId` would add a bare-id fallback: + `if (!modelById.has(model.id)) modelById.set(model.id, model);`. Over all + providers, that routes a shared bare id to whichever provider comes + first. A bare id gets the router's + `` formatError(404, "invalid_request_error", `Unknown model: ${modelId}`) ``. + - A provider missing from the owner's pool gets a dispatch 401 + (`resolveGatewayApiKey`). +- **Inbound auth.** `authorize` is the verifier, wrapped in + `withEnrollToken` when `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` is set (OQ5). + `bearerTokenAuthorizer` is not used, and no shared inbound token remains + (OQ2). +- **Outbound TLS.** The store and the verifier share one fetch, + `fetch(input, { ...init, tls: { ca } })`, with `ca` read from + `COMPASS_SERVER_CA_FILE`. Provider calls keep the default trust store. +- **Door probe before bind.** `/healthz` answers 200 with no checks, so + `waitGateway` cannot see a broken door. The boot calls `VerifyAgentToken` + with `""` and exits non-zero unless the answer is `invalid agent token`. + `GatewayTokens.Verify` (`go/internal/auth/gateway_token.go`) returns that + before any store read: + `if !wellFormedToken(token) { return store.GatewayCaller{}, ErrGatewayTokenInvalid }`. + The probe proves reachability, TLS and a live service bearer, and it reads + no credentials. ### Stack: service bearer, server address, start order -- **The service bearer.** Its file is `/gateway/service.token`, - mounted at `/run/compass/service.token` and named by - `COMPASS_SERVICE_TOKEN_FILE`. S1 deletes the old `/gateway/gateway.token`. - The rename keeps the old hex inbound secret (`ensureGatewayToken`: - `os.WriteFile(path, []byte(hex.EncodeToString(raw[:])), 0o600)`) from being - adopted through the "unknown → re-register" arm. It also makes version skew fail before bind: - an old image reads the missing `gateway.token`, and a new image finds no - `COMPASS_SERVER_URL`. - - `EnsureServiceToken` uses the file-before-store shape of `ensure` in - `go/internal/stack/adapters/token.go`. S1 makes the status check generic - over `store.Subject`. `RunnerTokenStatus` (`go/internal/runnerhub/mint.go`) - hardcodes `if subj.Kind == store.SubjectRunner && subj.ID == runnerID {`. The want is - `{Kind: store.SubjectService, ID: auth.LLMGatewayServiceID}`: - - **Registered under that subject:** keep the token. - - **Unknown** (database replaced): re-register the same token. - - **Revoked, or another subject:** mint a fresh one. The runner instead - keeps a revoked token, but no operator CLI mints service tokens. - - **Minting:** `runnerhub.GenerateRunnerToken`, then write the file at 0600 - atomically, then `PutTokenHash`. If the hash commit fails, remove the - file. - - `ensureGatewayToken` (`adapters/gateway_container.go`) is deleted. +- **Service bearer.** `/gateway/service.token` (0600), mounted as + `COMPASS_SERVICE_TOKEN_FILE`. S2 deletes the old `gateway.token`, which + `ensureGatewayToken` filled with an unregistered hex secret: + `os.WriteFile(path, []byte(hex.EncodeToString(raw[:])), 0o600)`. A new + name means skew fails before bind, never by adopting that secret. S1 makes + the token status check generic over `store.Subject`, because + `RunnerTokenStatus` (`go/internal/runnerhub/mint.go`) hardcodes + `if subj.Kind == store.SubjectRunner && subj.ID == runnerID {`. The rules + for subject `{SubjectService, "llm-gateway"}`: + - registered → keep; + - unknown → re-register; + - revoked or another subject → mint a fresh one, file first, then + `PutTokenHash`, and remove the file if the commit fails. Residuals: - - a revoke while the stack runs means 503 on every request until the next - `up`; - - with `ExternalDatabase` and a wiped state dir, each `up` leaves the old - `llm-gateway` hash live, with no revoke-by-subject. The runner path has - the same flaw. + - a revoke mid-run means 503 until the next `up`; + - an external database plus a wiped state dir leaves old hashes live, as on + the runner path. - **Server address (Linux).** The container runs with - `--network pasta:-T,` and dials - `https://127.0.0.1:`. The cert's `127.0.0.1` SAN matches - (`go/internal/stack/adapters/cert.go`: - `var certHosts = []string{"127.0.0.1", "::1", "localhost"}`). By default pasta - does not forward container loopback to the host (`pastaHostGateway` comment - in `go/e2e/fixture.go`: "pasta does not forward 127.0.0.1"). Local rootless - podman 5.8.6 probes showed: - - the default network is refused; - - `-T` reaches the host listener, and `-p` publishing still works. - - `/tls.crt` (0644, `certgen.WriteFiles`) is mounted read-only at - `/run/compass/server-ca.crt`. On darwin the server runs on the Mac and the - container runs in the podman-machine VM (`internal/embedded/machine.go`). - There `-T` would reach the VM's loopback [INFERENCE]: OQ7. The spec carries - the URL and network args as fields, so the OQ7 choice changes only S2 and - `certHosts`. - - The door host must be one that `-T` forwards to the fixed - `127.0.0.1` URL. `overlapsLoopbackPublish` (`go/internal/stack/config.go`) is - too wide for this, because it also passes every DNS name: + `--network pasta:-T,` and dials `https://127.0.0.1:`. + - pasta does not forward container loopback by default (`pastaHostGateway` + in `go/e2e/fixture.go`: "pasta does not forward 127.0.0.1"). + - A local probe on rootless podman 5.8.6 showed that `-T` works, and that + `-p` still publishes. + - The SAN matches (`go/internal/stack/adapters/cert.go`: + `var certHosts = []string{"127.0.0.1", "::1", "localhost"}`). + - `/tls.crt` is mounted read-only as the CA. + + On darwin the container runs in the podman-machine VM + (`internal/embedded/machine.go`), so `-T` would reach the VM's loopback + (OQ7, [INFERENCE]). +- **Door host.** `overlapsLoopbackPublish` (`go/internal/stack/config.go`) + is too wide here, because it also passes every DNS name: `return ip == nil || ip.IsUnspecified() || ip.Equal(net.IPv4(127, 0, 0, 1))`. - S2 therefore adds its own check (Global Constraints, Door listen host). -- **Start order.** Today `spawnChain` (`stack.go`) starts the gateway at step - 1e, before the TLS anchor (step 2) and the server (step 3). On a first run - that leaves no CA file and no store to register the token in. The gateway - moves to after `waitReady` and before `startRunner`, and in-process - `drainChildren` becomes runner → gateway → server. The drain gain is small. - By then the runner and its agents are gone, and once T5 revokes tokens on - stop, a late write-back parks and is lost anyway (OQ6). + S2 has its own rule (Global Constraints). +- **Start order.** `spawnChain` (`stack.go`) starts the gateway at step 1e, + before the TLS anchor and the server. So on a first run there is no CA file + and no store to register the token in. The gateway moves to after + `waitReady` and before `startRunner`. In-process drain becomes runner → + gateway → server. That gain is small: agents are gone by then, and T5 + revokes their tokens on stop (OQ6). ## Alternatives considered -- **Reshape `runServe` with a `--compass` mode.** This mixes broker and - compass wiring in one upstream command. Rejected. -- **Bare-id index over all providers.** Misroutes shared bare ids. Rejected - for `provider/id` only. -- **Keep the shared bearer as a second admitted class (OQ2).** Anyone holding - the file would get a pool-less `"\u0000shared"` caller. Rejected. -- **Reuse `gateway.token` for the service bearer.** It adopts the old inbound - secret, and version skew is silent about which meaning the file has. - Rejected for the rename. -- **`--network host`.** Drops `-p` and network isolation. Rejected. +- **A `--compass` mode in `runServe`.** This mixes broker and compass wiring + in one upstream command. Rejected. +- **A bare-id index over all providers.** It misroutes shared bare ids. + Rejected. +- **Keeping the shared bearer as a second caller class (OQ2).** Its holder + would get a pool-less caller. Rejected. +- **Reusing `gateway.token` for the service bearer.** It would adopt the old + inbound secret, and skew would stay silent. Rejected. +- **`--network host`.** It drops `-p` and network isolation. Rejected. `host.containers.internal` is OQ7 option A. -- **ErrorInfo reason on `VerifyAgentToken`.** The verifier would have to - decode protobuf error details from Connect JSON. Rejected for S0's literal - pin (OQ4). -- **Cache `VerifyAgentToken` results.** A revoked token would keep working. - Deferred (OQ3). +- **An ErrorInfo reason on `VerifyAgentToken`.** The verifier would have to + decode error details from Connect JSON. Rejected for S0's literal pin. +- **A verify cache.** A revoked token would keep working. Deferred (OQ3). ## Global Constraints - **Public repo.** This record and every compass task cite only compass and RigelBuild/oh-my-pi. -- **Fork changes stay seam-shaped.** Fork changes are limited to: - - new files in `packages/ai/src/compass/`; - - the boot (`gateway-boot.ts`); - - one export from `auth-gateway-cli.ts` (`GATEWAY_MODEL_KINDS`). - - No change to `auth-storage.ts`, `packages/ai/src/auth/`, or - `auth-gateway/server.ts` or `dispatch.ts`. -- **Fork base.** `rigel-release` with RIG-5205 (`routeScope`, `d0ac08ac56cc`) - and RIG-5206 (fork #146). #146 must first be rebased onto a `rigel-release` - that contains `d0ac08ac56cc`; its workspace has no `routeScope`. -- **Server dependency.** A default-pinned image needs RIG-5204. On main, - `ListCredentialPoolRequest` still requires `agent_account_id` - (`gateway_credentials.proto`: "empty is INVALID_ARGUMENT"). +- **Fork changes stay seam-shaped.** Allowed: + - `packages/ai/src/compass/` (new modules, plus F1's `wire.ts` helpers and + the matching `credential-store.ts` transport refactor); + - `gateway-boot.ts`; + - one export from `auth-gateway-cli.ts`. + + No change to `auth-storage.ts`, `packages/ai/src/auth/`, `server.ts` or + `dispatch.ts`. +- **Fork base.** `rigel-release` with RIG-5205 (`routeScope`, `d0ac08ac56cc`). + #146 (RIG-5206) is based on it. +- **Server dependency.** A default-pinned image needs RIG-5204, because on main + `ListCredentialPoolRequest` still requires `agent_account_id`. - **Fail before bind.** The boot exits non-zero and never binds when: - - `COMPASS_SERVER_URL` is unset or fails `assertSecureBaseUrl`; - - an https URL has no readable `COMPASS_SERVER_CA_FILE`; - - `COMPASS_SERVICE_TOKEN_FILE` is unset or fails `readGatewayToken`; - - `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` is set but unreadable, or fails - `withEnrollToken`; - - the door probe gets anything but `invalid agent token`. - - The compass values have no defaults. -- **Secrets never logged.** The service bearer, agent tokens and enroll token - never appear in a log line or an error. Only `errorName(error)` reaches - logs. -- **Env contract.** The stack passes these with `-e`, never as image `ENV`. - I1 removes the `COMPASS_GATEWAY_TOKEN_FILE` `ENV` from `Dockerfile.gateway` - and its entry from `SECRET_NAME_ALLOWLIST` (`tools/gateway-image/core.ts`). + - `COMPASS_SERVER_URL` is unset or fails `normalizeCompassBaseUrl`; + - an https URL has no readable CA file; + - the service-token file is unset, empty or holds whitespace (the error + names `COMPASS_SERVICE_TOKEN_FILE`); + - an enroll-token file is set but invalid; + - the door probe fails. +- **Inbound transport is loopback only.** The stack publishes the listener only + on host `127.0.0.1:4100` (`go/internal/stack/gateway_container.go`: + `gatewayHostEndpoint = "127.0.0.1:4100"`). So in this record agent tokens + cross loopback only, and the listener has no TLS. Parent OQ-4 ("TLS via the + stack's existing anchor when bound beyond loopback") applies when T5 exposes + the gateway to agent containers, and T5 owns it. +- **Secrets never logged.** No token appears in a log line or an error; only + `errorName(error)` is logged. +- **Env contract.** Passed with `-e`, never as image `ENV`. I1 drops the + `COMPASS_GATEWAY_TOKEN_FILE` `ENV` and its allowlist entry. | Name | Value | Supplier | |---|---|---| - | `COMPASS_SERVICE_TOKEN_FILE` | `/run/compass/service.token` | stack (S2), new mount | - | `COMPASS_SERVER_URL` | `https://127.0.0.1:` (Linux; darwin: OQ7) | stack (S2) | - | `COMPASS_SERVER_CA_FILE` | `/run/compass/server-ca.crt` | stack (S2), new mount | - | `COMPASS_GATEWAY_BIND` | `0.0.0.0:4000` | stack, existing | - | `COMPASS_GATEWAY_DRAIN_MS` | `20000` | stack, existing | - | `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` | optional | E2/E3 lane, later | - -- **Door listen host.** S2 accepts only a `ListenAddr` host of `127.0.0.1`, - `localhost`, or an unspecified IP (`0.0.0.0`, `::`). Each one accepts on - `127.0.0.1`, which is the URL the gateway dials. Any other host, including - any other DNS name, errors in the spec and names `--gateway-external`. - Option A of OQ7 changes the URL, not this host rule. -- **Order.** Each line below is linear. - - Compass: S0 → S1 → S2 → P1. S2 calls `EnsureServiceToken`. - - Fork: #146 rebased → F1 → F2 → F3. F1 has no code dependency on #146. - - I1 follows F3's merge to `rigel-release`, because `build.ts` checks - ancestry. - - P1 also waits on I1's digest and RIG-5204. - + | `COMPASS_SERVICE_TOKEN_FILE` | `/run/compass/service.token` | S2 | + | `COMPASS_SERVER_URL` | `https://127.0.0.1:` (darwin: OQ7) | S2 | + | `COMPASS_SERVER_CA_FILE` | `/run/compass/server-ca.crt` | S2 | + | `COMPASS_GATEWAY_BIND` | `0.0.0.0:4000` | existing | + | `COMPASS_GATEWAY_DRAIN_MS` | `20000` | existing | + | `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` | optional | E2/E3, later | + +- **Door listen host.** S2 accepts only `127.0.0.1`, `localhost`, `0.0.0.0` or + `::`, all of which accept on the `127.0.0.1` URL. Any other host or DNS name + errors and names `--gateway-external`. OQ7 option A changes the URL, not + this rule. +- **Order.** Each line is linear: + - compass: S0 → S1 → S2 → P1; + - fork: #146 → F1 → F2 → F3. + + I1 follows F3's merge to `rigel-release`. P1 also waits on I1 and RIG-5204. No Pulumi state is involved. ## Plan -### S0 — Pin the verify message (stack) +The Plan is the long section, because it carries the exact interfaces each +lane builds against. -Lane: compass-server (owns `go/server`). +### S0 — Pin the verify message -Interfaces: none new. Add a test in `go/server/gateway_auth_test.go` that -asserts `connect.CodeOf(err) == connect.CodeUnauthenticated` and -`err.(*connect.Error).Message() == "invalid agent token"`. Its comment names -the gateway verifier and boot probe as consumers. +Lane: compass-server (owns `go/server`). Add a test in +`go/server/gateway_auth_test.go` that asserts code `Unauthenticated` and +message `invalid agent token`, and names the gateway as consumer. -Test cycle: `go test ./server -run VerifyAgentToken`. +### F1 — Verifier and shared transport (fork #150) -### F1 — Agent verifier (fork) - -Lane: compass-gateway. +Lane: compass-gateway. Depends on #146. ```ts -// packages/ai/src/compass/agent-verifier.ts (exported from ./compass) -export interface CompassAgentVerifierOptions { - baseUrl: string; // same rules as CompassAuthCredentialStoreOptions.baseUrl - token: string; // llm-gateway service bearer - fetch?: typeof fetch; - timeoutMs?: number; // default 5_000 -} +// packages/ai/src/compass/wire.ts +export function normalizeCompassBaseUrl(baseUrl: string): string; // replaces assertSecureBaseUrl +export function fetchCompassRpcResponse(opts: { baseUrl: string; service: string; method: string; token: string; fetch: typeof fetch; body: unknown; signal: AbortSignal }): Promise<{ response: Response; text: string }>; +// packages/ai/src/compass/agent-verifier.ts +export interface CompassAgentVerifierOptions { baseUrl: string; token: string; fetch?: typeof fetch; timeoutMs?: number } export function createCompassAgentVerifier(opts: CompassAgentVerifierOptions): AuthGatewayAuthorizer; -/** The boot's pre-bind probe: resolves when the door answers `invalid agent token` to "". */ export function probeCompassDoor(opts: CompassAgentVerifierOptions): Promise; +// credential-store.ts: #request uses both helpers. ``` -Consumes `compassRpcErrorFromResponse` and `decodeCompassJson` (`wire.ts`) and -`presentedBearer` (`auth-gateway/http.ts`). - -Test cycle: `bun test` against a fake `fetch`. Cases: +Tests cover each mapping row above, abort, the probe, and that no token +appears in an error. -- no bearer → `null`, with no call made; -- 200 → `AgentCaller`, and the request carries the service bearer plus - `{ token }`; -- `invalid agent token` → `null`; -- other `unauthenticated` → throws; -- `internal` → throws; -- timeout → throws; -- `req.signal` abort cancels the call; -- an empty id in the response → throws; -- the probe resolves only on `invalid agent token`; -- no token appears in any error. - -### F2 — Owner pool (fork) +### F2 — Owner pool Lane: compass-gateway. ```ts -// packages/ai/src/compass/owner-pool.ts (exported from ./compass) +// packages/ai/src/compass/owner-pool.ts export interface CompassOwnerEntry { storage: AuthStorage; store: { hasPendingWrites(): boolean } } -export interface CompassOwnerPoolOptions { - open: (ownerUserId: string) => Promise; - maxOwners?: number; // default 256 (soft) - idleMs?: number; // default 900_000 - minIdleMs?: number; // default 60_000; floor for cap eviction - sweepIntervalMs?: number; // default 60_000; 0 = no timer (tests) - now?: () => number; -} +export interface CompassOwnerPoolOptions { open: (ownerUserId: string) => Promise; maxOwners?: number; idleMs?: number; minIdleMs?: number; sweepIntervalMs?: number; now?: () => number } export class CompassOwnerPool { - constructor(opts: CompassOwnerPoolOptions); - /** Inside withRequestLease, also leases the entry until that request's lease ends. */ get(ownerUserId: string): Promise; - /** Runs one routed request. Its leases end when a Response body ends, errors or is cancelled; at once for a body-less result or a throw. */ withRequestLease(run: () => Promise): Promise; sweep(): void; close(): void; @@ -353,262 +271,171 @@ export class CompassOwnerPool { export function openCompassOwner(opts: CompassAuthCredentialStoreOptions): Promise; ``` -Test cycle: `bun test` with fake entries and a fake clock. Cases: +Required tests: -- same instance per owner; -- concurrent first `get` opens once; -- a failed open is retried; -- an idle entry closes; -- **a streamed response held open past `minIdleMs` and `idleMs`, with the - cap exceeded, is not evicted, and it is evictable once the body ends**; -- a cancelled body and a thrown `run` both release the lease; -- **an idle owner with a parked write survives the sweep, and the next - request under a live token commits it.** This uses a real - `CompassAuthCredentialStore` over a fake door (door record requirement); -- the cap evicts LRU entries only when they are idle, unleased and have no - pending writes; -- `close()` closes all entries. +- **Two-owner isolation.** Each owner gets the same instance on repeat calls, + and the two owners get different instances. Each selects only its own + credential. +- **Lease.** A stream held past both idle limits, with the cap exceeded, + survives, and becomes evictable once its body ends. +- **Parked write.** An idle owner with a parked write survives the sweep. The + next live-token request commits it, using a real store over a fake door. +- **Basics:** single-flight open, retried after a failure, and closed by + `close()`. -### F3 — Compass boot (fork) +### F3 — Compass boot Lane: compass-gateway. ```ts // packages/coding-agent/src/cli/gateway-boot.ts -export interface GatewayBootConfig { - serviceTokenFile: string; serverUrl: string; caFile?: string; - bind: string; drainMs: number; enrollTokenFile?: string; -} +export interface GatewayBootConfig { serviceTokenFile: string; serverUrl: string; caFile?: string; bind: string; drainMs: number; enrollTokenFile?: string } export function getGatewayBootConfig(env: Readonly>): GatewayBootConfig; -export function bundledGatewayModels(): Map>; // keyed provider/id only -export async function runGatewayBoot(env?: Readonly>): Promise; -// auth-gateway-cli.ts: `export const GATEWAY_MODEL_KINDS` (was module-private) +export function readTokenFile(path: string, envName: string): Promise; // replaces readGatewayToken +export function bundledGatewayModels(): Map>; +export function runGatewayBoot(env?: Readonly>): Promise; ``` -`runGatewayBoot` steps: +Order: config → token files and CA → `installGlobalProxyFetch()` → +`probeCompassDoor` → `startAuthGateway(...)` → `registerGatewayShutdown(handle, drainMs, () => pool.close())`. +The required `storage` option gets a placeholder `AuthStorage` over a store +that throws on every call. -1. Read the config. -2. Read the token, the CA and the optional enroll token. -3. `installGlobalProxyFetch()`. -4. Build the compass fetch. -5. `probeCompassDoor`. -6. `startAuthGateway({ bind, authorize, storage: placeholder, resolveStorage, routeScope, resolveModel, listModels, version })`. -7. `registerGatewayShutdown(handle, drainMs, () => pool.close())`. +Tests: -`storage` is still required by `AuthGatewayRouteOptions`. The placeholder is -an `AuthStorage` over a store that throws on every call, so any accidental -use answers 500 and logs. Delete the "still requires OMP_AUTH_BROKER_URL" -comment. +- each fail-before-bind rule; +- the model map holds no bare ids; +- an end-to-end boot against a fake Server: good token → 200, bad token → + 401, Server `internal` → 503, bare id → 404, missing provider → 401. -Test cycle: extend `packages/coding-agent/test/cli/gateway-boot.test.ts`. +### S1 — Service bearer ensure -- Each fail-before-bind rule throws before any listen. -- The model map holds only routable kinds and no bare ids. -- An end-to-end boot against a fake Server on loopback `http`: - - `/healthz` → 200; - - a good token → `/v1/models` 200; - - a bad token → 401; - - the Server answering `internal` → 503; - - a bare model id → 404; - - a chat request for a provider absent from the pool → 401. -- The fork gateway suite stays green. - -### S1 — Service bearer ensure (stack) - -Lane: compass-server (owner of `go/internal/stack/adapters`; reviewer of -S1, S2 and P1). +Lane: compass-server (owns `go/internal/stack/adapters`; reviews S1, S2, P1). ```go -// go/internal/runnerhub/mint.go -func TokenStatus(ctx context.Context, r TokenHashResolver, token string, want store.Subject) (TokenState, store.Subject, error) -// RunnerTokenStatus becomes TokenStatus(ctx, r, token, store.Subject{Kind: store.SubjectRunner, ID: runnerID}) -// go/internal/stack/deps.go -type ServiceTokenEnsurer interface { - EnsureServiceToken(ctx context.Context, path, serviceID string) error -} -// Deps gains: ServiceTokens ServiceTokenEnsurer -// go/internal/stack/adapters/token.go -func (e *TokenEnsurer) EnsureServiceToken(ctx context.Context, path, serviceID string) error +func TokenStatus(ctx context.Context, r TokenHashResolver, token string, want store.Subject) (TokenState, store.Subject, error) // runnerhub; RunnerTokenStatus wraps it +type ServiceTokenEnsurer interface{ EnsureServiceToken(ctx context.Context, path, serviceID string) error } // stack.Deps.ServiceTokens +func (e *TokenEnsurer) EnsureServiceToken(ctx context.Context, path, serviceID string) error // adapters ``` -`TokenStatus` compares only `Kind` and `ID`. `ensure` takes the want subject, -a keep-revoked flag and a register func. The runner keeps -`StoreRunnerTokenHash`; the service path calls `PutTokenHash` with -`sha256(token)`. `cmd/compass-stack/main.go` wires one -`adapters.NewTokenEnsurer(cfg.DatabaseDSN)` into both fields. +`EnsureServiceToken` creates the token's directory at 0700 before its first +write. Tests: -Test cycle: `token_test.go` against the fake store: - -- absent → mint, file first; -- registered → kept; -- unknown → re-registered; -- revoked → rotated; -- other subject → rotated; -- hash commit fails → file removed; +- each Approach rule; +- the file is removed when the commit fails; - mode 0600; +- **fresh state:** with no `/gateway/`, the call creates it at 0700 + and mints the token; - the runner cases stay green. -### S2 — Gateway spec, network and order (stack) +### S2 — Gateway spec, network and order Lane: compass-server. Depends on S1. ```go -// go/internal/stack/gateway_container.go -const ( - GatewayServiceTokenMountPath = "/run/compass/service.token" - GatewayCAMountPath = "/run/compass/server-ca.crt" -) -type GatewayContainerSpec struct { /* existing fields */ ServerURL string; NetworkArgs []string; CAFile string } +const GatewayServiceTokenMountPath, GatewayCAMountPath = "/run/compass/service.token", "/run/compass/server-ca.crt" +type GatewayContainerSpec struct { /* existing */ ServerURL string; NetworkArgs []string; CAFile string } func gatewayContainerSpec(cfg Config, listenAddr string, cert CertResult) (GatewayContainerSpec, error) ``` -- **Spec.** `TokenFile` becomes `/gateway/service.token`. Env as in - the table. A `listenAddr` host outside the Door listen host set errors. -- **Run args.** `gatewayRunArgs` appends `NetworkArgs` - (`--network pasta:-T,` on Linux) and mounts the token at - `GatewayServiceTokenMountPath` and the CA at `GatewayCAMountPath` (`:ro,z`; - the host server reads the same CA file). -- **Start.** `Stack.startGateway` removes the old `gateway.token`, then calls - `ServiceTokens.EnsureServiceToken(ctx, spec.TokenFile, auth.LLMGatewayServiceID)`, - then `GatewayContainer.Start`. `ensureGatewayToken` is deleted. -- **Order.** `spawnChain` moves the gateway after `waitReady`; - `drainChildren` order becomes runner, gateway, server. - -Test cycle: - -- `gateway_container_test.go`: spec env, mounts and run args pinned; a - DNS hostname (`compass.example`) and a non-loopback IP both error; - `127.0.0.1`, `localhost`, `0.0.0.0` and `::` are accepted. -- Harness: server ready before gateway start; gateway drained before server; - service token ensured and old file removed before the container starts. -- `downdetached` tests stay green. - -### I1 — Fork pin, image env and smoke (image lane) +- The spec sets the env table, `NetworkArgs` and the two `:ro,z` mounts. +- `startGateway` runs `MkdirAll(TokenDir, 0o700)`, removes `gateway.token`, + then calls `EnsureServiceToken`, then `Start`. +- `ensureGatewayToken` is deleted. +- The gateway step moves as in the Approach. -Lane: compass-gateway. +Tests: -- Bump `tools/gateway-image/fork-pin.json` from `57f41121ff5e…` to the - `rigel-release` commit holding F1–F3. -- Drop the `COMPASS_GATEWAY_TOKEN_FILE` `ENV` and its allowlist entry. -- Rewrite `smoke.ts`: - - drop the broker; - - run a Bun fake Server on host loopback `http` that answers - `VerifyAgentToken` (`invalid agent token` for `""`) and an empty - `ListCredentialPool`; - - boot with `--network pasta:-T,`; - - assert `/healthz` 200, `/v1/models` with the good token `modelsListOk`, - and a bad token 401; - - fault `no-server-env` (replaces `no-broker-env`) exits before `/healthz` - answers. +- args and mounts are pinned; +- `compass.example` and a non-loopback IP error, and the four allowed hosts + pass; +- harness: on fresh state, server ready → token → container start, and the + drain stops the gateway before the server. -Test cycle: `core.test.ts`, plus the lane's `smoke` task on local podman. +### I1 — Fork pin, image env and smoke -### P1 — Default image pin (stack) +Lane: compass-gateway. -Lane: compass-server. Depends on S2, I1's digest and RIG-5204. +- Bump `fork-pin.json` from `57f41121ff5e…` to the `rigel-release` commit + that holds F1–F3. +- Drop the old `ENV` and its allowlist entry. +- `smoke.ts` drops the broker. It runs a fake Server on host loopback, boots + with `pasta:-T`, and asserts `/healthz`, `/v1/models` for a good token, 401 + for a bad token, and a `no-server-env` fault. -Set `DefaultGatewayImage` (`go/internal/stack/gateway_image.go`) to I1's -`repo@sha256:…`. +### P1 — Default image pin -Test cycle: one stack podman test that boots the bundled gateway with no -`--gateway-external` and gets 200 on `/v1/models`. Its agent token is minted -through `auth.NewGatewayTokens(st).Mint` directly. No production code calls -`Mint` today; it is used only by tests. So until T5, no real agent sends -traffic through the gateway, and this test is not end-to-end proof. +Lane: compass-server. Set `DefaultGatewayImage` to I1's digest. A podman +test boots the bundled gateway and gets 200 on `/v1/models`. Its token comes +from `auth.NewGatewayTokens(st).Mint` directly: only tests call `Mint` until +T5, so this test is not end-to-end proof. ## Tasks -- [ ] S0 — compass-server: Go test pins `invalid agent token` as the - `VerifyAgentToken` wire contract. -- [ ] S1 — compass-server: `TokenStatus` over `store.Subject`; - `EnsureServiceToken` for `llm-gateway`. -- [ ] S2 — compass-server: `service.token` + delete `gateway.token`; env, - mounts, `pasta:-T`; loopback-covering door host; gateway after server - ready; drain gateway before server. -- [ ] F1 — compass-gateway: `createCompassAgentVerifier` + - `probeCompassDoor`. -- [ ] F2 — compass-gateway: `CompassOwnerPool` (single-flight open, leases, - `hasPendingWrites` pin, idle sweep, soft cap, close) + stream and - parked-write tests. -- [ ] F3 — compass-gateway: compass `runGatewayBoot` (env contract, door - probe, `provider/id` catalog, throwing placeholder, CA-scoped fetch). -- [ ] I1 — compass-gateway: fork pin bump, image env cleanup, broker-free - smoke. +- [ ] S0 — compass-server: pin `invalid agent token`. +- [ ] S1 — compass-server: `TokenStatus`; `EnsureServiceToken` (makes its dir). +- [ ] S2 — compass-server: spec, mounts, `pasta:-T`, door-host rule, order. +- [ ] F1 — compass-gateway: `wire.ts` transport, verifier, door probe (#150). +- [ ] F2 — compass-gateway: owner pool and its required tests. +- [ ] F3 — compass-gateway: compass `runGatewayBoot`, `readTokenFile`. +- [ ] I1 — compass-gateway: pin bump, image env, broker-free smoke. - [ ] P1 — compass-server: pin `DefaultGatewayImage`. -- [ ] Ledger: claim these with `bun tools/dl-claim` when this record's PR is - ready. The design pass does not claim them. - - DL (server): the stack's `llm-gateway` service bearer is - `/gateway/service.token`. The compass boot admits agents only - through `GatewayAuth.VerifyAgentToken`, with no inbound shared token. - Record: `#approach`. - - DL (server): the compass boot serves the bundled catalog by - `provider/id` until stable-name P1. Record: `#approach`. Claim after - Matt rules on OQ1. +- [ ] Ledger: claim these with `bun tools/dl-claim` when this PR is ready; + the design pass does not claim them. + - DL (server): the `llm-gateway` service bearer is + `/gateway/service.token`, and agents are admitted only through + `VerifyAgentToken`. + - DL (server): the boot serves the bundled catalog by `provider/id` until + stable-name P1. Claim after OQ1. ## Open Questions -**OQ1 (load-bearing): catalog under per-owner pools.** +**OQ1 (load-bearing): the catalog under per-owner pools.** -- **A (recommended):** every bundled provider, indexed by `provider/id`, with - no credential gating. A missing provider gets a dispatch 401. -- **B:** a per-owner `ModelRegistry`. Needs a caller-aware `ModelResolver`, a +- **A (recommended):** every bundled provider by `provider/id`, with a + dispatch 401 when a credential is missing. +- **B:** a per-owner `ModelRegistry`. This needs a caller-aware resolver, a router-core change. -- **C:** a union over live pools. It leaks between owners and is +- **C:** a union of live pools. It leaks between owners and is nondeterministic. -- **D:** A, limited to the parent record's day-1 set ("anthropic (OAuth + - api_key), openai/openai-codex (OAuth + api_key), google (api_key)"). This - bounds `/v1/models`, but it turns a new provider into a code change. Parent - RD-6 says "Additional providers are configuration, not code". -- **E:** read the Server registry now (`GetGatewayModelRegistry` is mounted, - `go/server/gateway_registry_service.go`). Stable-name P1 owns that swap and - plans an async, caller-aware `resolveModel`. Today `ModelResolver` is sync, - so E would need a boot-time fetch, a version-poll timer and a - registry-to-`Model` mapping. That duplicates P1 before P1 lands. - -**Recommendation: A.** Decision needed: accept A (or D) as the interim -catalog. - -**OQ2 (load-bearing): drop the inbound shared bearer.** The record admits no -shared caller. The service bearer moves to `service.token` and -`COMPASS_SERVICE_TOKEN_FILE`, and `gateway.token` is deleted. This reverses -the T1 contract ("token file = inbound bearer"). - -**Recommendation: drop it.** Decision needed: confirm there is no operator or -debug use for a shared inbound token. - -**OQ7 (load-bearing): reaching the Server on darwin.** On darwin, pasta runs -in the podman-machine VM, so `-T` would reach the VM's loopback, not the Mac -[INFERENCE; not probed, no Mac host]. - -- **A:** `https://host.containers.internal:` on both OSes, with Linux - adding `--network pasta:--map-host-loopback,169.254.1.2` (probed: - reaches the host). Costs: - - a DNS SAN `host.containers.internal` in `certHosts`; - - a SAN check in `needsRotation`, which today checks only the key, parsing - and expiry, so existing anchors rotate once. -- **B:** per OS: `-T` and `127.0.0.1` on Linux, `host.containers.internal` on - darwin. This still needs A's SAN and adds a second path to test. -- **C:** `-T` everywhere. The bundled gateway is unsupported on darwin, and - the spec errors there, pointing to `--gateway-external`. - -**Recommendation: A, if a darwin probe shows `host.containers.internal` -reaching a Mac `127.0.0.1` listener; otherwise C for now.** The door probe -(Approach) makes any wrong choice fail `up` loudly instead of failing every -request. Decision needed: A or C, and who runs the darwin probe before S2. - -**OQ3 (not load-bearing): verifier cache.** None for now. Revisit after dev -measures VerifyAgentToken load, alongside the door-call rate (door record -Q1). - -**OQ4 (resolved by design): the 401 vs 503 split depends on a message.** No -Go test pinned the literal. `TestVerifyAgentTokenInvalidIsUnauthenticated` -checks only the code, plus equal text across tokens. S0 pins the literal. -ErrorInfo was rejected (Alternatives). - -**OQ5 (not load-bearing): enroll token supply.** No compass or fork code reads -or writes `COMPASS_GATEWAY_ENROLL_TOKEN_FILE`. Minting and mounting it belong -to E2/E3 of `compass-gateway-oauth-enrollment`. - -**OQ6 (not load-bearing): detached down.** `drainTargets` signals the whole -consumer tier (runner, server, gateway) at once. A write-back during the -gateway's drain can park and be lost. The door record accepts this residual. +- **D:** A, limited to the parent's day-1 set. This makes a new provider a + code change, against RD-6 ("Additional providers are configuration, not + code"). +- **E:** read `GetGatewayModelRegistry` now. This duplicates stable-name P1: + a sync resolver would need a boot fetch, a poll and a model mapping. + +Decision needed: A or D. + +**OQ2 (load-bearing): drop the inbound shared bearer.** This reverses the T1 +contract ("token file = inbound bearer"). Recommendation: drop it. Decision +needed: confirm there is no operator or debug use for one. + +**OQ7 (load-bearing): reaching the Server on darwin** [INFERENCE; not probed]. + +- **A:** `host.containers.internal` on both OSes. Linux adds + `pasta:--map-host-loopback,169.254.1.2`, which was probed and works. The + cost is a DNS SAN in `certHosts` and a SAN check in `needsRotation`. +- **B:** per OS. This still needs A's SAN and adds a second path to test. +- **C:** `-T` everywhere, with the bundled gateway unsupported on darwin. The + spec errors there and points to `--gateway-external`. + +Recommendation: A if a darwin probe works, otherwise C for now. The door +probe makes a wrong choice fail `up` loudly. Decision needed: A or C, and who +runs the darwin probe. + +**OQ3 (deferred): verify cache.** Revisit after dev measures the load. + +**OQ4 (resolved): the 401 vs 503 split hangs on one message.** No test pinned +it before; S0 does now. + +**OQ5 (deferred): enroll token supply.** Nothing reads or writes +`COMPASS_GATEWAY_ENROLL_TOKEN_FILE` today. Owned by E2/E3 of +`compass-gateway-oauth-enrollment`. + +**OQ6 (accepted residual): detached down.** `drainTargets` signals the +runner, server and gateway together, so a write-back during the gateway's +drain can be lost. + +**OQ8 (deferred to T5): inbound TLS.** The listener is published only on +host loopback (Global Constraints). T5 owns TLS once it binds beyond +loopback (parent OQ-4). From 648d48ab9718c4c812338968ab924026523aa10e Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 11 Oct 2026 21:54:31 -0400 Subject: [PATCH 04/13] docs(server): match the owner-pool lease to the Response-typed seam (RIG-2862) Co-authored-by: Matt Wilkinson --- docs/designs/server/compass-gateway-boot-wiring.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index 1bc27b4ae..120b02568 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -264,7 +264,8 @@ export interface CompassOwnerEntry { storage: AuthStorage; store: { hasPendingWr export interface CompassOwnerPoolOptions { open: (ownerUserId: string) => Promise; maxOwners?: number; idleMs?: number; minIdleMs?: number; sweepIntervalMs?: number; now?: () => number } export class CompassOwnerPool { get(ownerUserId: string): Promise; - withRequestLease(run: () => Promise): Promise; + withRequestLease(run: () => Promise): Promise; + // F2 narrows AuthGatewayRouteOptions.routeScope to Response to match. sweep(): void; close(): void; } From d65364d8206d6ab22a669e25ba4e22a958447079 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 11 Oct 2026 22:19:29 -0400 Subject: [PATCH 05/13] docs(server): record Matt's OQ2/OQ7 rulings and the darwin probe (RIG-2862) OQ2 drops the inbound shared bearer. OQ7 reaches the Server at host.containers.internal under pasta map-host-loopback on both OSes, with a DNS SAN; a mattmini probe confirmed it. OQ1 B is costed without a parent amendment. Co-authored-by: Matt Wilkinson --- .../server/compass-gateway-boot-wiring.md | 148 ++++++++++++------ 1 file changed, 97 insertions(+), 51 deletions(-) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index 120b02568..3619f74cd 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -87,7 +87,8 @@ to the store. - **Catalog: bundled, `provider/id` keys only (OQ1).** The `ModelResolver` type `(modelId) => Model | undefined` never sees the caller, and `/v1/models` skips the pool. The boot indexes `getBundledModels()` for - every bundled provider, keeping `GATEWAY_MODEL_KINDS` only. + every bundled provider, keeping `GATEWAY_MODEL_KINDS` only. OQ1 option B + would replace this with a per-owner registry. - **No bare ids.** `indexModelsByRequestId` would add a bare-id fallback: `if (!modelById.has(model.id)) modelById.set(model.id, model);`. Over all providers, that routes a shared bare id to whichever provider comes @@ -98,7 +99,7 @@ to the store. - **Inbound auth.** `authorize` is the verifier, wrapped in `withEnrollToken` when `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` is set (OQ5). `bearerTokenAuthorizer` is not used, and no shared inbound token remains - (OQ2). + (OQ2, resolved). - **Outbound TLS.** The store and the verifier share one fetch, `fetch(input, { ...init, tls: { ca } })`, with `ca` read from `COMPASS_SERVER_CA_FILE`. Provider calls keep the default trust store. @@ -131,19 +132,25 @@ to the store. - a revoke mid-run means 503 until the next `up`; - an external database plus a wiped state dir leaves old hashes live, as on the runner path. -- **Server address (Linux).** The container runs with - `--network pasta:-T,` and dials `https://127.0.0.1:`. - - pasta does not forward container loopback by default (`pastaHostGateway` - in `go/e2e/fixture.go`: "pasta does not forward 127.0.0.1"). - - A local probe on rootless podman 5.8.6 showed that `-T` works, and that - `-p` still publishes. - - The SAN matches (`go/internal/stack/adapters/cert.go`: - `var certHosts = []string{"127.0.0.1", "::1", "localhost"}`). +- **Server address (OQ7-A, resolved).** The container dials + `https://host.containers.internal:` on both OSes and runs with + `--network pasta:--map-host-loopback,169.254.1.2`. Both mappings land on + the host's `127.0.0.1` (probe table under Resolved decisions): + - Linux: by default pasta does not forward container loopback + (`pastaHostGateway` in `go/e2e/fixture.go`: "pasta does not forward + 127.0.0.1"). The map reaches a host `127.0.0.1` listener, and `-p` still + publishes. + - darwin: the name resolves to `192.168.127.254`, which reaches a Mac + `127.0.0.1` listener under the same pasta spec, and `-p` still + publishes from that container. + - TLS: the name needs a DNS SAN. Today the SANs are + `var certHosts = []string{"127.0.0.1", "::1", "localhost"}` + (`go/internal/stack/adapters/cert.go`), so the Linux probe failed the + hostname check. `needsRotation` checks only the key, the parse and the + expiry (`return !notAfter.After(now.Add(e.rotateWindow))`). S2 adds the SAN + and makes a missing `certHosts` SAN trigger rotation. Every existing + anchor then rotates once, on the expiry-rotation path. - `/tls.crt` is mounted read-only as the CA. - - On darwin the container runs in the podman-machine VM - (`internal/embedded/machine.go`), so `-T` would reach the VM's loopback - (OQ7, [INFERENCE]). - **Door host.** `overlapsLoopbackPublish` (`go/internal/stack/config.go`) is too wide here, because it also passes every DNS name: `return ip == nil || ip.IsUnspecified() || ip.Equal(net.IPv4(127, 0, 0, 1))`. @@ -166,7 +173,9 @@ to the store. - **Reusing `gateway.token` for the service bearer.** It would adopt the old inbound secret, and skew would stay silent. Rejected. - **`--network host`.** It drops `-p` and network isolation. Rejected. - `host.containers.internal` is OQ7 option A. +- **`--network pasta:-T,` with `127.0.0.1`.** Linux only. On + darwin it reaches the VM's loopback ("Connection reset by peer"). Rejected + (OQ7). - **An ErrorInfo reason on `VerifyAgentToken`.** The verifier would have to decode error details from Connect JSON. Rejected for S0's literal pin. - **A verify cache.** A revoked token would keep working. Deferred (OQ3). @@ -179,10 +188,13 @@ to the store. - `packages/ai/src/compass/` (new modules, plus F1's `wire.ts` helpers and the matching `credential-store.ts` transport refactor); - `gateway-boot.ts`; - - one export from `auth-gateway-cli.ts`. + - one export from `auth-gateway-cli.ts`; + - option types and call sites in `dispatch.ts` and `server.ts`, for F2's + `routeScope` narrowing and, only if OQ1 picks B, the caller-aware + `resolveModel` and `listModels`. - No change to `auth-storage.ts`, `packages/ai/src/auth/`, `server.ts` or - `dispatch.ts`. + No routing-core change, and no change to `auth-storage.ts` or + `packages/ai/src/auth/`. - **Fork base.** `rigel-release` with RIG-5205 (`routeScope`, `d0ac08ac56cc`). #146 (RIG-5206) is based on it. - **Server dependency.** A default-pinned image needs RIG-5204, because on main @@ -208,16 +220,21 @@ to the store. | Name | Value | Supplier | |---|---|---| | `COMPASS_SERVICE_TOKEN_FILE` | `/run/compass/service.token` | S2 | - | `COMPASS_SERVER_URL` | `https://127.0.0.1:` (darwin: OQ7) | S2 | + | `COMPASS_SERVER_URL` | `https://host.containers.internal:` | S2 | | `COMPASS_SERVER_CA_FILE` | `/run/compass/server-ca.crt` | S2 | | `COMPASS_GATEWAY_BIND` | `0.0.0.0:4000` | existing | | `COMPASS_GATEWAY_DRAIN_MS` | `20000` | existing | | `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` | optional | E2/E3, later | -- **Door listen host.** S2 accepts only `127.0.0.1`, `localhost`, `0.0.0.0` or - `::`, all of which accept on the `127.0.0.1` URL. Any other host or DNS name - errors and names `--gateway-external`. OQ7 option A changes the URL, not - this rule. +- **Door listen host.** Both host mappings deliver to the host's IPv4 + `127.0.0.1`. So S2 accepts only `127.0.0.1`, `localhost`, `0.0.0.0` or `::`, + which all accept there: + - `localhost` binds its first IPv4 (`la := addrs.first(isIPv4)` in Go's + `net/dial.go`); + - `::` binds dual-stack. + + Any other host, including `::1`, `127.0.0.2` and every DNS name, errors and + names `--gateway-external`. - **Order.** Each line is linear: - compass: S0 → S1 → S2 → P1; - fork: #146 → F1 → F2 → F3. @@ -335,11 +352,17 @@ Lane: compass-server. Depends on S1. ```go const GatewayServiceTokenMountPath, GatewayCAMountPath = "/run/compass/service.token", "/run/compass/server-ca.crt" +const gatewayServerHost, gatewayHostLoopbackAddr = "host.containers.internal", "169.254.1.2" type GatewayContainerSpec struct { /* existing */ ServerURL string; NetworkArgs []string; CAFile string } func gatewayContainerSpec(cfg Config, listenAddr string, cert CertResult) (GatewayContainerSpec, error) +// adapters/cert.go +var certHosts = []string{"127.0.0.1", "::1", "localhost", "host.containers.internal"} +func (e *CertEnsurer) needsRotation(certPath, keyPath string, now time.Time) bool // also true when a certHosts SAN is missing ``` -- The spec sets the env table, `NetworkArgs` and the two `:ro,z` mounts. +- The spec sets the env table, `NetworkArgs` + (`--network pasta:--map-host-loopback,169.254.1.2`) and the two `:ro,z` + mounts. - `startGateway` runs `MkdirAll(TokenDir, 0o700)`, removes `gateway.token`, then calls `EnsureServiceToken`, then `Start`. - `ensureGatewayToken` is deleted. @@ -348,8 +371,10 @@ func gatewayContainerSpec(cfg Config, listenAddr string, cert CertResult) (Gatew Tests: - args and mounts are pinned; -- `compass.example` and a non-loopback IP error, and the four allowed hosts +- `::1`, `127.0.0.2` and `compass.example` error, and the four allowed hosts pass; +- `cert_test.go`: a fresh anchor carries the DNS SAN. An unexpired anchor + without it rotates once, and the next ensure keeps it; - harness: on fresh state, server ready → token → container start, and the drain stops the gateway before the server. @@ -360,9 +385,11 @@ Lane: compass-gateway. - Bump `fork-pin.json` from `57f41121ff5e…` to the `rigel-release` commit that holds F1–F3. - Drop the old `ENV` and its allowlist entry. -- `smoke.ts` drops the broker. It runs a fake Server on host loopback, boots - with `pasta:-T`, and asserts `/healthz`, `/v1/models` for a good token, 401 - for a bad token, and a `no-server-env` fault. +- `smoke.ts` drops the broker. It runs a fake Server on the host's + `127.0.0.1` over https, with a throwaway cert for `host.containers.internal` + (`compass-gen-cert -hosts`) mounted as the CA. It boots with the S2 network + spec and asserts `/healthz`, `/v1/models` for a good token, 401 for a bad + token, and a `no-server-env` fault. ### P1 — Default image pin @@ -375,7 +402,8 @@ T5, so this test is not end-to-end proof. - [ ] S0 — compass-server: pin `invalid agent token`. - [ ] S1 — compass-server: `TokenStatus`; `EnsureServiceToken` (makes its dir). -- [ ] S2 — compass-server: spec, mounts, `pasta:-T`, door-host rule, order. +- [ ] S2 — compass-server: spec, mounts, `map-host-loopback`, SAN and + one-time rotation, door-host rule, order. - [ ] F1 — compass-gateway: `wire.ts` transport, verifier, door probe (#150). - [ ] F2 — compass-gateway: owner pool and its required tests. - [ ] F3 — compass-gateway: compass `runGatewayBoot`, `readTokenFile`. @@ -386,8 +414,38 @@ T5, so this test is not end-to-end proof. - DL (server): the `llm-gateway` service bearer is `/gateway/service.token`, and agents are admitted only through `VerifyAgentToken`. - - DL (server): the boot serves the bundled catalog by `provider/id` until - stable-name P1. Claim after OQ1. + - DL (server): the bundled gateway reaches the Server at + `host.containers.internal` through `pasta:--map-host-loopback`, and the + stack anchor carries that DNS SAN (OQ7). + - DL (server): the boot's catalog per the OQ1 ruling. Claim after OQ1. + +## Resolved decisions + +**OQ2: drop the inbound shared bearer (Matt, 2026-10-12).** This reverses +the T1 contract ("token file = inbound bearer"). Agents are admitted only +through `VerifyAgentToken`. + +**OQ7: A, `host.containers.internal` on both OSes (Matt, 2026-10-12).** +One network spec, `--network pasta:--map-host-loopback,169.254.1.2`, with the +DNS SAN and the one-time rotation from S2. Option C (bundled gateway +unsupported on darwin) was the fallback. It is moot, because the darwin probe +passed. + +Probe evidence. Each probe targets a host listener on `127.0.0.1`. + +| Host | Network | Target | Result | +|---|---|---|---| +| Linux, rootless podman 5.8.6 | default | `127.0.0.1` | refused | +| Linux | `pasta:-T,` | `127.0.0.1` | reached; `-p` publishes | +| Linux | `pasta:--map-host-loopback,169.254.1.2` | `host.containers.internal` | reached (cert failed: no SAN); `-p` publishes | +| mattmini, macOS arm64, podman 5.8.6, applehv | default | `host.containers.internal` → `192.168.127.254` | reached | +| mattmini | `pasta:--map-host-loopback,169.254.1.2` | `host.containers.internal` → `192.168.127.254` | reached | +| mattmini | `pasta:-T,51999` | `127.0.0.1` | "Connection reset by peer" | +| mattmini | default, `-p 127.0.0.1:51998:80` | host → container | reached | +| mattmini | `pasta:--map-host-loopback,169.254.1.2`, `-p 127.0.0.1:51998:8080` | host → container, and same container → `host.containers.internal` | both reached | + +The Mac listener was `python3 -m http.server 51999 --bind 127.0.0.1`. The +probe machine was removed afterwards. ## Open Questions @@ -395,8 +453,13 @@ T5, so this test is not end-to-end proof. - **A (recommended):** every bundled provider by `provider/id`, with a dispatch 401 when a credential is missing. -- **B:** a per-owner `ModelRegistry`. This needs a caller-aware resolver, a - router-core change. +- **B:** a per-owner `ModelRegistry`, built in the owner pool. It needs no + parent amendment: the stable-name record (Global Constraints, "Fork changes + are seam-shaped") already ratified widening `resolveModel` to + `(modelId, identity) => Promise | undefined>` as an + injection-point change. B pulls that widening forward from stable-name P1. + It also needs a caller-aware `listModels`, because today's + `listModels?: () => Iterable>` (`dispatch.ts`) skips the pool. - **C:** a union of live pools. It leaks between owners and is nondeterministic. - **D:** A, limited to the parent's day-1 set. This makes a new provider a @@ -405,24 +468,7 @@ T5, so this test is not end-to-end proof. - **E:** read `GetGatewayModelRegistry` now. This duplicates stable-name P1: a sync resolver would need a boot fetch, a poll and a model mapping. -Decision needed: A or D. - -**OQ2 (load-bearing): drop the inbound shared bearer.** This reverses the T1 -contract ("token file = inbound bearer"). Recommendation: drop it. Decision -needed: confirm there is no operator or debug use for one. - -**OQ7 (load-bearing): reaching the Server on darwin** [INFERENCE; not probed]. - -- **A:** `host.containers.internal` on both OSes. Linux adds - `pasta:--map-host-loopback,169.254.1.2`, which was probed and works. The - cost is a DNS SAN in `certHosts` and a SAN check in `needsRotation`. -- **B:** per OS. This still needs A's SAN and adds a second path to test. -- **C:** `-T` everywhere, with the bundled gateway unsupported on darwin. The - spec errors there and points to `--gateway-external`. - -Recommendation: A if a darwin probe works, otherwise C for now. The door -probe makes a wrong choice fail `up` loudly. Decision needed: A or C, and who -runs the darwin probe. +Decision needed: B (Matt leaning) or A. **OQ3 (deferred): verify cache.** Revisit after dev measures the load. From 1f9e3a6feaebaac3e40ef16863a4545b44c6091a Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 11 Oct 2026 22:44:07 -0400 Subject: [PATCH 06/13] docs(server): fold review round 4 into the boot wiring record (RIG-2862) Split OQ1-B into B1/B2, move the service-token mount, reorder drainChildren explicitly, and qualify the ledger admission row. Co-authored-by: Matt Wilkinson --- .../server/compass-gateway-boot-wiring.md | 83 ++++++++++++------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index 3619f74cd..e2d402b2f 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -88,7 +88,7 @@ to the store. type `(modelId) => Model | undefined` never sees the caller, and `/v1/models` skips the pool. The boot indexes `getBundledModels()` for every bundled provider, keeping `GATEWAY_MODEL_KINDS` only. OQ1 option B - would replace this with a per-owner registry. + would replace the resolver with a per-owner one. - **No bare ids.** `indexModelsByRequestId` would add a bare-id fallback: `if (!modelById.has(model.id)) modelById.set(model.id, model);`. Over all providers, that routes a shared bare id to whichever provider comes @@ -158,9 +158,10 @@ to the store. - **Start order.** `spawnChain` (`stack.go`) starts the gateway at step 1e, before the TLS anchor and the server. So on a first run there is no CA file and no store to register the token in. The gateway moves to after - `waitReady` and before `startRunner`. In-process drain becomes runner → + `waitReady` and before `startRunner`. `drainChildren` uses a hard-coded + list (runner, server, llm-gateway, …), so S2 reorders it to runner → gateway → server. That gain is small: agents are gone by then, and T5 - revokes their tokens on stop (OQ6). + revokes their tokens on stop. Detached down is unchanged (OQ6). ## Alternatives considered @@ -191,7 +192,8 @@ to the store. - one export from `auth-gateway-cli.ts`; - option types and call sites in `dispatch.ts` and `server.ts`, for F2's `routeScope` narrowing and, only if OQ1 picks B, the caller-aware - `resolveModel` and `listModels`. + `resolveModel`. A caller-aware `listModels` (B2) also needs a + stable-name amendment first. No routing-core change, and no change to `auth-storage.ts` or `packages/ai/src/auth/`. @@ -227,11 +229,14 @@ to the store. | `COMPASS_GATEWAY_ENROLL_TOKEN_FILE` | optional | E2/E3, later | - **Door listen host.** Both host mappings deliver to the host's IPv4 - `127.0.0.1`. So S2 accepts only `127.0.0.1`, `localhost`, `0.0.0.0` or `::`, - which all accept there: - - `localhost` binds its first IPv4 (`la := addrs.first(isIPv4)` in Go's - `net/dial.go`); - - `::` binds dual-stack. + `127.0.0.1`. The door binds through `Stack.listenTCP` (`stack.go`), which + calls `net.ResolveTCPAddr` and then `net.ListenTCP`. S2 accepts only hosts + that accept on `127.0.0.1`: + - `127.0.0.1`; + - `localhost`, which resolves IPv4 first (`forResolve` in Go's + `net/ipsock.go`: "IPv4 is preferred, unless addr contains an IPv6 + literal"); + - `0.0.0.0`, `::` and an empty host (`:port`), which bind a wildcard. Any other host, including `::1`, `127.0.0.2` and every DNS name, errors and names `--gateway-external`. @@ -295,7 +300,9 @@ Required tests: and the two owners get different instances. Each selects only its own credential. - **Lease.** A stream held past both idle limits, with the cap exceeded, - survives, and becomes evictable once its body ends. + survives. The lease is released, and the owner becomes evictable, when the + body ends, when it errors, and when it is cancelled. #151 already has these + cases. - **Parked write.** An idle owner with a parked write survives the sweep. The next live-token request commits it, using a real store over a fake door. - **Basics:** single-flight open, retried after a failure, and closed by @@ -360,19 +367,31 @@ var certHosts = []string{"127.0.0.1", "::1", "localhost", "host.containers.inter func (e *CertEnsurer) needsRotation(certPath, keyPath string, now time.Time) bool // also true when a certHosts SAN is missing ``` -- The spec sets the env table, `NetworkArgs` - (`--network pasta:--map-host-loopback,169.254.1.2`) and the two `:ro,z` - mounts. +- `TokenFile` becomes `/gateway/service.token`, mounted at + `GatewayServiceTokenMountPath`. `GatewayTokenMountPath` and the + `COMPASS_GATEWAY_TOKEN_FILE` env are removed. +- The spec sets the env table and `NetworkArgs` + (`--network pasta:--map-host-loopback,169.254.1.2`). +- Mount labels: + - the service token uses `:ro,Z`, a private label, like today's + `spec.TokenFile+":"+stack.GatewayTokenMountPath+":ro,Z"` in + `gatewayRunArgs`; + - the CA uses `:ro,z`, a shared label, because the host server reads the + same `tls.crt`. - `startGateway` runs `MkdirAll(TokenDir, 0o700)`, removes `gateway.token`, then calls `EnsureServiceToken`, then `Start`. +- `drainChildren` order becomes runner, llm-gateway, server. Detached + `liveTargets` keeps its order, and `drainTargets` still signals the + consumer tier together. - `ensureGatewayToken` is deleted. - The gateway step moves as in the Approach. Tests: -- args and mounts are pinned; -- `::1`, `127.0.0.2` and `compass.example` error, and the four allowed hosts - pass; +- the argv test pins the service-token mount (`:ro,Z`) and the CA mount + (`:ro,z`), and finds no `COMPASS_GATEWAY_TOKEN_FILE`; +- `::1`, `127.0.0.2` and `compass.example` error. `127.0.0.1`, `localhost`, + `0.0.0.0`, `::` and an empty host (`:port`) pass; - `cert_test.go`: a fresh anchor carries the DNS SAN. An unexpired anchor without it rotates once, and the next ensure keeps it; - harness: on fresh state, server ready → token → container start, and the @@ -412,8 +431,9 @@ T5, so this test is not end-to-end proof. - [ ] Ledger: claim these with `bun tools/dl-claim` when this PR is ready; the design pass does not claim them. - DL (server): the `llm-gateway` service bearer is - `/gateway/service.token`, and agents are admitted only through - `VerifyAgentToken`. + `/gateway/service.token`. Agents are admitted only through + `VerifyAgentToken`; the optional enroll token admits only the enroll + caller, on `/internal/enroll/*`. - DL (server): the bundled gateway reaches the Server at `host.containers.internal` through `pasta:--map-host-loopback`, and the stack anchor carries that DNS SAN (OQ7). @@ -422,8 +442,9 @@ T5, so this test is not end-to-end proof. ## Resolved decisions **OQ2: drop the inbound shared bearer (Matt, 2026-10-12).** This reverses -the T1 contract ("token file = inbound bearer"). Agents are admitted only -through `VerifyAgentToken`. +the T1 contract agreed in the RIG-2862 comment of 2026-09-29, under which +the boot loaded the token file `/run/compass/gateway.token` as the single +inbound bearer. Agents are now admitted only through `VerifyAgentToken`. **OQ7: A, `host.containers.internal` on both OSes (Matt, 2026-10-12).** One network spec, `--network pasta:--map-host-loopback,169.254.1.2`, with the @@ -453,13 +474,19 @@ probe machine was removed afterwards. - **A (recommended):** every bundled provider by `provider/id`, with a dispatch 401 when a credential is missing. -- **B:** a per-owner `ModelRegistry`, built in the owner pool. It needs no - parent amendment: the stable-name record (Global Constraints, "Fork changes - are seam-shaped") already ratified widening `resolveModel` to - `(modelId, identity) => Promise | undefined>` as an - injection-point change. B pulls that widening forward from stable-name P1. - It also needs a caller-aware `listModels`, because today's - `listModels?: () => Iterable>` (`dispatch.ts`) skips the pool. +- **B:** a per-owner `ModelRegistry`, built in the owner pool, behind the + `resolveModel` widening that the stable-name record already ratified + (Global Constraints, "Fork changes are seam-shaped"): + `(modelId, identity) => Promise | undefined>`. That record keeps + `listModels?: () => Iterable>` caller-independent and lists the + stable-name vocabulary on it. Two variants: + - **B1 (recommended if B):** the resolver only, pulled forward from + stable-name P1, with the listing unchanged. No amendment is needed. + Dispatch is per owner, but `/v1/models` is not: a listed model the owner + holds no credential for gets a 404 from the resolver. + - **B2:** B1 plus a caller-aware listing. This adds a second seam and + changes the `/v1/models` contract that stable-name discovery relies on, + so it needs a stable-name amendment. - **C:** a union of live pools. It leaks between owners and is nondeterministic. - **D:** A, limited to the parent's day-1 set. This makes a new provider a @@ -468,7 +495,7 @@ probe machine was removed afterwards. - **E:** read `GetGatewayModelRegistry` now. This duplicates stable-name P1: a sync resolver would need a boot fetch, a poll and a model mapping. -Decision needed: B (Matt leaning) or A. +Decision needed: B (Matt leaning) or A; if B, then B1 or B2. **OQ3 (deferred): verify cache.** Revisit after dev measures the load. From ca7a64b2e82994805334a02f9652160be7bce91f Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 11 Oct 2026 23:02:32 -0400 Subject: [PATCH 07/13] docs(server): scope B1 to every resolver callsite (RIG-2862) Co-authored-by: Matt Wilkinson --- .../server/compass-gateway-boot-wiring.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index e2d402b2f..cebf913ef 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -191,9 +191,13 @@ to the store. - `gateway-boot.ts`; - one export from `auth-gateway-cli.ts`; - option types and call sites in `dispatch.ts` and `server.ts`, for F2's - `routeScope` narrowing and, only if OQ1 picks B, the caller-aware - `resolveModel`. A caller-aware `listModels` (B2) also needs a - stable-name amendment first. + `routeScope` narrowing; + - only if OQ1 picks B: the caller-aware `resolveModel` in `dispatch.ts`, + `server.ts` and the seven `routes/` handlers (embeddings, images, + rerank, speech, systemone, transcriptions, video). Each + `bootOpts.resolveModel(...)` call receives the admitted caller and awaits + the result. This is an injection-point change, not routing core. A + caller-aware `listModels` (B2) also needs a stable-name amendment first. No routing-core change, and no change to `auth-storage.ts` or `packages/ai/src/auth/`. @@ -482,8 +486,11 @@ probe machine was removed afterwards. stable-name vocabulary on it. Two variants: - **B1 (recommended if B):** the resolver only, pulled forward from stable-name P1, with the listing unchanged. No amendment is needed. - Dispatch is per owner, but `/v1/models` is not: a listed model the owner - holds no credential for gets a 404 from the resolver. + Cost: the seam change in `dispatch.ts`, `server.ts` and seven route + handlers (Global Constraints), plus one caller-specific non-chat route + test (embeddings). Dispatch is per owner, but `/v1/models` is not: a + listed model the owner holds no credential for gets a 404 from the + resolver. - **B2:** B1 plus a caller-aware listing. This adds a second seam and changes the `/v1/models` contract that stable-name discovery relies on, so it needs a stable-name amendment. From 2da048394ca1df5745c0a6a44c676eb12a8ffc8d Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 12 Oct 2026 00:57:23 -0400 Subject: [PATCH 08/13] docs(server): add the passt floor for map-host-loopback (RIG-2862) Co-authored-by: Matt Wilkinson --- .../server/compass-gateway-boot-wiring.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index cebf913ef..a4ec71103 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -205,6 +205,12 @@ to the store. #146 (RIG-5206) is based on it. - **Server dependency.** A default-pinned image needs RIG-5204, because on main `ListCredentialPoolRequest` still requires `agent_account_id`. +- **Host floor (OQ7-A).** The bundled gateway needs passt 2024-08-21 or newer, + which added `--map-host-loopback`. Ubuntu 24.04's passt lacks it, and + 26.04's has it (Resolved decisions). Below the floor, `up` fails at S2's + preflight; `--gateway-external` still works. The existing + `hostcheck.MicroVMFloors` passt floor (`2025_09_19`) covers only the + microVM backend's `passt` on PATH, not podman's pasta. - **Fail before bind.** The boot exits non-zero and never binds when: - `COMPASS_SERVER_URL` is unset or fails `normalizeCompassBaseUrl`; - an https URL has no readable CA file; @@ -389,6 +395,15 @@ func (e *CertEnsurer) needsRotation(certPath, keyPath string, now time.Time) boo consumer tier together. - `ensureGatewayToken` is deleted. - The gateway step moves as in the Approach. +- **Network preflight.** Before `EnsureServiceToken`, `startGateway` runs a + throwaway `podman run --rm` of the gateway image with the S2 `NetworkArgs` + and `--version`, which tini (the image's `ENTRYPOINT` in `Dockerfile.gateway`) + answers. This is a new `GatewayContainer.CheckNetwork(ctx, spec) error`. + On failure, `up` fails with an error that names passt 2024-08-21, + `--map-host-loopback` and `--gateway-external`, and quotes the first two + lines of podman's stderr (pasta's `unrecognized option` line; its full + usage text runs to about 150 lines). The run tests the option itself, so + it works through the darwin machine too, with no version string to parse. Tests: @@ -400,6 +415,8 @@ Tests: without it rotates once, and the next ensure keeps it; - harness: on fresh state, server ready → token → container start, and the drain stops the gateway before the server. +- a fake podman that rejects `--map-host-loopback` fails `up` with that + error, before the token is ensured or the container starts; ### I1 — Fork pin, image env and smoke @@ -413,6 +430,11 @@ Lane: compass-gateway. (`compass-gen-cert -hosts`) mounted as the CA. It boots with the S2 network spec and asserts `/healthz`, `/v1/models` for a good token, 401 for a bad token, and a `no-server-env` fault. +- `release.yml` `publish-gateway-image` runs the smoke on + `runs-on: ubuntu-latest`. Pin it to `runs-on: ubuntu-26.04`, because + 24.04's passt lacks `--map-host-loopback` and `ubuntu-latest` moves to + 26.04 only gradually (Resolved decisions). The alternative is a nix passt + on PATH. Pinning is recommended. ### P1 — Default image pin @@ -472,6 +494,19 @@ Probe evidence. Each probe targets a host listener on `127.0.0.1`. The Mac listener was `python3 -m http.server 51999 --bind 127.0.0.1`. The probe machine was removed afterwards. +Host floor evidence. A holds, with a floor: + +- `--map-host-loopback` landed in upstream passt on 2024-08-21 (commit + e813a4df, "conf: Allow address remapped to host to be configured"). +- Ubuntu noble (24.04) ships passt `0.0~git20240220`, and its pasta(1) has + no `--map-host-loopback`. Resolute (26.04, passt 20260120) has it + (manpages.ubuntu.com `noble`/`resolute` `man1/pasta.1`; + launchpad.net/ubuntu/+source/passt). +- GitHub `ubuntu-latest` moves to 26.04 gradually, from 2026-10-19 to + 2026-11-19 (github.blog changelog, 2026-09-17). +- This box's podman 5.8.6 bundles `pasta 2026_07_16`, whose `--help` lists + the option. + ## Open Questions **OQ1 (load-bearing): the catalog under per-owner pools.** From 275853ee750a810d42cb499e786da4f7b4f80416 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 12 Oct 2026 01:24:52 -0400 Subject: [PATCH 09/13] docs(server): fix the network preflight argv (RIG-2862) Co-authored-by: Matt Wilkinson --- .../server/compass-gateway-boot-wiring.md | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index a4ec71103..bdc6119ab 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -208,9 +208,9 @@ to the store. - **Host floor (OQ7-A).** The bundled gateway needs passt 2024-08-21 or newer, which added `--map-host-loopback`. Ubuntu 24.04's passt lacks it, and 26.04's has it (Resolved decisions). Below the floor, `up` fails at S2's - preflight; `--gateway-external` still works. The existing - `hostcheck.MicroVMFloors` passt floor (`2025_09_19`) covers only the - microVM backend's `passt` on PATH, not podman's pasta. + preflight; `--gateway-external` still works. The existing passt floor in + `MicroVMFloors` (`go/internal/hostcheck/hostcheck.go`, `2025_09_19`) + covers only the microVM backend's `passt` on PATH, not podman's pasta. - **Fail before bind.** The boot exits non-zero and never binds when: - `COMPASS_SERVER_URL` is unset or fails `normalizeCompassBaseUrl`; - an https URL has no readable CA file; @@ -395,10 +395,12 @@ func (e *CertEnsurer) needsRotation(certPath, keyPath string, now time.Time) boo consumer tier together. - `ensureGatewayToken` is deleted. - The gateway step moves as in the Approach. -- **Network preflight.** Before `EnsureServiceToken`, `startGateway` runs a - throwaway `podman run --rm` of the gateway image with the S2 `NetworkArgs` - and `--version`, which tini (the image's `ENTRYPOINT` in `Dockerfile.gateway`) - answers. This is a new `GatewayContainer.CheckNetwork(ctx, spec) error`. +- **Network preflight.** Before `EnsureServiceToken`, `startGateway` runs + `podman run --rm --entrypoint /usr/bin/tini --version`. + The override is required: the fork's `Dockerfile.gateway` has + `ENTRYPOINT ["/usr/bin/tini", "--"]`, so without it the run executes + `tini -- --version` and fails on every host. This is a new + `GatewayContainer.CheckNetwork(ctx, spec) error`. On failure, `up` fails with an error that names passt 2024-08-21, `--map-host-loopback` and `--gateway-external`, and quotes the first two lines of podman's stderr (pasta's `unrecognized option` line; its full @@ -415,8 +417,10 @@ Tests: without it rotates once, and the next ensure keeps it; - harness: on fresh state, server ready → token → container start, and the drain stops the gateway before the server. -- a fake podman that rejects `--map-host-loopback` fails `up` with that - error, before the token is ensured or the container starts; +- the preflight argv is pinned, including `--entrypoint /usr/bin/tini` and a + trailing `--version`. A fake podman that rejects `--map-host-loopback` + fails `up` with that error, before the token is ensured or the container + starts; ### I1 — Fork pin, image env and smoke @@ -430,11 +434,11 @@ Lane: compass-gateway. (`compass-gen-cert -hosts`) mounted as the CA. It boots with the S2 network spec and asserts `/healthz`, `/v1/models` for a good token, 401 for a bad token, and a `no-server-env` fault. -- `release.yml` `publish-gateway-image` runs the smoke on - `runs-on: ubuntu-latest`. Pin it to `runs-on: ubuntu-26.04`, because - 24.04's passt lacks `--map-host-loopback` and `ubuntu-latest` moves to - 26.04 only gradually (Resolved decisions). The alternative is a nix passt - on PATH. Pinning is recommended. +- compass `.github/workflows/release.yml`, job `publish-gateway-image`, runs + the smoke on `runs-on: ubuntu-latest`. Pin it to `runs-on: ubuntu-26.04`, + because 24.04's passt lacks `--map-host-loopback` and `ubuntu-latest` moves + to 26.04 only gradually (Resolved decisions). Add the pinned Go to PATH for + `compass-gen-cert`. Compass draft #2233 does both. ### P1 — Default image pin From c49629ca8526d164323ffaad76604a3a2e8c9b4c Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 12 Oct 2026 01:32:20 -0400 Subject: [PATCH 10/13] docs(server): link the passt floor evidence (RIG-2862) Co-authored-by: Matt Wilkinson --- docs/designs/server/compass-gateway-boot-wiring.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index bdc6119ab..1e90511f4 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -209,7 +209,8 @@ to the store. which added `--map-host-loopback`. Ubuntu 24.04's passt lacks it, and 26.04's has it (Resolved decisions). Below the floor, `up` fails at S2's preflight; `--gateway-external` still works. The existing passt floor in - `MicroVMFloors` (`go/internal/hostcheck/hostcheck.go`, `2025_09_19`) + `MicroVMFloors` (`go/internal/hostcheck/hostcheck.go`: + `{Binary: "passt", Fields: []int{2025, 9, 19}, Display: "2025_09_19"}`) covers only the microVM backend's `passt` on PATH, not podman's pasta. - **Fail before bind.** The boot exits non-zero and never binds when: - `COMPASS_SERVER_URL` is unset or fails `normalizeCompassBaseUrl`; @@ -501,13 +502,16 @@ probe machine was removed afterwards. Host floor evidence. A holds, with a floor: - `--map-host-loopback` landed in upstream passt on 2024-08-21 (commit - e813a4df, "conf: Allow address remapped to host to be configured"). + [e813a4df](https://passt.top/passt/commit/?id=e813a4df7da28a69ef32642f42fd625aea798967), + "conf: Allow address remapped to host to be configured"). - Ubuntu noble (24.04) ships passt `0.0~git20240220`, and its pasta(1) has no `--map-host-loopback`. Resolute (26.04, passt 20260120) has it - (manpages.ubuntu.com `noble`/`resolute` `man1/pasta.1`; - launchpad.net/ubuntu/+source/passt). + ([noble](https://manpages.ubuntu.com/manpages/noble/man1/pasta.1.html), + [resolute](https://manpages.ubuntu.com/manpages/resolute/man1/pasta.1.html), + [Launchpad](https://launchpad.net/ubuntu/+source/passt)). - GitHub `ubuntu-latest` moves to 26.04 gradually, from 2026-10-19 to - 2026-11-19 (github.blog changelog, 2026-09-17). + 2026-11-19 + ([changelog](https://github.blog/changelog/2026-09-17-ubuntu-26-generally-available-and-latest-migration/)). - This box's podman 5.8.6 bundles `pasta 2026_07_16`, whose `--help` lists the option. From 3666e9e99d42749a55c82fbea13e81be916afdf2 Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 15 Oct 2026 00:30:49 -0400 Subject: [PATCH 11/13] docs(server): record the OQ1 B1 ruling for the gateway catalog (RIG-2862) Matt ruled B1 on RIG-5264: the boot resolves models per owner through stable-name P1's resolver; /v1/models stays caller-independent. Adds F4 for the boot swap and moves OQ1 to Resolved decisions. Co-authored-by: Matt Wilkinson --- .../server/compass-gateway-boot-wiring.md | 67 +++++++------------ 1 file changed, 25 insertions(+), 42 deletions(-) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index 1e90511f4..5775f19a8 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -84,11 +84,11 @@ to the store. There is no credential-poll timer. - **`routeScope`.** The door record's line, plus the lease: `(req, run) => { const t = presentedBearer(req); const leased = () => pool.withRequestLease(run); return t ? runWithAgentToken(t, leased) : leased(); }`. -- **Catalog: bundled, `provider/id` keys only (OQ1).** The `ModelResolver` - type `(modelId) => Model | undefined` never sees the caller, and - `/v1/models` skips the pool. The boot indexes `getBundledModels()` for - every bundled provider, keeping `GATEWAY_MODEL_KINDS` only. OQ1 option B - would replace the resolver with a per-owner one. +- **Catalog: per-owner stable-name resolver (OQ1-B1).** `resolveModel` + becomes `(modelId, identity) => Promise | undefined>`, the seam + the stable-name record ratified, and the boot serves it from stable-name + P1's resolver over the owner's pool. `/v1/models` stays caller-independent. + F3 boots on the bundled catalog below; F4 swaps in the resolver. - **No bare ids.** `indexModelsByRequestId` would add a bare-id fallback: `if (!modelById.has(model.id)) modelById.set(model.id, model);`. Over all providers, that routes a shared bare id to whichever provider comes @@ -192,12 +192,12 @@ to the store. - one export from `auth-gateway-cli.ts`; - option types and call sites in `dispatch.ts` and `server.ts`, for F2's `routeScope` narrowing; - - only if OQ1 picks B: the caller-aware `resolveModel` in `dispatch.ts`, - `server.ts` and the seven `routes/` handlers (embeddings, images, - rerank, speech, systemone, transcriptions, video). Each - `bootOpts.resolveModel(...)` call receives the admitted caller and awaits - the result. This is an injection-point change, not routing core. A - caller-aware `listModels` (B2) also needs a stable-name amendment first. + - the caller-aware `resolveModel` (OQ1-B1) in `dispatch.ts`, `server.ts` + and the seven `routes/` handlers (embeddings, images, rerank, speech, + systemone, transcriptions, video). Each `bootOpts.resolveModel(...)` call + receives the admitted caller and awaits the result. This is an + injection-point change, not routing core. `listModels` stays + caller-independent. No routing-core change, and no change to `auth-storage.ts` or `packages/ai/src/auth/`. @@ -457,6 +457,8 @@ T5, so this test is not end-to-end proof. - [ ] F1 — compass-gateway: `wire.ts` transport, verifier, door probe (#150). - [ ] F2 — compass-gateway: owner pool and its required tests. - [ ] F3 — compass-gateway: compass `runGatewayBoot`, `readTokenFile`. +- [ ] F4 — compass-gateway: boot `resolveModel` from the stable-name + resolver (OQ1-B1), after F3 and stable-name P1. - [ ] I1 — compass-gateway: pin bump, image env, broker-free smoke. - [ ] P1 — compass-server: pin `DefaultGatewayImage`. - [ ] Ledger: claim these with `bun tools/dl-claim` when this PR is ready; @@ -468,10 +470,21 @@ T5, so this test is not end-to-end proof. - DL (server): the bundled gateway reaches the Server at `host.containers.internal` through `pasta:--map-host-loopback`, and the stack anchor carries that DNS SAN (OQ7). - - DL (server): the boot's catalog per the OQ1 ruling. Claim after OQ1. + - DL (server): the boot resolves models per owner through the stable-name + resolver; `/v1/models` stays caller-independent (OQ1-B1). ## Resolved decisions +**OQ1: B1, a per-owner resolver with `/v1/models` unchanged (Matt, +2026-10-15).** The boot resolves each request through stable-name P1's +resolver over the owner's pool. When no candidate is usable, the resolver +returns the first known one, so the owner gets the dispatch 401 rather than a +404. No stable-name amendment. Rejected: A (bundled catalog only); B2 +(caller-aware listing, which amends the stable-name listing contract; a +later follow-up if managed tenants need a per-user list); C (union of live +pools: leaks between owners); D (day-1 set: against RD-6); E (a second +registry reader that duplicates P1). + **OQ2: drop the inbound shared bearer (Matt, 2026-10-12).** This reverses the T1 contract agreed in the RIG-2862 comment of 2026-09-29, under which the boot loaded the token file `/run/compass/gateway.token` as the single @@ -517,36 +530,6 @@ Host floor evidence. A holds, with a floor: ## Open Questions -**OQ1 (load-bearing): the catalog under per-owner pools.** - -- **A (recommended):** every bundled provider by `provider/id`, with a - dispatch 401 when a credential is missing. -- **B:** a per-owner `ModelRegistry`, built in the owner pool, behind the - `resolveModel` widening that the stable-name record already ratified - (Global Constraints, "Fork changes are seam-shaped"): - `(modelId, identity) => Promise | undefined>`. That record keeps - `listModels?: () => Iterable>` caller-independent and lists the - stable-name vocabulary on it. Two variants: - - **B1 (recommended if B):** the resolver only, pulled forward from - stable-name P1, with the listing unchanged. No amendment is needed. - Cost: the seam change in `dispatch.ts`, `server.ts` and seven route - handlers (Global Constraints), plus one caller-specific non-chat route - test (embeddings). Dispatch is per owner, but `/v1/models` is not: a - listed model the owner holds no credential for gets a 404 from the - resolver. - - **B2:** B1 plus a caller-aware listing. This adds a second seam and - changes the `/v1/models` contract that stable-name discovery relies on, - so it needs a stable-name amendment. -- **C:** a union of live pools. It leaks between owners and is - nondeterministic. -- **D:** A, limited to the parent's day-1 set. This makes a new provider a - code change, against RD-6 ("Additional providers are configuration, not - code"). -- **E:** read `GetGatewayModelRegistry` now. This duplicates stable-name P1: - a sync resolver would need a boot fetch, a poll and a model mapping. - -Decision needed: B (Matt leaning) or A; if B, then B1 or B2. - **OQ3 (deferred): verify cache.** Revisit after dev measures the load. **OQ4 (resolved): the 401 vs 503 split hangs on one message.** No test pinned From 70e2076424d6dd339fb4082d663fb3bf85bb3dd7 Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 15 Oct 2026 00:32:50 -0400 Subject: [PATCH 12/13] docs(server): claim DL-516..518 for the gateway boot rulings (RIG-2862) Co-authored-by: Matt Wilkinson --- docs/designs/decisions/server/DL-516.md | 6 ++++++ docs/designs/decisions/server/DL-517.md | 6 ++++++ docs/designs/decisions/server/DL-518.md | 6 ++++++ docs/designs/server/compass-gateway-boot-wiring.md | 14 +++----------- 4 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 docs/designs/decisions/server/DL-516.md create mode 100644 docs/designs/decisions/server/DL-517.md create mode 100644 docs/designs/decisions/server/DL-518.md diff --git a/docs/designs/decisions/server/DL-516.md b/docs/designs/decisions/server/DL-516.md new file mode 100644 index 000000000..0cce8f844 --- /dev/null +++ b/docs/designs/decisions/server/DL-516.md @@ -0,0 +1,6 @@ +--- +id: DL-516 +decision: "The bundled gateway's `llm-gateway` service bearer is `/gateway/service.token`; agents are admitted only through `VerifyAgentToken`, and the optional enroll token admits only the enroll caller on `/internal/enroll/*` (OQ2: no inbound shared bearer)" +status: "Active (Matt, 2026-10-12)" +record: ../../server/compass-gateway-boot-wiring.md#resolved-decisions +--- diff --git a/docs/designs/decisions/server/DL-517.md b/docs/designs/decisions/server/DL-517.md new file mode 100644 index 000000000..99a8dc80d --- /dev/null +++ b/docs/designs/decisions/server/DL-517.md @@ -0,0 +1,6 @@ +--- +id: DL-517 +decision: "The bundled gateway reaches the Server at `host.containers.internal` through `pasta:--map-host-loopback` on Linux and macOS, and the stack anchor carries that DNS SAN (OQ7-A)" +status: "Active (Matt, 2026-10-12)" +record: ../../server/compass-gateway-boot-wiring.md#resolved-decisions +--- diff --git a/docs/designs/decisions/server/DL-518.md b/docs/designs/decisions/server/DL-518.md new file mode 100644 index 000000000..b21899b87 --- /dev/null +++ b/docs/designs/decisions/server/DL-518.md @@ -0,0 +1,6 @@ +--- +id: DL-518 +decision: "The bundled gateway resolves models per owner through the stable-name resolver over the owner's credential pool; `/v1/models` stays caller-independent (OQ1-B1)" +status: "Active (Matt, 2026-10-15)" +record: ../../server/compass-gateway-boot-wiring.md#resolved-decisions +--- diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index 5775f19a8..2b20f6387 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -461,17 +461,9 @@ T5, so this test is not end-to-end proof. resolver (OQ1-B1), after F3 and stable-name P1. - [ ] I1 — compass-gateway: pin bump, image env, broker-free smoke. - [ ] P1 — compass-server: pin `DefaultGatewayImage`. -- [ ] Ledger: claim these with `bun tools/dl-claim` when this PR is ready; - the design pass does not claim them. - - DL (server): the `llm-gateway` service bearer is - `/gateway/service.token`. Agents are admitted only through - `VerifyAgentToken`; the optional enroll token admits only the enroll - caller, on `/internal/enroll/*`. - - DL (server): the bundled gateway reaches the Server at - `host.containers.internal` through `pasta:--map-host-loopback`, and the - stack anchor carries that DNS SAN (OQ7). - - DL (server): the boot resolves models per owner through the stable-name - resolver; `/v1/models` stays caller-independent (OQ1-B1). +- [x] Ledger: DL-516 (service bearer, agent-token-only admission), DL-517 + (`host.containers.internal` via `map-host-loopback`), DL-518 (per-owner + stable-name resolution, caller-independent `/v1/models`). ## Resolved decisions From 279a7bddcc8a4777714852241fd7c595f453e089 Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 15 Oct 2026 00:42:16 -0400 Subject: [PATCH 13/13] docs(server): pin the gateway image after F4 (RIG-2862) Co-authored-by: Matt Wilkinson --- docs/designs/server/compass-gateway-boot-wiring.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/designs/server/compass-gateway-boot-wiring.md b/docs/designs/server/compass-gateway-boot-wiring.md index 2b20f6387..862cf45c9 100644 --- a/docs/designs/server/compass-gateway-boot-wiring.md +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -253,9 +253,10 @@ to the store. names `--gateway-external`. - **Order.** Each line is linear: - compass: S0 → S1 → S2 → P1; - - fork: #146 → F1 → F2 → F3. + - fork: #146 → F1 → F2 → F3 → F4, where F4 also waits on stable-name P1. - I1 follows F3's merge to `rigel-release`. P1 also waits on I1 and RIG-5204. + I1 follows F4's merge to `rigel-release`, so the default image carries the + B1 resolver. P1 also waits on I1 and RIG-5204. No Pulumi state is involved. ## Plan @@ -428,7 +429,7 @@ Tests: Lane: compass-gateway. - Bump `fork-pin.json` from `57f41121ff5e…` to the `rigel-release` commit - that holds F1–F3. + that holds F1–F4. - Drop the old `ENV` and its allowlist entry. - `smoke.ts` drops the broker. It runs a fake Server on the host's `127.0.0.1` over https, with a throwaway cert for `host.containers.internal`