Skip to content

feat: MU1 multi-user control plane (opt-in, SH_REQUIRE_AUTH=false by default) - #249

Merged
pdettori merged 42 commits into
rossoctl:mainfrom
pdettori:feat/mu1-multi-user-control-plane
Sep 11, 2026
Merged

pdettori merged 42 commits into
rossoctl:mainfrom
pdettori:feat/mu1-multi-user-control-plane

Conversation

@pdettori

Copy link
Copy Markdown
Member

Implements the MU1 multi-user control plane from
docs/specs/2026-09-08-multi-user-control-plane-design.md
(ADR-0033). Before this, the deployment had one shared upstream API key and no notion of a user.

Opt-in and inert by default. deploy/knative/control-plane.yaml is deliberately not in the base
kustomization, and SH_REQUIRE_AUTH defaults to false — so existing single-user deployments see no
behaviour change. The 14 existing deploy/knative scripts that drive /turn and /runs without a token
keep working; a present-but-bad token is 401 in either mode. Flipping the default is a later milestone.

What it adds

  • packages/control-plane (new) — the authenticated /v1 surface: Ed25519 session tokens
    (alg: EdDSA, hand-rolled on node:crypto), AES-256-GCM envelope encryption over per-user Kubernetes
    Secrets, a Redis session-ownership index with ordered cascade delete, GitHub OAuth device flow for
    identity, and a per-turn credential exchange on /internal/credentials.
  • Data-plane integration in packages/knative-server/turn resolves the caller's own credential
    from a session token, on both the buffered and SSE paths.
  • harness/src/run-turn.ts — strictly additive: a tagged UpstreamCredential union and one new term
    at the front of the auth chain. P5's env fallbacks and its write-once ANTHROPIC_API_KEY seed are
    untouched; removing them is P5's to do.
  • docs/api/openapi.yaml — the /v1 contract as a checked-in document, pinned by a drift test that
    compares it against ROUTES, CP_ERROR_CODES and CREDENTIAL_NAME_RE in both directions.
  • deploy/knative/demo-multiuser.sh — a two-account live demo asserting 10 numbered claims, gated on
    MULTIUSER_LIVE_SMOKE, with a cluster-free test of the properties that make it honest.

Design decisions worth a reviewer's attention

  • The token asymmetry is load-bearing. The control plane holds the private key; the data plane gets
    only public keys, as plain config — a compromised harness can verify but not mint. The keyset is a
    comma-separated list so rotation needs no flag day.
  • Credentials are keyed on consumer tier (inference / sandbox-egress / control-plane), not on
    vendor. A multi-secret-field kind is refused for inference at write time, because the exchange
    delivers one Bearer token and basic would otherwise send a username upstream.
  • AAD is subject|name, so relabelling a Secret's annotations cannot repoint one user's ciphertext at
    another's name.
  • RBAC omits list. Secret names derive from the subject, so every access is a get by exact name;
    reaching another user's Secret requires already knowing their subject. list lives in a maintenance
    Role bound to nothing.
  • 404, never 403, for another user's session — 403 is an existence oracle.
  • Fail-closed everywhere. Redis down, kubectl unavailable, control plane unreachable, exchange 401 —
    every one refuses the turn rather than falling back to the deployment-wide key.

Known gap, tracked and guarded

#248 — the control plane currently runs in default, where the harness ServiceAccount holds
unrestricted pods/exec, so code execution in the harness pod can read the signing key from
/proc/1/environ. Spec §8.2 documents this honestly rather than leaving it implicit, and §8.1's
token-forgery row is qualified to the sandbox tier. It is not reachable from the sandbox tier where
model code runs, and it is inert while MU1 is opt-in with auth off.

A CI tripwire in packages/knative-server/test/control-plane-manifest.test.ts refuses the combination
SH_REQUIRE_AUTH=true and a default-namespace control plane, so the flag cannot be flipped without
#248 landing first.

Verification

pnpm -r test1377 passed / 17 skipped across 10 packages. pnpm -r typecheck, make lint (tree
clean after), make test-deploy, and pre-commit run --all-files across all 9 hooks: all green.

Built task-by-task with a per-task review gate, then a whole-branch review, one fix wave and a scoped
re-review. That final review returned CHANGES REQUESTED on three findings — all in deploy/ YAML, none
in the TypeScript — and the two blocking ones are fixed here:

  • an additive NetworkPolicy: the base egress policy default-denied TCP 8080, so on any egress-enforcing
    cluster (including every OCP profile) the exchange hop was dropped and MU1 was non-functional;
  • credential writes returned 503 during a Redis outage, contradicting spec §9.2's promise that they
    stay up — the audit is now best-effort on those two routes, and logged so the gap is discoverable.

Roughly twenty-five defects were found and fixed during development. The dominant class, by a wide margin,
was a test that cannot fail — so several commits here replace weak assertions with derived ones
(comparing against the code's own exported values) rather than adding new coverage on top.

The route table is declared as data so the router, the authz enumeration test
(spec §9.3 test 2) and the OpenAPI drift test (test 3) all read one source of
truth. Errors map to a status in exactly one place (spec §9.1); an unrecognised
throwable becomes a bare internal_error so its text cannot leak a connection
string to an arbitrary caller.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Ed25519 rather than a shared HMAC secret because the harness is the brain tier:
under a shared secret a compromised harness could mint a token for any subject
(spec §5.2). makeSigner() refuses a public key, so 'the verifier cannot sign' is
a property of the API rather than of its callers.

Verification is local arithmetic with no JWKS fetch, keeping spec §9.2's property
that a control-plane outage does not break running work. The kid is derived from
the key (sha256 of its SPKI DER) so it cannot drift, and the keyset is a LIST so
rotation needs no flag day.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…x alg-check test

Important fixes from review:

1. verifyToken now guards against JSON.parse returning null for header/payload
   (e.g., when header decodes to the literal string 'null'). Previously this
   caused a raw TypeError when dereferencing header.alg; now it correctly
   throws CpError(token_invalid).

2. The alg-none test now uses a properly signed token with alg='none' instead
   of an invalid signature. This ensures the test actually exercises the alg
   check at line 188; without it the test would pass when the check is removed
   (previously it silently passed because the structural check rejected the
   empty segment before the alg check could run).

Mutation verification:
- Temporarily disabled alg check: test fails (expected function to throw)
- Restored alg check: test passes
- All 21 token tests pass, full suite 38/38

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…at rest

A Secret is base64, not encryption, and namespace get-secrets reads it. The KEK
lives in a separate Secret mounted only into the control plane, so 'read the
credential store' and 'read the key that opens it' are two RBAC subjects
(spec §6.5).

AAD = subject|name, which buys a property rather than tidiness: an attacker who
can write Secrets still cannot relabel Alice's ciphertext into Bob's row and
spend her key. One opaque decrypt error for every failure, so a caller cannot
learn which half of the AAD it guessed right.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…malformed IV/tag

The createDecipheriv and setAuthTag calls can throw if the IV or tag segment
decodes to the wrong byte length (e.g. a truncated or corrupted iv/tag field
from a bit-corruption or truncation that preserves segment count). These were
outside the try block, leaking raw Node crypto TypeErrors instead of the
module's single opaque 'failed to decrypt credential' message.

This violated both the code comment ("One opaque message for every failure")
and the module's security contract (not distinguishing which part of the
serialization is malformed).

Add regression tests for 4-segment inputs where the IV or tag content length
is wrong: too-short IV, too-long IV, too-short tag, too-long tag. All must
throw the opaque error, not a raw TypeError.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…resolution

Keyed on the CONSUMER TIER rather than the service (spec §6.1): which trust tier
consumes a credential decides whether it can be delivered safely at all, so
keying on the vendor buries the governing property and makes every new service a
schema change. kind is a registry, so SigV4 is an addition rather than a
migration.

Resolution refuses to guess: several inference credentials and none named is
credential_ambiguous, none at all is credential_required AT SESSION CREATION
rather than three turns in (spec §6.4). The name charset is the intersection of
the Secret data-key charset, envelope.ts's AAD delimiter, and URL path safety.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Same shape as packages/k8s-sandbox/src/resolve-pod.ts -- pure builders plus one
injectable runner, so every Kubernetes interaction is unit-testable with no
cluster. kubectl is already in the runtime image, so this needs no client library
and no new dependency.

Patch bodies ride on stdin rather than argv: /proc/<pid>/cmdline is readable by
anything sharing the pod. No Secret operation is ever a list, which is what keeps
spec §6.5's list-free Role usable rather than a 403 waiting to happen.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…encryption

Redis is entirely ephemeral (spec §2.5), so it cannot hold credentials -- a pod
bounce would lose every user's key. The Secret name is derived from the subject,
which is what makes every access a get by exact name, so the runtime Role can
omit the list verb and a bug cannot enumerate users (spec §6.5). A hashed name
also discloses no logins.

Writes are merge patches, not apply: apply would drop the subject's other
credentials, and read-modify-write would lose updates. list() rebuilds
descriptors from annotations, so it never touches the KEK.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The pre-commit prettier hook runs with --write (.pre-commit-config.yaml), so
`make lint` mutates the tree rather than only reporting. Task 5 committed before
that run, leaving the committed blob unformatted while the working tree carried
the formatted version -- which would have misattributed a whitespace diff to
whichever later task next staged these paths.

Machine-generated by the repo's pinned Prettier 3.9.6 and verified by
`prettier --check`; no logic change.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The index lives in Redis BECAUSE Redis is ephemeral (spec §7.1): if Redis is
wiped the sessions are gone, so their ownership records are meaningless, and
co-location means index and data can never disagree.

The owner zset is the only user-facing list path -- LogStore.list() is a
keys('session:*') scan with no owner concept (spec §2.3). Cascade delete is
tombstone → data → index, asserted through the op log, because dropping the index
first leaves data present but invisible. The runtime hash is written through a
field allow-list, so an 'owner' field from a compromised harness is dropped
rather than stored and then trusted.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…dently

The "carries no owner field" test only observed the allow-list through
getRuntime(), which re-filters on read through the same RUNTIME_FIELDS list.
That gave zero regression protection to the write-side property stated in
ownership.ts's class doc: deleting putRuntime's filter and storing the raw
fields spread would still pass, since getRuntime would strip owner on the
way out regardless. Add an assertion against the fake's raw hash store so
the write-side filter is pinned on its own; keep the existing read-side
assertion too.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…der seam

GitHub is not an OIDC provider -- no id_token, no discovery, no JWKS -- so
identity comes from exchanging a code for an opaque token and reading GET /user
(spec §5.1). The DEVICE flow, because demo-multiuser.sh is a shell script and
cannot complete a browser redirect, and because it needs no client secret.

The subject is the numeric id, never the login: a login is mutable and reusable
after account deletion, so a login-keyed subject would let a new account inherit
a departed user's sessions and credentials. The opaque token is used once for
GET /user and dropped -- MU1 stores no GitHub token, since OAuth authenticates
API calls while the stored credential authorizes egress (spec §5.5).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…rmatting

Two important fixes:

1. Prettier formatting: reflowed 10 lines in identity.ts and identity.test.ts
   to respect the 100-char printWidth (lines 82-85, 88, 115, 119-121, 142-144,
   in the original files).

2. Test coverage for null-body guard: the typeof null === 'object' trap in
   parseBody (line 172) was not covered. Added test case where /user returns
   literal JSON null, verifying that the || parsed === null clause prevents a
   TypeError when accessing properties on a null object. Verified mutation:
   removing the clause causes the test to fail with a TypeError instead of
   throwing CpError('unauthorized', ...).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
One ownership choke point, because the failure mode is authz scattered
per-handler where the fifth endpoint someone adds forgets the check (spec §5.4).
A non-owner and an unknown session both return 404 with the same code, so a 403
cannot act as an existence oracle; admin does not bypass it, since the role gates
?owner= on the list route only.

POST /v1/sessions resolves the inference credential at CREATION and records the
choice, so a missing key fails there rather than three turns in, and a later
second credential cannot turn a running session ambiguous. It never consults the
deployment's ANTHROPIC_AUTH_TOKEN -- the first of the two policy points that make
MU1 fail closed before P5's sentinel lands (spec §3.5).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
PUT is write-only and no read-back path exists anywhere in /v1 (spec §4.2), so a
compromised api token cannot exfiltrate a stored provider key. The subject comes
from the token, never the path or the body, so there is nothing to spoof -- and a
smuggled subject field is ignored rather than honoured.

GET returns metadata only and decrypts nothing, which is what the annotation-based
descriptor buys (spec §6.2). DELETE is 204 whether or not the credential existed:
a 404 there would be an existence oracle over credential names.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…onse

The binding field contains the credential binding template (e.g. 'Bearer {token}')
and is specified in spec §6.2 as explicitly non-secret metadata. It is part of the
OpenAPI contract and required in the CredentialDescriptor. The {token} placeholder
in the format string is a template variable, not a credential value.

Update the test assertion to verify field shape exactly (no added secret fields,
exactly six descriptor fields) rather than attempting a blunt substring ban that
fails on correct output containing template variables. The stricter shape assertion
catches accidental leaks while allowing legitimate metadata.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… outage

No session -> pod index exists (spec §2.4: a lease member is the run id, not the
session id), so the harness self-reports into sh:cp:session:<sid>:runtime. That
hash is written by the brain tier, so the projection emits only fields it knows
by name -- an 'owner' written there cannot surface as though the control plane had
blessed it (spec §7.4).

A Kubernetes outage yields sandbox.phase 'unknown' rather than a 500: for an
introspection endpoint, partial data with explicit unknowns beats an error
(spec §9.2). lease and queue are null rather than zeroed, so MU2 filling them in
is visibly a change.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…uard

The test 'reports unknown when the harness reported neither' did not verify that
the kubectl runner was never invoked. Without asserting on the `called` flag,
the test passes whether the early-return guard on line 29 is present or deleted --
if deleted, run() would throw on an undefined selector, and the catch block would
return the identical result.

Added a `called` flag and assertion to verify the early-return path executes and
the kubectl invocation is avoided. This prevents a malformed kubectl call on every
request for a session whose harness reported no sandbox.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…hout

The single most important behaviour in the design (spec §9.2): the control plane
never falls back to the environment. A deleted credential, a tombstoned session,
or a token whose subject is not the owner all REFUSE -- and the refusals hold with
the deployment's own ANTHROPIC_AUTH_TOKEN present in the environment, which is
what makes MU1 fail closed before P5's sentinel lands (spec §3.5).

anthropicBaseUrl is never returned undefined: run-turn.ts:313's || would fall
through to the environment and send one user's gateway token to the default
Anthropic endpoint. A credential whose destination cannot be resolved is a
misdirected secret, not a degraded request, so it is refused.

Exchange auth is a shared bearer, fail-closed with none configured, compared in
constant time and never logged with the presented value (spec §5.3.1). Placeholder
mode wins whenever an injector exists, so adding one strictly narrows what the
harness may hold (spec §3.6).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ound 1)

checkExchangeAuth had no test with equal-length, different-content
tokens, so timingSafeEqual's own comparison was never exercised (only
the length-mismatch shortcut before it). Add a same-length case.

The api-scoped-token rejection test minted a token with no sid, so
the downstream !claims.sid guard independently threw token_invalid,
masking whether requiredScope: 'turn:write' was actually enforced.
Mint the api token with a valid sid against a real session instead.

Both gaps verified by mutation: temporarily removing the guard under
test makes the corresponding test fail; both guards restored exactly
afterward. No change to src/exchange.ts or src/handlers.ts.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ion test

The router is driven entirely by ROUTES, so 'which routes need a token' and
'which go through assertOwner' are enumerable data rather than lines scattered
through handler bodies. The enumeration test (spec §9.3 test 2) then proves every
session-scoped route 404s a non-owner, so a sixth endpoint added without the check
fails CI instead of shipping -- and it guards both directions, a route with no
handler and a handler with no route.

Config fails fast at startup: a control plane booted without a KEK would accept
credential writes it cannot encrypt, and one without an exchange token would 401
every turn from a healthy-looking pod. ALLOW_OPERATOR_FALLBACK is read as exactly
'true' so a typo cannot switch it on.

Two deviations from the literal reference implementation, both needed to make the
reference's own tests pass cleanly rather than to change behavior:

- readBody() in server.ts drains the socket to 'end' instead of calling
  req.destroy() the instant the 64 KiB cap is hit. Destroying mid-stream while
  unread bytes still sit in the kernel's receive buffer races an abortive RST
  against the 400 response, so the client sees ECONNRESET instead of the error
  body. Confirmed with two standalone repro scripts before touching the real
  file; memory stays bounded because chunks stop being pushed at the same point.
- startControlPlane() now removes its SIGTERM listener on the server's 'close'
  event. Called once per test across many server-starting tests, the literal
  version leaks one process-level listener per call and trips Node's
  MaxListenersExceededWarning well before a real suite's tenth test.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…g auth-separation comment

Fix round 1 on the router/entrypoint task, per code review. Two one-line
fixes, both confined to test/server.test.ts -- src/server.ts and
src/main.ts are unchanged.

- The fabricated Redis URL in the error-hygiene test's thrown Error was
  missing its `# notsecret` marker; every other fabricated secret-shaped
  literal in the file carries one on its own line.
- "does not accept the exchange token as an /v1 credential" fails
  verifyToken's structural JWT check before requiredScope is ever
  evaluated, so it does not exercise scope separation as its old comment
  claimed (that property is covered by the session-token-on-/v1 test
  above). No fixture can make it depend on scope -- the exchange token is
  an opaque secret that never parses as a JWT -- so the test stays as a
  guard against a future exchange-token shortcut inside authorize(), and
  only its comment now says so.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Spec §3.4 says MU1 need not touch run-turn.ts and §3.6 requires a tagged
credential that lives there; both hold, because §3.4 is about behaviour (:306
already prefers config over the environment) and §3.6's tag is a type. So this is
one type, one optional field, and one new leading || term -- nothing deleted.

The tag is not ceremony: a bare string makes placeholder and direct mode
indistinguishable, and both failure directions are silent -- a misconfigured
injector sends a placeholder upstream, and a deployment that later grows an
injector has its real key rewritten.

P5 still owns :310-312 and the two env fallbacks; deleting the seed without
P5's sentinel would make ANTHROPIC_API_KEY absent and break gateway mode outright
(spec §3.5). A test pins that they are still there.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ession token

POST /turn enforces exactly one rule -- token.sid === body.sessionId -- and does
no ownership lookup, because it holds no ownership data (spec §4.3). The subject
is token.sub, never an inbound header: a request carrying both a token and a
conflicting X-SH-Subject is rejected rather than resolved by precedence, since a
silent winner there is a cross-tenant bug waiting to be written (spec §3.5).

SH_REQUIRE_AUTH defaults to false because 14 deploy scripts call /turn with no
auth today, but a present-but-bad token is 401 in EITHER mode: the flag governs
whether auth is required, never whether it is enforced (spec §4.3.1).

An unreachable control plane fails the turn -- it never falls back to the
environment (spec §9.2) -- and an authenticated turn does not carry the ambient
token in TurnConfig at all. A token-borne sid binds createIfAbsent:true, since it
was minted by the trusted tier and the 404 contract exists to stop a client
resuming an id it invented.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…orter

Fix round 1 for Task 15, addressing two Important review findings:

- Add tests for the authenticated SSE branch of /turn (handleTurnStream),
  which had no coverage: assert createIfAbsent:true, the tagged direct
  credential, and the dropped ambient token when a valid session token
  is presented over Accept: text/event-stream, plus a companion asserting
  createIfAbsent:false is unchanged for an unauthenticated SSE request.

- Fix makeRuntimeReporter so a transient Redis connect failure no longer
  permanently disables the shared, process-lifetime reporter: the catch
  now clears the memoised ready/index state so the next call retries,
  instead of forever awaiting an already-rejected promise. Add a test
  that stubs a first-attempt connect failure followed by a successful
  retry.

Both fixes verified by mutation testing (temporarily reintroducing each
bug, confirming the new test fails, then restoring).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The /v1 surface is delivered as a document rather than as prose, and a drift test
asserts it and src/routes.ts describe the same route table in both directions --
without it, 'API as a product' degrades into documentation that lies (spec §9.3
test 3). The test also pins the credential-name pattern to the one the code
enforces, and asserts no /v1 response schema returns a credential value.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
A plain Deployment, not Knative: it holds a signing key, mints tokens for
cron-fired runs with no client present, and is the trusted tier -- scale-to-zero
would buy nothing and cost a cold start on every list (spec §3.1).

The RBAC is the containment. The serving Role grants
get/create/update/patch/delete on Secrets and OMITS list, so a bug cannot
enumerate users; list lives in a maintenance Role bound to nothing. That is only
expressible because the credential store has its own namespace -- Kubernetes RBAC
filters by resourceNames, never by label (spec §6.5). The KEK comes from a
different Secret than anything it opens.

SH_REQUIRE_AUTH defaults to false and the manifest is deliberately not in the base
kustomization: MU1 ships opt-in, and flipping both is MU2 (spec §4.3.1, §10).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Two real GitHub logins, because the whole point is that the subject is attested by
GitHub rather than asserted by the caller -- the script cannot fabricate two
subjects. It runs with SH_REQUIRE_AUTH=true so the property demonstrated is the
real one rather than the permissive default, and restores false on exit including
on an aborted run.

The load-bearing claim is Claim 9: a subject with no stored credential gets
credential_required while the deployment's own ANTHROPIC_AUTH_TOKEN is mounted on
the Service. The narration also says out loud what slice 1 does NOT isolate --
the sandbox pool is shared (spec §8.2) -- because a demo that quietly implies
isolation it lacks is worse than no demo.

The cluster-free test cannot run the demo, but it can pin the things that make it
honest or dangerous: the skip-not-fail gates, the flag being set and restored, no
token in the transcript, and the shared-pool disclaimer being present.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
harness-egress-policy.yaml is default-deny egress on the harness pod and its
allowlist predates MU1: DNS, Redis 6379, relay 8443, 0.0.0.0/0 on 443 and 6443.
The per-turn credential exchange targets http://sh-control-plane.default.svc:8080
-- TCP 8080, which no rule there permits -- so on any egress-enforcing cluster
(OVN-Kubernetes on OCP, modern kindnet) the exchange fetch is dropped and every
authenticated turn ends in 503 credential_unavailable. Fail-closed, but MU1's
central data path is dead.

NetworkPolicies union, so ship an ADDITIVE policy in control-plane.yaml and leave
the base file untouched: MU1 is opt-in, and opting out must stay exactly "do not
apply control-plane.yaml" rather than widening the base allowlist for every
deployment that never runs a control plane.

control-plane-manifest.test.ts said nothing about egress, which is why eighteen
task reviews missed this. It now asserts the policy allows the control plane's
own pod labels on the port PARSED OUT of SH_CONTROL_PLANE_URL (derived, not
transcribed), that its podSelector equals harness-egress-policy.yaml's -- two
independently authored files compared -- and that the base allowlist still does
not contain that port.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…spec §9.2)

Spec §9.2 promises "Redis down => session routes 503, while /v1/credentials stays
up, because §7.1 put them in different stores". The code broke the promise:
putCredential and deleteCredential patch a Kubernetes Secret and then await
index.audit(), which is a Redis write, and OwnershipIndex.guard turns any
transport failure into redis_unavailable (503). So with Redis down the Secret was
written successfully and the caller was told 503 -- a status that lies, and a user
who cannot repair a broken credential during an outage.

Preserve the spec's property rather than narrowing it to the code: the audit
stream is the right thing to sacrifice for the repair path. auditBestEffort()
swallows the audit's failure on those TWO routes only -- not on audit globally,
and never on the session routes, whose 503 is honest because their own state is
in the Redis that is down. The swallow logs route + credential NAME to stderr so
an audit gap is discoverable; the value is never passed to it at all.

The §9.2 test exercised only listCredentials, the one credential route that
touches no Redis, so it could not fail on this. It is now two-sided, and its
fixture is fixed too: it built the outage by overriding the INDEX's methods,
which bypasses guard and yields plain Errors, so nothing ever exercised the
redis_unavailable mapping the promise rests on. It now wraps a real
OwnershipIndex around a Redis client whose every command rejects.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…HROUGH itself

PASSTHROUGH was a hand-written, unexported Set of eight codes, and its covering
test restated four of them in a second hand-written list -- so half the set was
untested and the two lists could drift apart silently. A typo in one member
degrades that refusal to credential_unavailable, and a mistyped code reaching
statusFor makes res.writeHead(undefined) throw inside the error path, turning an
intended refusal into a 500 with a stringified error.

Export it; iterate it in the test rather than restating it, deriving each reply's
status from statusFor so the fixture cannot drift from the taxonomy; and assert
every member is in the real CP_ERROR_CODES, which is the assertion that makes a
typo unshippable.

Both directions checked: typoing 'unauthorized' fails the CP_ERROR_CODES
assertion, and short-circuiting the PASSTHROUGH.has() branch fails the
propagation loop.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
demo-multiuser.test.sh checked almost everything with `grep -q '<string>'` over
the demo's own text, which a MENTION anywhere satisfies -- comments included. Two
holes the review demonstrated: `SH_REQUIRE_AUTH=true` also appears in the demo's
header comment at :20, so deleting the real `set_ksvc_env SH_REQUIRE_AUTH=true` at
:133 left the check green (verified: 4 remaining matches, all in comments or echo
strings); and `asserts a cross-tenant 404` grepped for the literal `Claim 4`, a
label rather than a behaviour.

Convert the three load-bearing ones to mocked-kubectl call-log assertions, the
pattern every sibling in this directory already uses (Makefile:15-19): the flag
patched to true, restored to false by the EXIT trap on an aborted run, and
`apply -f control-plane.yaml` actually invoked -- plus the flip-before-restore
ordering, which is what makes the restore reachable at all. The demo is driven
with its live gates satisfied and every external tool mocked, so it reaches the
device-flow login and dies there, which is after all three.

The rest are anchored to a command position (`^[^#]*= credential_required`, the
real cp_code line) so a comment can no longer satisfy them. The secret-leak guard
is untouched -- it was verified correct in both directions.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…e text

The test at model-gateway.test.ts asserted the source text of run-turn.ts contains
'process.env.ANTHROPIC_AUTH_TOKEN' and 'process.env.ANTHROPIC_BASE_URL'. Both
identifiers appear exactly once today, so it passed by luck of the current file:
it pinned that an identifier exists, not that it is the right-hand side of a `||`,
and deleting `config?.anthropicAuthToken ||` from run-turn.ts:331 left it green.
The sibling tests already cover both fallbacks behaviourally, so two thirds of it
was redundant and the last third was weak.

Replaced with two behavioural assertions on the one construct that had no
behavioural proxy -- P5's write-once seed at run-turn.ts:336-338. It seeds
ANTHROPIC_API_KEY from the SUBJECT'S credential in a fresh pod, and it does not
overwrite one already set. That pins the seed (P5's to remove, not MU1's) and
makes its reach an asserted property rather than an unexamined one: in direct
mode the first authenticated turn writes that subject's key into the process
environment for the pod's lifetime. Reachability is narrow -- service.yaml makes
ANTHROPIC_API_KEY a required secretKeyRef -- but it belongs in a test.

run-turn.ts is deliberately unchanged. Both directions checked: deleting the seed
fails the first, making it unconditional fails the second.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The mechanical items from deferred-findings.md, applied exactly as scoped.

B1 k8s-secret-store: a typed guard replaces `JSON.parse(raw) as SecretJson`, and a
`guard()` mirroring OwnershipIndex.guard maps a kubectl/API failure to
credential_unavailable (503) instead of letting it escape as a plain Error and
become 500 internal_error. The taxonomy already had the code and nothing mapped
to it. kubectl's stderr never reaches the message -- it can name a token.
B2 listByOwner: nextCursor now comes from the tail ZSET MEMBER's score (new
zScore on CpRedisLike) rather than the last surviving record, so a ghost at a page
tail is no longer re-visited on every later page, and an all-ghost page no longer
truncates the walk to null.
C1/C2 two unused imports. D1 a redundant re-encode assertion. D4 the
"tombstone before delete" test asserts the recorded op ORDER, which is the
property; final state was identical either way.
E1 errors.test.ts loops CP_ERROR_CODES against an independently transcribed
status map, closing four codes that only had a `>= 400` check. E2..E7: the
null-header/payload regression, consumer 'control-plane' (four cases, previously
zero), zRange/zScore in the outage test so listByOwner is covered, the clientId
constructor guard, the no-tab selector reply, and token_invalid (not just 401) on
the route-level bad-token test.
F1 the redundant `key!` and a no-interpolation template literal -- which required
making `bad()` a function declaration, since TS does not narrow through a
never-returning arrow const (the footgun credential-store.ts already documents on
its own `invalid()`). F2 asserts asymmetricKeyType === 'ed25519' at parse time.
F3 one `invalid(...)` call style. F4 a named `isCredentialBinding` guard replaces
the double cast. F5 a new `unregisterKind` plus an afterEach, so registerKind no
longer widens the module-level KINDS map for the rest of the file. F7 the two
notsecret markers. F9 the orphaned JSDoc moves to makeRuntimeReporter, which had
none, rather than being discarded. F11 optional:true on the control-plane side's
SH_EXCHANGE_TOKEN ref, and the header now says `kubectl describe pod` and
explains which failure mode writes a log and which does not.
`iss` is now verified, not merely minted. ALLOW_OPERATOR_FALLBACK is renamed
SH_ALLOW_OPERATOR_FALLBACK. openapi.yaml's description says it covers the control
plane only, since the data plane also serves /v1 paths.

Dropped as instructed: B3, F6, F8, F10 (withdrawn -- both tokens are 10 chars).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
server.ts read and JSON.parsed the body before authorize(), so an unauthenticated
PUT /v1/credentials/x with malformed JSON answered 400 invalid_json instead of
401 -- telling an anonymous caller something about the route's body handling before
it was established that it may talk to the route at all. No information of
consequence leaked and the 64 KiB cap bounded the read, but authentication belongs
in front of it. `authorize` reads only headers, so the order is free. Every route
here is new in MU1, so no existing caller depends on the old codes.

Two tests, because the reorder could also have swallowed the invalid_json path
rather than moving it: unauthenticated + malformed is 401 token_required, and
authenticated + malformed is still 400 invalid_json. The request helper gained a
rawBody option so a test can send bytes JSON.parse will reject.

The data plane's readBody (knative-server/src/server.ts) is deliberately untouched:
it has no size cap at all, which is a larger and pre-existing hole needing its own
limit decision.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…e ADR

The env var was renamed for prefix consistency in 0a0c0fd (pre-merge is the only
cheap moment: renaming after operators adopt it is a breaking change). These two
references were the last occurrences of the bare name outside docs/plans/, so
without this the spec and the ADR document a variable no deployment reads.

Mechanical and factual: the identifier only, on the two lines that name it. No
surrounding prose touched, and nothing in §3.6 or §8.1 -- the two spec statements
the whole-branch review found false of the shipped code remain the repo owner's to
correct, and are deliberately untouched here.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Security review of the MU1 branch found three statements in the design
docs no longer match the shipped code; the code is correct, the docs
were stale.

- Spec §8.1's "Token forgery" row claimed the harness cannot mint a
  token at all. True at the sandbox tier; false from code execution in
  the harness pod, where an unscoped pods/exec grant into `default`
  reaches the control-plane pod's environment and its Ed25519 private
  key (service.yaml:113-127, control-plane.yaml:138,200-214). Row
  qualified to the tier that actually holds; ADR-0033's matching
  Positive bullet updated the same way.
- Spec §8.2 named only the shared sandbox pool as an unguaranteed
  property. Added a second item naming the namespace-collocation
  exposure above, why it's bounded to harness-tier code execution and
  not the sandbox tier, and the intended fix (move sh-control-plane
  and its Secrets into sh-credentials, split the pod-phase RBAC).
  Tracked as follow-up, not done in slice 1. ADR-0033 gets a matching
  Follow-up-owed bullet.
- Spec §3.6 claimed P5's lock-down invariant wants environment scoping
  "which MU1 never writes." MU1 does: run-turn.ts:336-338's
  write-once-if-absent seed, now resolving upstreamCredential first
  (:329-332), writes a real per-subject key into the process
  environment on the first direct-mode turn. Corrected to say so,
  cited against harness/test/model-gateway.test.ts's pinning test, and
  framed as P5's construct (MU1 forbidden from removing it) — MU1's
  already-declared divergence from P5 §5 is wider than stated, not a
  second undeclared one.

No code, manifest, or test changed. pnpm -r test and make test-deploy
totals unchanged (1375 passed / 17 skipped). Full diff rationale in
.superpowers/sdd/2026-09-09-mu1-multi-user-control-plane/spec-correction-report.md.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ontrol plane

Adds a guard against the flag flip landing before the control plane moves
out of namespace default. While collocated, the harness ServiceAccount's
unrestricted pods/exec lets anyone with code execution in the harness pod
read the session-signing key, the credential KEK and the exchange token
from sh-control-plane's /proc/1/environ (spec 8.2). The assertion is a
conjunction read from both manifests so it stays green through today's
false+default state and the intended true+sh-credentials end state,
failing only on the dangerous combination. A canary case pins the current
shipped values and is expected to need updating in the same change that
performs the namespace move.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The comment cited control-plane.yaml:129 and :191-205 for the collocation mechanism;
the Deployment's `namespace: default` is at :138 and the three secret env injections
run :200-214. Both had drifted +9 earlier in the branch. The comment exists so a
future reader can find the mechanism, so a stale citation is the one defect that
makes it useless.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The section was written so a link could be dropped in once the follow-up existed.
Names rossoctl#248 as a blocker on SH_REQUIRE_AUTH=true, and points at the CI tripwire
that refuses the combination until it lands.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>

@cwiklik cwiklik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass on 628feaa. 58 files, +9722/−46, 40 commits — reviewed the security-critical path in full (token mint/verify, envelope encryption, the exchange, the ownership choke point, the kubectl argv builders, RBAC) rather than by summary, and spot-checked the rest.

One must-fix, three suggestions. The must-fix is not in the crypto or the authz — those hold up. It is a keyset parse that runs per request outside a try block, which turns an operator typo into a 500 on every /turn including unauthenticated ones, and falsifies a property token.ts states in a comment.

What I verified rather than took on trust

The body makes a lot of specific claims. These check out against the tree:

Claim Verified
RBAC omits list; maintenance Role bound to nothing control-plane.yaml:80 is get,create,update,patch,delete; credentials-maintenance appears exactly once in the whole deploy/ tree — its own definition, no RoleBinding
Every Secret access is a get by exact name secretNameFor is sh-cred-<sha256(subject)[0:16]>; K8sSecretStore.list() is a single get filtered in process, so the list verb genuinely is not needed
A compromised harness can verify but not mint makeSigner refuses a non-Ed25519 and a public key; data plane only ever calls parseKeyset/verifyToken
The three secrets are separately mounted sh-session-token-key, sh-credential-kek, sh-exchange-token — three distinct Secrets, and a test pins the exchange name/key parity across both tiers
/turn auth covers the SSE path resolveTurnAuth is at server.ts:147, before the wantsStream branch at :153, and auth is threaded into handleTurnStream. This is the bypass #239's spec got wrong by parking :174; here it is right
Deleting a session actually revokes exchange.ts:75-82 re-checks existence, owner match and tombstone on every turn, so local verification does not outlive a delete
404 never 403 for another user's session assertOwner:85 collapses unknown and non-owner into one session_not_found; admin deliberately does not bypass it
The #248 tripwire is real It is a conjunction read from both manifests with .toBe(false), plus a canary pinning false+default so changing either half alone still fails

Two things worth calling out as load-bearing and correct: verifyToken pins alg before the key lookup and parses claims only after cryptoVerify, so a forged exp cannot produce a token_expired oracle; and because the Secret name is a hash, nothing caller-controlled reaches spawn('kubectl', args) in leading-dash position — argv injection is closed by construction rather than by validation.

The unauthorized and KEK items below are both cases where the design was careful in one place and did not carry the same care to its neighbour. Neither is a bug today; both get materially more expensive after MU1 is switched on once.

Author: pdettori (MEMBER — maintainer)
Areas reviewed: TypeScript (control-plane, knative-server, harness), Kubernetes/YAML, Shell, Docs, Tests, Build
Agent/IDE config (.claude/.vscode): none — grepped both +++ b/ and rename to forms; 0 renames anywhere in the diff
Commits: 40, all signed off, all Assisted-By per house convention, conventional prefixes throughout
CI status: passing (12/12 on 628feaa — DCO, CodeQL, Trivy, shellcheck, hadolint, dependency-review, lint, proto, deploy-scripts all green)
Base: 45a218d; mergeable_state: blocked pending review

Comment thread packages/knative-server/src/server.ts Outdated
return;
}

const deps = turnAuthDeps();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fixturnAuthDeps() is outside the try that starts on the next-but-one line, and it can throw.

turnAuthDepsFromEnv calls parseKeyset (turn-auth.ts:64), which throws a plain Error — not a CpError — on three conditions: a malformed entry (token.ts:103), a non-Ed25519 key (:112), and a kid that does not match its key (:119). Because the throw lands outside the try at :146, writeAuthError never sees it. It propagates to the route's own handler at :637:

res.writeHead(500, JSON_HEADERS).end(JSON.stringify({ error: String(err) }));

So a malformed SH_SESSION_TOKEN_PUBLIC_KEYS produces, for every POST /turn and POST /v1/turn:

500 {"error":"Error: SH_SESSION_TOKEN_PUBLIC_KEYS entry '<kid>' is not a base64 Ed25519 SPKI ..."}

Three separate problems, in increasing order of how much they matter:

  1. Internal error text to an arbitrary caller. Low severity on its own — the keyset holds public keys, and the design is explicit that those are not secrets — but it is the pattern errors.ts:89-92 deliberately refuses ("an arbitrary error's text can carry a Redis connection string or a presented token"). The control plane hardened this; the data plane's new auth path feeds the unhardened site.

  2. It breaks the unauthenticated path too. The throw happens before anything looks at requireAuth or at whether a token was presented. A deployment adopting MU1 that fumbles the keyset does not get "MU1 is broken" — it gets /turn returning 500 for everyone, including the 14 existing no-token deploy scripts. The whole opt-in design exists so that turning MU1 on cannot disturb them, and this is the one path where a config error does.

  3. token.ts:63-68 claims this cannot happen, and that claim is the rationale for the curve assertion:

    Asserting the curve here fails at PARSE time instead: parseKeyset runs at startup on both tiers, so a mislabelled key becomes a boot error naming the real problem.

    It runs at startup on one tier. main.ts:57 parses at control-plane boot, so the property holds there. On the data plane parseKeyset is reached only through turnAuthDepsFromEnv, which server.ts:98 calls per request — there is no startup call anywhere in packages/knative-server/src. And /healthz and /readyz do not touch the keyset, so the pod goes Ready and stays Ready while every turn 500s. That is precisely the "failure arrives per request on a healthy-looking deployment" outcome the comment says it prevents.

The easiest trigger is the one the keyset design exists to support: rotate a key, publish the new one alongside the old, forget to update the kid. parseKeyset:118-122 rejects that — correctly — and on this tier the rejection surfaces as a total /turn outage with no boot-time signal.

Fix, two halves, both small. They address different problems, so I would take both:

  • Validate at startup. Call parseKeyset(process.env.SH_SESSION_TOKEN_PUBLIC_KEYS) once before server.listen(...) (:653) and let it throw. That is what makes token.ts:68's "on both tiers" true, and it converts an operator typo from a silent per-request 500 into a crashloop with the reason in the container log.
  • Keep the per-request read, but make it typed. Move const deps = turnAuthDeps() inside the try at :146 and have turnAuthDepsFromEnv wrap the parseKeyset call so a keyset failure becomes new CpError('credential_unavailable', 'the token keyset is not usable') → 503 with no raw text. Defence in depth for the case where the env changes after boot, which is the whole reason the read is per-request.

If you would rather not add a boot check, the second half alone fixes the 500 and the disclosure; but then token.ts:63-68 needs its "on both tiers" claim corrected, because a future reader will otherwise rely on a boot error that does not exist.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 206b538, both halves.

I reproduced the 500 before changing anything — a test that posts to /turn with no token after setting SH_SESSION_TOKEN_PUBLIC_KEYS=garbage returned exactly 500 with the stringified parse error, on the unauthenticated path, as you described.

  • Boot check. assertKeysetUsable(process.env) in startServer, before createServer — so nothing binds with an unusable keyset. It deliberately rethrows parseKeyset's own message, since that one is read by an operator in a container log and its whole value is naming the bad entry.
  • Typed per-request read. keysFromEnv wraps the parse and throws CpError('credential_unavailable', 'the token keyset is not usable'); const deps = turnAuthDeps() moved inside the try at :146. Fixed message, so no entry text reaches a caller.

On your point 3: rather than just adding the call, I rewrote the token.ts comment to name both call sitesmain.ts via configFromEnv, and turn-auth.ts's assertKeysetUsable from startServer — and to state that /healthz and /readyz never touch the keyset, so dropping either call returns it to a per-request failure on a Ready pod. The prose claim is what drifted; naming the sites makes the next drift visible. Same paragraph added to spec §5.2.

Also tightened a test this exposed: turn-auth.test.ts had a bare expect(...).toThrow() on a malformed keyset, which passed for either a plain Error or the typed one — i.e. it could not have caught this. It now asserts the code and the 503, that the message carries no entry text, and covers all three parseKeyset failure modes rather than one, including the kid-mismatch rotation footgun you called out as the easiest trigger.

'session_not_found',
'token_invalid',
'token_expired',
'unauthorized',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestionunauthorized is the one member of this set that is never about the caller, and passing it through reports a deployment misconfiguration as the user's fault.

Every other member is caller-attributable: credential_required/credential_ambiguous/credential_not_found are the subject's credential state, endpoint_unresolved is their credential's endpoint, session_not_found is their session, token_invalid/token_expired are their token. A caller can act on all seven.

unauthorized on /internal/credentials has exactly two sources, and neither involves the caller:

  • checkExchangeAuth (exchange.ts:48-62) — the shared bearer, i.e. SH_EXCHANGE_TOKEN
  • the defensive re-check at handlers.ts:389 — a routing bug

The user's token is checked separately inside exchangeCredential, and it fails as token_invalid / token_expired / session_not_found. So unauthorized reaching this tier means precisely one thing: the harness cannot authenticate to its own control plane.

What the user sees is statusFor('unauthorized') = 401 (errors.ts:47), which reads as "your token is bad." It is not, and no user action fixes it — they re-run the device flow, get a brand-new token, and it fails identically. Meanwhile the same class of fault reported one line earlier is 503: credential_unavailable covers "control plane unreachable" (:114), "returned a non-JSON body" (:122) and "returned an unknown credential mode" (:137). A control plane that is unreachable is a 503; a control plane that rejects the harness is a 401. Same fault, opposite blame.

Two practical consequences:

  • The outage is invisible where you would look for it. 5xx alerting and retry/backoff see nothing; the dashboard shows a 401 spike that looks like users fumbling tokens. This bites hardest on first rollout and on exchange-token rotation — exactly when SH_EXCHANGE_TOKEN is most likely to be wrong on one side, which is why the manifest test pins the name/key parity across both tiers in the first place.
  • If any client treats 401 as "token is dead, re-authenticate", a one-sided rotation makes every client discard a valid session token and stampede the device flow. I have not checked the clients here, so treat that as a possibility rather than a claim — but it is the reason I would not leave the status wrong.

Fix: delete 'unauthorized' from this set. It then falls through to throw new CpError('credential_unavailable', 'control plane returned ${res.status}') at :130 — a 503, with the status preserved in the message for whoever reads the log. One line, and it puts this failure in the same bucket as the three neighbours it belongs with.

Worth a line in the PASSTHROUGH doc comment too: the invariant that makes this set safe is "every code here is attributable to the caller," and stating it is what stops the next code being added on the weaker test of "the control plane can return it."

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 206b538'unauthorized' deleted from the set.

I checked the two sources before removing it. exchange.ts:54 and handlers.ts:390 are the only ones reachable via /internal/credentials; identity.ts throws unauthorized in five places but all are on the device-flow routes, which the data plane never calls. And exchangeCredential fails the user's own token as token_invalid / session_not_found well before any of that. So the reclassification is safe in the sense that matters: no caller-attributable failure gets swallowed by it.

It now falls through to credential_unavailable at :130 with the status in the message, and there is a test asserting the message still contains 401 — reclassifying should not cost the operator the diagnosis.

Took the doc-comment suggestion too, and made it the lead paragraph rather than a footnote: the invariant is that every member is caller-attributable, with the explicit note that "the control plane can return this code" is the weaker test that let unauthorized in. Pinned by expect(PASSTHROUGH.has('unauthorized')).toBe(false) so the decision is a test and not just prose.

On your unverified possibility — a client treating 401 as "token is dead, re-authenticate": I did not chase it either, and it does not change the fix, so I have left it out of the comment rather than assert something I have not checked.

// reporter for the rest of the process's life: clear the memoised state so the NEXT call
// retries from scratch, rather than forever awaiting an already-rejected `ready` (fix round 1,
// Important 2). Display-only data, so the turn itself must never fail because this did.
ready = undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — clearing the memoised state does not close the client it discards, so this leaks a Redis connection per failure — the same class of leak sharedRuntimeReporter was built to prevent.

The round-1 fix is right about the bug it targets: without clearing, ready stays a rejected promise forever and the reporter is dead for the life of the process. But client is block-scoped at :257 and is never referenced again, so after ready = undefined nothing holds it and nothing has called quit().

The post-connect case is the one that definitely leaks. Take the scenario the comment itself names — a Redis restart mid-rollout:

  1. connect() resolves, index is set, calls succeed.
  2. Redis restarts. putRuntime throws. The catch clears ready and index.
  3. The client from step 1 is still connected (or reconnecting under node-redis's default reconnectStrategy) and is now unreachable — no reference, no quit().
  4. The next call builds a fresh client. Steady state is one live client plus one orphan per clearing event.

Each flap adds an orphan, and each orphan keeps retrying on its own timer. Bounded in practice by pod lifetime — and this is display-only data on a best-effort path, which is why this is a suggestion and not a must-fix — but it is a slow leak in the one function whose sibling comment (:218-224) is entirely about not opening a connection per turn.

Hoist the client so the catch can close it:

let client: ReturnType<typeof createClient> | undefined;
let index: OwnershipIndex | undefined;
let ready: Promise<void> | undefined;
return async (sessionId, fields) => {
  try {
    if (!ready) {
      client = createClient({ url: redisUrl });
      const c = client;
      ready = c.connect().then(() => {
        index = new OwnershipIndex(c as unknown as CpRedisLike);
      });
    }
    await ready;
    await index?.putRuntime(sessionId, fields);
  } catch {
    // Close the client we are about to orphan, or a flapping Redis accumulates one connected,
    // retrying client per failure. quit() may itself reject; that must not mask the retry.
    void client?.quit().catch(() => undefined);
    client = undefined;
    ready = undefined;
    index = undefined;
  }
};

The const c = client capture matters: without it the .then closes over the mutable client, which the catch has already set to undefined by the time a slow connect() resolves, and index silently never gets built.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 206b538, with one deliberate divergence.

Not quit(). In node-redis 6 quit() is marked @deprecated use .close instead, and disconnect() likewise defers to destroy() (@redis/client/dist/lib/client/index.d.ts:424-439). Of the two live options, close() "waits for pending commands" — against a server that has just gone away, which is the case that lands here — and destroy() "rejects all commands immediately". For a client being abandoned on a failure path, immediate is what we want, so it is destroy().

That also means no promise to swallow, but destroy() throws on a client that never opened, so it is wrapped — and the state clearing happens before the close attempt, so a throw there cannot skip the retry your round-1 fix exists for.

I took the const c = client capture as written. It is not strictly reachable today — ready is assigned synchronously right after, so no other call can enter the if before the .then — but with client now mutable it is a one-line guard against exactly the silent failure you describe, and the comment says why rather than leaving a future reader to re-derive it.

Four tests, all red before the change:

  • post-connect failure (your "definitely leaks" case): destroy called once, and the retry builds a fresh client
  • connect() rejecting: destroy called
  • five consecutive flaps: five clients, each destroyed exactly once — the accumulating-orphan steady state
  • a destroy() that throws: the turn still resolves and the next call still retries

The existing mock only exposed connect/hSet, so it would have passed with any close call at all, including one that threw. It now records a destroy per client.

*/
export const KEK_BYTES = 32; // AES-256
const IV_BYTES = 12; // GCM standard nonce
const VERSION = 'v1';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — the wire form is versioned but carries no key identity, so there is no KEK rotation path. Cheap to add now; not addable later without the old KEK.

The contrast with the token keyset is stark and, I think, unintentional. parseKeyset takes a list specifically so key rotation needs no flag day, and token.ts:81-83 spells out the procedure ("publish the new key alongside the old, roll the Service, then switch signing key"). Rotation is discussed eight times across the spec and token.ts. For the KEK: git grep -niE 'kek.*rotat|rotat.*kek|re-?seal' across docs/ and packages/ returns nothing. SH_CREDENTIAL_KEK is a single value, kekFromBase64 accepts exactly one, and the sealed form has a version but no key id.

Concretely, today:

  • Changing SH_CREDENTIAL_KEK makes every stored credential undecryptable at once. There is no dual-key read window, so there is no rotation — only a cutover that breaks everything sealed before it.
  • A rotation in progress is indistinguishable from an attack. open()'s single opaque message (:65-70) is the right call for a caller, but it means "sealed under the previous KEK" and "tampered ciphertext" produce the same failed to decrypt credential '<name>'. An operator mid-rotation cannot tell which they are looking at.
  • Recovery is not an operator action. There is deliberately no read-back path anywhere in /v1 (handlers.ts:320-326), so nobody — including the control plane's own admin surface — can export and re-seal. Re-sealing requires the old KEK; without it, every user must re-enter every credential by hand. A leaked KEK therefore forces exactly the outcome the design otherwise avoids: full manual re-entry, at the worst possible moment.

The v1 prefix suggests at-rest evolution was already on your mind — it just stopped at format rather than key identity. Two options, either fine:

  • Key id in the wire form: v1.<kekid>.<iv>.<tag>.<ct>, with kekid derived from the KEK the way keyIdFor derives one from a public key. open() then selects the right KEK and can say "sealed under an unknown key id" — a diagnosis, not a guess — without telling a caller anything about which bytes they got right.
  • Accept a KEK list, newest-first for seal, try-each for open, mirroring parseKeyset. No format change, so no migration; rotation becomes "add the new KEK, re-seal lazily on next write, drop the old one."

The reason to do it in this PR rather than a follow-up: the moment MU1 seals its first production credential, adding a key id becomes a migration that itself needs the old KEK and a re-seal pass that the no-read-back rule forbids. Right now there is no ciphertext anywhere and it is a format decision. That asymmetry — free today, unbounded later — is the whole argument; if you would rather not expand this PR, a ## 9-style scope note recording the gap and the trigger would at least make it a decision rather than an omission.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken, in this PR, as your second option — the KEK list. 206b538.

Your "free today, unbounded later" argument is the one that decided it: there is no ciphertext anywhere yet, so this is a configuration decision now and a migration-needing-the-old-KEK after the first production seal. Doing it later is the one option that is actually expensive.

keksFromBase64 replaces kekFromBase64 and parses a comma-separated ring, newest first; seal uses keks[0], open tries each. K8sSecretStore holds keks: Buffer[]. Rotation is: prepend, roll, let writes re-seal forward, drop the retired key.

Why not the key id in the wire form. I considered it and think the two options are not symmetric. A key id does not enable rotation by itself — open still needs a list to select from — so the list is the necessary half and the key id is the diagnostic addition on top. And it is a format change, which is precisely the cost your own argument says to avoid incurring later; incurring a smaller version of it now to improve an error message did not seem worth it. If the diagnosis becomes a real operational need it can go in as v2 alongside a v1 reader, which the version prefix already allows.

Two things I made sure the ring did not quietly weaken:

  • It is not an oracle. One opaque failed to decrypt credential '<name>' after the whole ring fails, byte-identical to the single-wrong-key and tampered-ciphertext messages. Trying N keys must not turn one outcome into N distinguishable ones; there is a test asserting the exact message for a 3-key wrong ring and for a 1-key one.
  • The AAD still binds against every key. It is re-bound per attempt, so a relabelled ciphertext is refused by the retired key too, not just the primary. That was the failure mode worth checking — a ring is a set of chances for the relabelling defence to be skipped.

Also: an all-empty value is refused at parse (an empty ring would have had seal reach for keks[0] and encrypt under undefined), seal([]) refuses explicitly rather than surfacing as a node crypto error naming neither the ring nor the credential, and a bad entry names its position — an operator adding a second KEK mid-rotation is exactly who needs to know which of the two is wrong.

Tests include a full cycle through the store, since that is what an operator performs rather than seal/open in isolation: write under the old key → read with [new, old] → next write re-seals forward → [new] alone reads it → [old] alone no longer does. Plus a credential under a dropped key throwing rather than reading as absent — a miss must not look like "no such credential", or a user sees it in list() and cannot use it with no signal to the operator.

Documented in spec §6.5 (including why the ring is in config and not on the wire) and in control-plane.yaml's header, with the three-step rotation procedure spelled out next to the kubectl create secret line.

Addresses the four findings from the first review pass on rossoctl#249. Each was verified
against the tree before changing anything; all four held up.

MUST-FIX — a keyset parse outside the try, and no boot check anywhere.

`turnAuthDeps()` was built before `handleTurn`'s try, and `turnAuthDepsFromEnv`
calls `parseKeyset`, which throws on a malformed entry, a non-Ed25519 key, and a
kid that does not match its key. That throw therefore skipped `writeAuthError`
and surfaced through the route's own catch as
`500 {"error":"Error: SH_SESSION_TOKEN_PUBLIC_KEYS entry ..."}` — internal error
text to an arbitrary caller, and, because the throw lands before anything reads
`requireAuth`, on the unauthenticated path too. The whole point of MU1 being
opt-in is that turning it on cannot disturb the 14 existing no-token deploy
scripts, and this was the one path where a config error did.

`token.ts` also justified its curve assertion with "parseKeyset runs at startup
on both tiers". It ran at startup on one: `main.ts` parsed at control-plane boot,
but on the data plane the only call was per request, and neither /healthz nor
/readyz touches the keyset — so the pod went Ready, stayed Ready, and 500ed every
turn. Exactly the outcome that comment claims to prevent.

Both halves of the reviewer's fix, since they address different problems:

- `assertKeysetUsable`, called from `startServer` before anything binds, so an
  operator typo is a crashloop naming the bad entry. This is what makes token.ts's
  "both tiers" claim true; the comment now names the two call sites rather than
  asserting the property in prose, so the next drift is visible.
- the per-request read stays, but `keysFromEnv` converts a parse failure into
  `CpError('credential_unavailable')` — 503, fixed message, no entry text — for
  the case the per-request read exists for: the env changing under a serving pod.

SUGGESTION — `unauthorized` reported a deployment fault as the caller's.

Every other PASSTHROUGH member is caller-attributable. On
`/internal/credentials`, `unauthorized` has exactly two sources —
`checkExchangeAuth`'s shared bearer and `handlers.ts`'s defensive re-check — and
neither involves the caller; the user's own token is checked inside
`exchangeCredential` and fails as token_invalid / token_expired /
session_not_found. So it meant one thing: the harness cannot authenticate to its
own control plane. Passing it through made that a 401 reading as "your token is
bad", so a user re-runs the device flow, gets a fresh token, and fails
identically, while 5xx alerting and retry/backoff see nothing — worst during
first rollout and exchange-token rotation. Dropped from the set, so it falls
through to `credential_unavailable` alongside its three neighbours (unreachable,
non-JSON body, unknown mode) with the status kept in the message for the log. The
doc comment now states the invariant, so the next code is not admitted on the
weaker "the control plane can return it".

SUGGESTION — a Redis connection leaked per failure.

`makeRuntimeReporter`'s catch cleared `ready`/`index` so the next call retries,
but `client` was block-scoped and never closed: after clearing, nothing held it
and nothing had closed it. A Redis restart mid-rollout left it connected and
reconnecting on its own timer, so steady state was one live client plus one
orphan per flap — a slow leak in the one function whose sibling comment is about
not opening a connection per turn. Client hoisted and closed in the catch, with
the `const c` capture the reviewer flagged, without which a slow connect()
resolving after the catch would build `index` against a discarded client.
`destroy()` rather than the suggested `quit()`: quit is deprecated in node-redis 6
and `close()` waits for pending commands against a server that may be gone. It
throws on a client that never opened, so it is guarded — the state clearing must
not be skippable.

SUGGESTION — the KEK had no rotation path. Now a ring.

`SH_CREDENTIAL_KEK` was a single value: changing it made every stored credential
undecryptable at once, with no dual-read window, and a rotation in progress was
indistinguishable from an attack. Worse, `/v1` deliberately exposes no read-back
path, so nothing — not even the control plane's own admin surface — could export
and re-seal: the only recovery was every user re-entering every credential by
hand, the outcome the design otherwise avoids, at the worst possible moment.

`keksFromBase64` now parses a comma-separated ring, newest first; `seal` uses the
first key and `open` tries each. Rotation is prepend, let writes re-seal forward,
drop the retired key. Done now rather than later for the reason the review gives:
no ciphertext exists yet, so this is a free configuration decision today and a
migration needing the old KEK — which the no-read-back rule forbids — after the
first production seal.

No wire-format change, so no migration: a key id in the sealed value would let
`open` select instead of try, but that is the format change whose cost is the
whole argument for acting now. Try-each is a failed GCM verification or two on a
ring of that size. The ring is not an oracle: one opaque message after the whole
ring fails, identical to a tampered ciphertext, and the AAD is re-bound per
attempt so a relabelled ciphertext is refused by every key, not only the primary.
An empty ring is refused at parse rather than reaching createCipheriv as
undefined, and a bad entry names its position — which is what an operator adding
a second key actually needs.

Tests. Every fix is pinned by a test that fails without it. The keyset work also
tightened an existing `.toThrow()` that passed for either a plain Error or the
typed one, and covers all three parseKeyset failure modes rather than one. The
KEK work adds a full rotation cycle through the store — read under the retired
key, re-seal forward on write, retire the old key — plus the AAD holding against
every key in the ring, and a credential under a dropped key throwing rather than
reading as absent.

pnpm -r test: 1403 passed / 17 skipped across 10 packages (was 1377/17).
pnpm -r typecheck, make lint (tree clean after), make test-deploy: all green.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>

@cwiklik cwiklik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 on 206b538. All four round-1 findings are fixed, and I checked each against the code rather than against the replies. The must-fix is closed on both halves.

Approving. One non-blocking suggestion below, and it is about the operational story around the new KEK ring rather than its cryptography — which holds up.

The four fixes, verified

Round-1 finding Status What I checked
must-fix server.ts keyset parse outside the try fixed, both halves assertKeysetUsable(process.env) at server.ts:657, before createServer. let deps: TurnAuthDeps at :150, assigned inside the try and definitely-assigned afterwards because the catch returns at :157. keysFromEnv yields a CpError, which makes writeAuthError:104's throw err rethrow — the thing that actually produced the 500 — unreachable for a keyset fault. token.ts now names both call sites instead of asserting the property in prose. turn-auth-route.test.ts:334 covers the SSE branch too, which is where I would have expected the fix to stop short.
unauthorized in PASSTHROUGH fixed Gone from the set (turn-auth.ts:129-137), so it falls through to credential_unavailable at :173 with the status kept in the message. The invariant is now the lead paragraph, and turn-auth.test.ts:365 pins has('unauthorized') === false — so re-admitting it fails a test rather than needing a reviewer to notice.
Redis client leaked per failure fixed State cleared at :318-321 before the destroy() attempt at :327-331, so a throwing close cannot skip the retry. That ordering was the thing worth verifying and it is right. const c = client capture at :306. Concurrent-failure case is safe too: the second catch sees client === undefined and no-ops rather than double-destroying.
KEK had no rotation path fixed, as a ring seal uses keks[0] (:70), open loops (:95). All four guards hold: the opaque throw is outside the loop at :113 and carries no key count, with a test anchoring /^failed to decrypt credential 'a'$/ across both a 3-key and a 1-key ring; setAAD is genuinely inside the loop at :98, with a test that relabels against [newK, oldK]; an all-empty value is refused at parse; seal([]) is refused at :71. k8s-secret-store.test.ts:160 walks the full cycle including step 4 — the retired key alone no longer opens it — which is what makes "re-seals forward" a demonstrated property rather than a claim.

On the two sub-suggestions you declined, both hold up. destroy() over quit() is better than what I proposed — I had not checked node-redis 6's deprecation or close()'s pending-command behaviour, and your reason for preferring it is the right one. Rejecting the wire-format key id also holds: the ring is what creates the dual-read window, and the format cost is real. The suggestion below is deliberately the version of that idea that needs no format change, because your argument against the format change is correct.

Author: pdettori (MEMBER — maintainer)
Areas reviewed (incremental 628feaa..206b538): TypeScript (control-plane, knative-server), Kubernetes/YAML, Docs, Tests
Agent/IDE config (.claude/.vscode): none — grepped both +++ b/ and rename to forms on the incremental diff; 0 renames
Commits: 41 (1 new: 206b538), signed off, Assisted-By per house convention, fix(mu1): prefix
CI status: passing — 12/12 green on 206b538 (DCO, CodeQL, Trivy, shellcheck, hadolint, dependency-review, lint, proto, deploy-scripts)
Incremental diff: 13 files, +614/−80

Comment thread deploy/knative/control-plane.yaml Outdated
# # | kubectl apply -f -
# # Roll the Service so the new Revision picks it up.
# # b. Let writes re-seal forward: every PUT /v1/credentials/{name} reseals under the new key.
# # c. Once nothing is left under the old key, drop it from the list and roll again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — step (c) is the one step an operator has to decide, and nothing in the system tells them when it is true.

The ring is the right shape and its guards are real — I verified all four. This is about the procedure's terminating condition, which is currently unobservable:

  • open returns only the plaintext. The ring index that succeeded is discarded (envelope.ts:100-103).
  • Nothing counts or logs a non-primary open. The whole control plane has four console.* calls; none is in envelope.ts or k8s-secret-store.ts.
  • list() deliberately never touches the KEK (k8s-secret-store.ts:190), so the metadata path cannot answer it either.
  • There is no read-back path in /v1, so nobody can sweep the store to check.
  • The audit record carries subject, credential and decision — not the key that opened it.

So "once nothing is left under the old key" is a guess, and when the guess is wrong the outcome is the one the ring was added to prevent, narrowed to whichever subset of users had not re-PUT since the rotation began. Their credential is intact, undecryptable, and — correctly, per your own test's reasoning — throws rather than reading as absent. Recovery is still manual re-entry.

The part that makes it worse than it needs to be: the operator cannot tell whose. The only artifact is server.ts:128's

[control-plane] unhandled Error: failed to decrypt credential 'my-anthropic'

That is the credential name — user-chosen, and it collides freely across subjects. Nothing makes my-anthropic unique, and it is the obvious name for everyone to pick. The subject appears nowhere. So after dropping the key too early, the operator knows some users are broken and cannot enumerate them.

Fix, and specifically not the key id you rejected — this needs no wire-format change. Have open report which ring index succeeded (return it alongside the plaintext, or take an onKeyIndex?: (i: number) => void), and have K8sSecretStore.get log or count it when i > 0, keyed by subjectHash(subject) — already computed, and it discloses no login, which is the property k8s-secret-store.ts:28 already relies on. Two things follow:

  • Step (c) becomes a positive signal: "no credential has opened under a non-primary key for N days." Today the only signal is the failure.
  • When it does go wrong anyway, the log names the subject hash, which is enough to identify the user who needs to re-enter.

Your rejection of the wire-format key id and this are not the same question. A key id does not enable rotation — the ring does, and you are right that the format cost is not worth paying for key selection. But the key id was also the only thing that would have made completing a rotation observable, and dropping it left that hole open. An index report closes it for free, because it never leaves the process and changes no bytes at rest.

If you would rather not add it here, the honest alternative is to change this line and spec §6.5's "drop the retired key once nothing is left under it" to something an operator can actually perform — keep the retired key for a full credential-lifetime window, say, or require every user to re-PUT before step (c). As written, both documents instruct a check that cannot be carried out.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken, as the index report rather than the doc-only alternative. 8d9d225.

Your framing is right that these are two separate questions and that the format cost was the only reason to refuse the first. An index that never leaves the process costs nothing at rest, so there was no argument left against it.

open returns { plaintext, keyIndex }, and only on success — a failure still yields the one opaque message with no index and no ring size, so the thing I checked hardest is that this does not walk back "a ring must not turn one outcome into N." K8sSecretStore.get logs every keyIndex > 0, keyed by subjectHash(subject).

Log rather than count, since there is no metrics surface to count into and inventing one for this would be the larger change. And deliberately not deduplicated, which was the one design decision in here worth arguing: a once-per-(subject, credential) cache is the obvious tidying, and it would break the signal. "No non-primary open for N days" is then satisfiable by a long-lived pod that logged once on day one while the credential is still stale — a false positive on exactly the condition the line exists to establish. So the steady state is silent and, during a rotation, the volume is the backlog. There is a test pinning the silence, because that is the half that makes it a signal.

On the failure path, the subject hash goes into the message rather than into a second log line: one artifact, no duplication with server.ts:128, and the original error is kept as cause. It reaches no caller — writeError reduces a non-CpError to a bare internal_error with no message at all, which is what makes enriching it safe. I did not reclassify the caller-visible status; a dropped KEK arguably reads better as credential_unavailable than as internal_error, but that is a different question from this one and you did not raise it.

Both documents now name the check instead of implying one. control-plane.yaml step (c) and spec §6.5 give the positive terminating condition, that dropping early is unrecoverable because /v1 has no read-back path, and that the hash in the log is the sh-cred-<hash> suffix — so "enumerate whom to tell" is a concrete instruction rather than a hope.

Four tests, and I checked each against a mutation of the code it covers rather than trusting that it was red once:

  • the index report uses a three-key ring, because a boolean "was it the primary" passes a two-key test and still cannot say which retired key is in use (mutation: report keyIndex + 1)
  • the log line names the subject hash, the credential and the ring position (mutation: log the raw subject — caught; the raw subject is a login, the hash is not)
  • silence when the primary opens (mutation: drop the > 0 guard)
  • the failure message, asserted exactly rather than by substring — which is also what pins the absence of the raw subject, instead of asserting that separately (mutation: drop the hash)

One assertion I had to tighten before it was worth anything: expect(line).toContain('1') for the ring position is satisfied by the subject hash on its own, since that is hex. It is toContain('ring index 1') now.

pnpm -r test1407 passed / 17 skipped; control-plane 306 → 310, which is the four above. pnpm -r typecheck, make lint (tree clean after), make test-deploy, all green.

The ring made rotation possible; nothing made its last step decidable. `open`
computed the ring index and discarded it, nothing counted a non-primary open,
`list()` never touches the KEK, `/v1` has no read-back path to sweep with, and
the audit record carries the decision but not the key. So "drop the retired key
once nothing is left under it" was a guess, and the only signal it had been
guessed wrong was the outage that followed.

- `open` returns `{ plaintext, keyIndex }`. On success only: a failure still
  yields one opaque message with no index and no ring size, so trying N keys
  cannot become N distinguishable outcomes.
- `K8sSecretStore.get` logs every `keyIndex > 0`, keyed by `subjectHash`.
  Deliberately not deduplicated -- the terminating condition is "no non-primary
  open for a credential-lifetime window", and a once-per-process log would let a
  long-lived pod satisfy it while credentials were still stale.
- The decrypt failure gains the subject hash. `open` names the credential, but a
  credential name is user-chosen and collides freely across subjects, so an
  operator who dropped a key early knew some users were broken and could not
  enumerate which. Nothing reaches a caller: `writeError` reduces a non-CpError
  to a bare `internal_error` with no message.

No wire-format change, so no migration: the index never leaves the process.

`control-plane.yaml` step (c) and spec §6.5 previously instructed a check that
could not be carried out; both now name the positive signal.

Four tests, each verified to fail against a mutation of the code it covers:
the three-key index report (a boolean "was it primary" passes a two-key test),
the log line, silence in steady state, and the failure message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
@pdettori
pdettori merged commit 694464d into rossoctl:main Sep 11, 2026
12 checks passed
@pdettori
pdettori deleted the feat/mu1-multi-user-control-plane branch September 11, 2026 01:07
pdettori added a commit to pdettori/serverless-harness that referenced this pull request Sep 14, 2026
…and six more

Ten review comments: two must-fix, seven suggestions, one nit. All addressed.

## must-fix: Redis published on every interface

`-p 6379:6379` binds 0.0.0.0 in podman, and the image runs with no --requirepass, no
ACL and no TLS. On THIS deployment that is the execution path, not data at rest:
env/supervisor.env.example ships SH_SANDBOX_DISCOVERY=records, and select-sandbox.ts
honours it by never listing pods, so the only inventory of executors is a set of Redis
records. Whoever can write them chooses the sandbox every turn dispatches to --
receiving the user's prompts and repository contents and returning whatever they like
as the agent's output. Admission control, the fail-closed relay token and
RestrictAddressFamilies are all bypassed because none of them sits in that path.

Now `-p 127.0.0.1:6379:6379`. Nothing loses access: both env templates already point at
redis://127.0.0.1:6379, and the sandbox containers reach the RELAY, not Redis. The
invariant was already written down for the admin listener; it simply had not been
applied to the listener that exposes session state. setup-vm.test.sh now asserts both
directions, with comment lines stripped first because the script deliberately quotes
the unsafe form in a comment (the guard fired on its own documentation at first).

## must-fix: the wedge was only closed for connections that END

The close-only `load` report left the HELD case open. S x W sockets handed off, issuing
no turn, and kept alive drive every slot's estimate to S; main.ts then refuses before
hand-off, so no turn arrives, so no `load` arrives, so nothing reconciles -- 429s until
a worker crashes. At S=1 that is ONE held connection, and it needs no attacker: a load
balancer or monitoring probe on a persistent connection does it by accident. It is also
invisible while it happens, because `spurious_refusals` can only rise on a `load`
reporting lower than the refusal estimate, and the wedge is the state where no `load`
arrives.

Now reported on accept as well as on close. The trade, stated in the comment because it
inverts a deliberate bias: a real turn's connection momentarily reports its pre-turn
count, so the estimate dips for one IPC round trip and the pool can over-admit -- but
that is bounded by the round trip, accepted by §3.9, and already counted, where the
wedge was unbounded and uninstrumented.

The new integration test holds a keep-alive connection open with no turn on it and
asserts the estimate still returns to 0 and a second connection still gets 200. Verified
non-vacuous: removing the accept-side report fails exactly that test and nothing else.
Four unit tests' expectations gained the accept report, each with a note on why.

## suggestions and nit

- `void route(socket)` now has a `.catch` that logs and destroys the socket. `route` is
  async, so a throw is a rejected promise; unhandled, Node's default ends the process,
  KillMode=control-group takes every worker, and Restart=always brings back a supervisor
  that lost every in-flight turn. `config.policy.pick` is injected, so this is reachable.
  Same treatment for `void supervisor.close()` on shutdown, where an unhandled rejection
  would skip the drain the close() is for; it exits non-zero so a failed drain is
  distinguishable in the journal.
- SH_STATS_INTERVAL_MS was the one numeric env parse outside config.ts's readInt. NaN ->
  setInterval(fn, NaN) -> a hot timer in the process whose event-loop lag E8 measures.
  Now `statsIntervalMs`, rejecting non-integers and non-positives, with tests including
  one proving the old expression really did yield NaN.
- SH_SANDBOX_DISCOVERY is now validated at BOOT. Its only caller was on the turn path, so
  a typo booted cleanly, passed /health, and failed every turn -- the shape rossoctl#249 closed as
  a must-fix, with SH_ROUTING_POLICY validated at boot two doors away. Placed in the
  worker rather than the supervisor because resolveDiscoverySource lives in @sh/harness and
  packages/supervisor ships `dependencies: {}` deliberately; the worker already depends on
  the harness and is where assertKeysetUsable, the cited precedent, lives. A throw there
  still surfaces as a crashloop with the reason in the journal.
- The relay token no longer enters podman's argv. `-e SANDBOX_TOKEN` (no `=`) takes the
  value from podman's own environment, so `ps` cannot read what /proc/<pid>/cmdline used
  to expose. The tests now assert all three halves -- by-name present, value absent from
  argv, value actually delivered -- which needed the podman mock to record its own
  environment, since argv alone cannot tell "passed safely" from "not passed at all".
- resume.integration.test.ts now skips without REDIS_URL instead of failing. vitest picks
  it up unconditionally, so a fresh clone saw a red suite with a connection error rather
  than "1 skipped"; CI hid it by having Redis. Gated on the variable rather than a probe,
  following handoff.integration.test.ts's convention.
- New admin-allowlist.test.ts pins ENV_ALLOWLIST secret-free by NAME SHAPE rather than a
  list of known-bad names, so it catches the variable nobody thought of, plus a
  behavioural check that /metrics filters rather than passing the environment through, plus
  a check proving the pattern matches the names it is meant to catch.

- head.ts no longer re-concatenates and re-scans the whole accumulation on every 'data'
  event. The peer chose the multiplier: at one byte per packet up to MAX_HEAD_BYTES that is
  1+2+...+16384 bytes copied and about as many scanned, ~134 MB touched plus a 16384-entry
  array for 16 KB of input. It is reachable BEFORE admission (main.ts decides saturation
  before awaiting readHead) with nothing capping concurrency, and it lands in the CPU E8
  measures on the routing hop -- in the arm whose whole purpose is comparing sticky routing
  against leastInFlight. Each chunk is now scanned once with a <=3-byte carry, so the work is
  linear; `chunks` is untouched, so finish() still concatenates once and the over-cap bytes
  are still kept. Three tests cover the only thing that can regress -- a terminator
  straddling a boundary -- and removing the carry fails exactly those two.

Verified: harness 403 passed, supervisor 110 passed / 2 skipped, knative-server 331
passed, test-deploy 23 ok, typecheck and lint clean.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
pdettori added a commit to pdettori/serverless-harness that referenced this pull request Sep 14, 2026
…, and one more

All nine comments on rossoctl#250 verified against the code and fixed. Two turned out
worse than reported; one new defect surfaced while pinning them.

Boot-time validation (medium): worker.ts bypasses startServer, so rossoctl#249's
assertKeysetUsable never ran on this deployment — a malformed
SH_SESSION_TOKEN_PUBLIC_KEYS booted clean and 503'd every authenticated /turn.
Called it alongside the existing SH_SANDBOX_DISCOVERY check.

Shutdown drain (medium): KillMode=control-group SIGTERMed every worker
alongside the supervisor, and worker.ts installs no SIGTERM handler, so
close()'s ordered drainAll -> stop accepting -> awaitIdle drained a pool that
was already dead. KillMode=mixed, which is what that sequence assumes.

Handle-less conn (medium-low): accept() returned above the load report, so
handOff's +1 credit for a handle-less conn was permanent — the same unbounded
wedge the 30-line comment below it closes. Report moved above the guard.

Abandoned pre-read (reported low, measured worse): readHead's 'closed' outcome
was ignored. The predicted cost was W handoff_retries plus a failure; measured,
send() does not throw at all, so both counters stay at zero and the WORKER dies
instead (worker_exit code 1, restarts 1) — every turn it multiplexed with it.
Return early.

Hand-off exhaustion (low): destroy() gave an ECONNRESET where main.ts answers
429 for the identical state, and skipped noteRefusal() so the refusal could
never reconcile as spurious. noteRefusal + refuse.

Computed fallbacks (low): readInt returned fallback unchecked; os.cpus() may be
empty, so SH_WORKERS could be 0 — forks nothing, isSaturated([]) is true, 429s
forever with no error. Bounds now cover fallbacks, and defaultWorkers clamps.

HOME (medium): every Knative manifest running this code sets HOME=/tmp with a
writable mount; the unit set none under ProtectHome=true and a --no-create-home
user. Environment=HOME=/tmp, paired with the existing PrivateTmp.

Reboots (medium): the podman containers had no --restart and no generated unit
while both services are WantedBy=multi-user.target. --restart=always plus
podman-restart.service (podman-run(1): --restart alone does not survive a
reboot), non-fatal if that unit is absent. Redis's missing volume is now stated
rather than discovered.

README (low): the token instruction tee'd into a file setup-vm.sh had not
created yet, and the 1-4 list read as one invocation. Documents the real
two-pass first run.

Additionally, and not from the review: ChildProcess.send() to a closed channel
does not throw — it emits 'error' a tick later, so every try/catch around a
send in pool.ts guarded the one shape this failure never takes. On the ordinary
shutdown path with any worker already dead, close() -> drainAll() killed the
supervisor by uncaughtException before awaitIdle, losing the in-flight turns on
every other worker. drainAll now checks connected, and each child gets an
'error' listener. Found because the new abandoned-pre-read test made a dead
worker exist at teardown for the first time.

Every fix has a test; the two measured behaviours are recorded in the comments
and test rationale rather than the predicted ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants