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 new file mode 100644 index 000000000..862cf45c9 --- /dev/null +++ b/docs/designs/server/compass-gateway-boot-wiring.md @@ -0,0 +1,541 @@ +# 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" })`. +`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 the +entrypoint to the Compass Server's doors: + +- 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. + +## Approach + +### Fork: a compass boot, not a reshaped `runServe` + +`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`. 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. + - The deadline is `AbortSignal.any([req.signal, AbortSignal.timeout(5_000)])`. + 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. + - **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: 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 + 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, 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. +- **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 + +- **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 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 (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. +- **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 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`. `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. Detached down is unchanged (OQ6). + +## Alternatives considered + +- **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. +- **`--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). + +## Global Constraints + +- **Public repo.** This record and every compass task cite only compass and + RigelBuild/oh-my-pi. +- **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`; + - option types and call sites in `dispatch.ts` and `server.ts`, for F2's + `routeScope` narrowing; + - 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/`. +- **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`. +- **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 passt floor in + `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`; + - 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` | 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.** Both host mappings deliver to the host's IPv4 + `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`. +- **Order.** Each line is linear: + - compass: S0 → S1 → S2 → P1; + - fork: #146 → F1 → F2 → F3 → F4, where F4 also waits on stable-name P1. + + 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 + +The Plan is the long section, because it carries the exact interfaces each +lane builds against. + +### S0 — Pin the verify message + +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. + +### F1 — Verifier and shared transport (fork #150) + +Lane: compass-gateway. Depends on #146. + +```ts +// 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; +export function probeCompassDoor(opts: CompassAgentVerifierOptions): Promise; +// credential-store.ts: #request uses both helpers. +``` + +Tests cover each mapping row above, abort, the probe, and that no token +appears in an error. + +### F2 — Owner pool + +Lane: compass-gateway. + +```ts +// packages/ai/src/compass/owner-pool.ts +export interface CompassOwnerEntry { storage: AuthStorage; store: { hasPendingWrites(): boolean } } +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; + // F2 narrows AuthGatewayRouteOptions.routeScope to Response to match. + sweep(): void; + close(): void; +} +export function openCompassOwner(opts: CompassAuthCredentialStoreOptions): Promise; +``` + +Required tests: + +- **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. 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 + `close()`. + +### 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 function getGatewayBootConfig(env: Readonly>): GatewayBootConfig; +export function readTokenFile(path: string, envName: string): Promise; // replaces readGatewayToken +export function bundledGatewayModels(): Map>; +export function runGatewayBoot(env?: Readonly>): Promise; +``` + +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. + +Tests: + +- 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. + +### S1 — Service bearer ensure + +Lane: compass-server (owns `go/internal/stack/adapters`; reviews S1, S2, P1). + +```go +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 +``` + +`EnsureServiceToken` creates the token's directory at 0700 before its first +write. Tests: + +- 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 + +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 +``` + +- `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. +- **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 + 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: + +- 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 + drain stops the gateway before the server. +- 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 + +Lane: compass-gateway. + +- Bump `fork-pin.json` from `57f41121ff5e…` to the `rigel-release` commit + 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` + (`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. +- 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 + +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: pin `invalid agent token`. +- [ ] S1 — compass-server: `TokenStatus`; `EnsureServiceToken` (makes its dir). +- [ ] 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`. +- [ ] 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`. +- [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 + +**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 +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 +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. + +Host floor evidence. A holds, with a floor: + +- `--map-host-loopback` landed in upstream passt on 2024-08-21 (commit + [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 + ([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 + ([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. + +## Open Questions + +**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).