Skip to content

feat(control-plane): test-user logins behind a shared secret - #620

Merged
pdettori merged 1 commit into
rossoctl:mainfrom
aslom:feat/test-user-logins
Oct 28, 2026
Merged

pdettori merged 1 commit into
rossoctl:mainfrom
aslom:feat/test-user-logins

Conversation

@aslom

@aslom aslom commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Every login to the control plane is GitHub's device flow, which needs a person with a browser. smoke.sh works around that by minting an API token inside the control-plane pod with the signing key, which skips refresh.issue(), rolesFor() and the audit login entry -- so the smoke authenticates in a shape no real user has, and needs kubectl exec to do it. Multi-user behaviour (per-subject credentials, cross-user session denial) had no automated coverage above one user.

With SH_TEST_USERS=N the control plane now serves logins for N synthetic subjects test:1..test:N via POST /v1/auth/test-token, and mocactl login --test-user N drives it. A test user is configured exactly like a GitHub user: the handler makes the same two calls as completeDeviceAuth -- refresh.issue() then loginBody() -- so token TTLs, rotation, roles, ownership, audit and credential storage cannot tell them apart. Only the way identity is established differs.

The path is gated by SH_TEST_USER_SECRET, which the client presents and the control plane compares in constant time (both sides hashed to 32 bytes first, so a length mismatch is neither an exception nor an oracle). The endpoint issues a real login for an arbitrary subject, so without a secret anyone who could route to the control plane would be every test user -- including a compromised sandbox. The secret is a credential FILE via CONTROL_PLANE_SECRETS, never an env line a kubectl child inherits, and setup.sh generates it into moca-mu1 without rotating it on a re-run.

Fails closed: 404 when SH_TEST_USERS is unset (indistinguishable from an unrouted path), the route is not mounted on the sandbox profile at all and the control plane refuses to boot if the two are combined, N is capped at 1000, the secret must be at least 16 characters, and a warning names the count at boot and on every setup run.

deploy/k8s/test-users.py drives N users (default 10) through real mocactl commands, one XDG_CONFIG_HOME each -- auth.json holds exactly one login, so one directory cannot hold ten -- and asserts the cross-user claims: user 2 is refused user 1's real session, user 1 still resumes it, and each list holds only its owner's sessions. --api runs the same claims over HTTP as a reference for a non-CLI client.

Fixes #616

What this does

With SH_TEST_USERS=N the control plane serves logins for N synthetic subjects test:1 … test:N
via POST /v1/auth/test-token, and mocactl login --test-user N drives it. Unset, the path does
not exist.

The decision that carries the value: a test user is configured exactly like a GitHub user. The
handler makes the same two calls as completeDeviceAuth — deps.refresh.issue() then
loginBody() — so token TTLs, refresh rotation and renewal, rolesFor, the ownership index, the
audit trail and credential storage cannot tell them apart. Only the way identity is established
differs.

Decision record: ADR-0045.

Security: the shared secret

The issue as filed had the endpoint gated only by SH_TEST_USERS. On review that is not enough, so
this PR adds SH_TEST_USER_SECRET — required whenever test users are on. The endpoint issues a
real login for an arbitrary subject; without a secret, anyone who can route to the control plane
is every test user, including a compromised sandbox on the same cluster. Test clusters are reachable
from more places than their operators assume, which is the common theme in the write-ups on test
environments becoming the breach path.

What the gate actually does:

Concern Measure
Timing side channel on the secret crypto.timingSafeEqual, with both sides SHA-256'd to 32 bytes first so a length mismatch is neither an exception (it throws on unequal buffers) nor a length oracle
Guessable secret minimum 16 characters, refused at boot; setup.sh generates 64 hex chars
Secret in process state travels as a credential file via CONTROL_PLANE_SECRETS, so it never enters process.env, which every kubectl child inherits
Secret on a command line mocactl reads it from MOCA_TEST_USER_SECRET, never a flag — a flag would land in shell history and in ps
Probing which half is wrong one unauthorized code for missing and wrong alike
Probing how many users exist the secret is checked before index, so a bad index and a good one answer identically to an unauthenticated caller
Feature discoverable in prod 404 not_found when SH_TEST_USERS is unset — indistinguishable from an unrouted path
Reaching the sandbox profile route not mounted on that profile at all (HARNESS, not BOTH), and a boot refusal if the two are combined (SBX0 §4.2 admits only enrolled github: subjects)
Typo'd scale N capped at 1000; SH_TEST_USERS=ten is refused rather than coerced to 0
Silent enablement a warning naming the count at control-plane boot and on every setup.sh run
Secret rotation on re-run setup.sh keeps the stored value, like every other moca-mu1 secret

Accepted costs are written down in the ADR rather than left implicit: a cluster with the variable
set has a login path needing no human, and test:N can be named in SH_ADMIN_SUBJECTS (deliberate
— it is what makes the admin path testable — and one more reason the secret is not optional).

Best practices this follows

Industry practice for unattended OAuth testing is a mock or local identity provider with predefined
test users — Dex's mock connector, oauth2-mock-server, MockServer's OAuth2 support. This takes the
same shape (a fixed set of test identities, no browser) but implements it behind the existing
IdentityProvider seam
instead of deploying a server, because identity.ts already anticipates a
second provider ("slice 2 adds a generic oidc provider") and running Dex was already weighed and
rejected for slice 1. The alternative of a MOCA_API_TOKEN override was rejected precisely because
it would skip the refresh machinery and so would not be configured like a GitHub user.

The driver script

deploy/k8s/test-users.py — PEP 723 uv run, stdlib-only so uv needs no PyPI egress.

export MOCA_TEST_USER_SECRET=...          # the control plane's SH_TEST_USER_SECRET
./deploy/k8s/test-users.py --users 10     # N configurable, 10 by default
./deploy/k8s/test-users.py --users 10 --api --json

Why a directory per user (the research question in the issue): mocactl keeps its login in one
auth.json holding exactly one CachedAuth (packages/mocactl/src/config.ts), resolved from
XDG_CONFIG_HOME. One process in one directory therefore cannot hold ten logins — a second
login overwrites the first. The script gives each user its own config dir, which also makes
--jobs safe. A mocactl --profile flag would be the nicer long-term answer and is listed as
follow-up, not done here.

Per user: login --test-user N → doctor → credentials add (secret on stdin) → one turn.
Then the cross-user claims — user 2 refused user 1's real session id, user 1 still able to resume
it (proving the id was real and the refusal was about ownership), a never-existing id refused the
same way, and each user's list holding only its own sessions.

Test commands

make lint && make typecheck && make test

# deploy with test users on
SH_TEST_USERS=10 SH_GITHUB_CLIENT_ID=<id> SH_SINGLE_NAMESPACE=<ns> \
  deploy/k8s/setup.sh --target ocp-single --skip-build \
  --image <registry>/moca:<tag> --sandbox-image <registry>/moca-remote-worker:<tag>

# read the generated secret
kubectl -n <ns> get secret moca-mu1 -o jsonpath='{.data.SH_TEST_USER_SECRET}' | base64 -d

# check the GATE before the feature: 404 while SH_TEST_USERS is unset
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' \
  -d '{"index":1}' http://127.0.0.1:8090/v1/auth/test-token

# N=10 end to end
./deploy/k8s/test-users.py --users 10

# ten distinct subjects in the audit stream
kubectl -n <ns> exec redis-0 -- sh -c \
  'redis-cli --no-auth-warning -a "$REDISCLI_AUTH" XREVRANGE sh:cp:audit + - COUNT 40' |
  grep -A1 '^subject$' | grep '^test:' | sort -u | wc -l

# regression: the device flow and the existing smoke are untouched
mocactl --control-plane-url http://127.0.0.1:8090 login
K8S_LIVE_SMOKE=1 SMOKE_MODEL_URL=<endpoint> SMOKE_MODEL_KIND=bearer SMOKE_MODEL_TOKEN=<token> \
  deploy/k8s/smoke.sh --target ocp-single      # still PASS=12 FAIL=0

Verification done

Check Result
pnpm -r typecheck clean
packages/control-plane suite 853 passed, 0 failed (+62 new)
deploy/k8s/tests/setup.test.sh 103 passed (+1 new)
packages/mocactl suite 1473 passed; failures are a strict subset of main's under identical back-to-back runs (10 on this branch vs 12 on main) — pre-existing timing-sensitive UI/sbx tests that flake under parallel load, not regressions
scripts/check-doc-links.py 210 files, 0 broken links

Four repo guards caught the change and were updated rather than worked around, which is them doing
their job: the OpenAPI contract test (route + error code must be documented), the route inventory and
route-profile allow-lists, the mocactl client-coverage test ("adding a new public method must make
this test fail"), and the moca-settings key inventory (nine keys → ten).

New tests worth naming: the 404 holds for every body shape when the feature is off; a wrong secret
answers identically for a valid and an absurd index; secretMatches does not throw at any length;
the issued refresh token actually rotates; SH_TEST_USERS + sandbox profile refuses to boot; the
generated secret is not a copy of the exchange token (an early version of my setup.sh change
reused generated_value SH_EXCHANGE_TOKEN, which reads the generator's output by key name and would
have made the two identical — the test now pins it).

Live run on OpenShift (avek2, N=10)

Image quay.io/aslomnet/moca:test-users-3816b706 (amd64, built from this commit; the feature was
verified present inside the image, not just assumed). Stack brought up with
SH_TEST_USERS=10 deploy/k8s/setup.sh --target ocp-single, which printed the warning and generated
a 64-hex-character secret into moca-mu1. Six pods, all Running, 0 restarts throughout.

The gate, checked before the feature: on the previous image (no SH_TEST_USERS)
POST /v1/auth/test-token answered 404, and mocactl login --test-user 1 against it printed
this control plane has no test users (set SH_TEST_USERS on the control plane to enable them).

Security checks against the live stack:

Request Answer
no secret 401 unauthorized — "wrong or missing secret"
wrong secret, index: 1 401
wrong secret, index: 99999 401 — identical, so probing cannot count the test users
correct secret, index of 0 / 11 / 1.5 / "1" 400 invalid_request — "must be a whole number in 1..10"
correct secret, index: 1 200: test:1, Test User 1, roles: [], API token and an mrt_… refresh token

Both driver modes passed:

users 10/10 fully ok;  cross-user 4/4      RESULT: PASS      (--api)
users 10/10 fully ok;  cross-user 4/4      RESULT: PASS      (cli, real mocactl)

Cross-user claims, live: user 2 refused user 1's real session id; user 1 still resumed it; a
never-existing id refused the same way; user 1's list excluding user 2's.

Independent evidence (not the script's own word): the audit stream holds all ten distinct
test:N subjects
across 92 entries, each with its own credential name (test-user-1 …
test-user-10), and zero occurrences of the model token's prefix — names recorded, values never.
A turn as test:7 ran in a sandbox and replied moca-sandbox-0, and mocactl doctor showed all 7
checks green.

The device flow is untouched: a plain mocactl login on the same stack printed a fresh GitHub
device code.

Regression: the existing smoke still passes

deploy/k8s/smoke.sh --target ocp-single on the test-users image: PASS=12 FAIL=0, all twelve
claims green (claim 7's kube-API note is the documented README §12.3 behaviour, not a failure).
So enabling test users changes nothing for the existing login, turn, drain, persistence and
isolation paths.

One operational gotcha found while getting there: the smoke binds 18090 for the control plane and
reaches the harness at the URL the control plane advertises (127.0.0.1:8080). A leftover
port-forward on either port makes the smoke's own forward fail to bind
(bind: address already in use in its pf-cp.log) and every /turn answers 404 — which looks
exactly like a broken harness. Stop any forward on 8080/18090 first; §9.3 gives the same
warning for its connection count, and §13 now repeats it.

Not done / remaining work

  • smoke.sh still mints in-pod. Migrating it to test:1 is the natural first consumer and the
    change that would retire the workaround this PR exists to replace. Deliberately a follow-up so
    this PR does not also rewrite the smoke.
  • No mocactl --profile. The driver works around one-login-per-directory with
    XDG_CONFIG_HOME; a profile flag would be cleaner.
  • API mode asserts the control-plane half only. --api creates sessions rather than running
    turns, so it does not exercise the harness; the CLI mode does.
  • ADR-0045 is Proposed, matching ADR-0039's status.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

@ingpaolodettori-dev ingpaolodettori-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review at d4ec0a4 (force-push of the single commit, same base d8bc58b).

Fixed since 2c52de5: SH_TEST_USERS ref is now optional (closes the SBX0 missing-key issue); base-control-plane.test.ts updated for the fourth secret item.

Still blocking:

  1. With test users off, moca-mu1 holds an empty SH_TEST_USER_SECRET and the volume projects it as a 0-byte file. SH_TEST_USER_SECRET is in CONTROL_PLANE_SECRETS, and credentialValue throws systemd credential … is empty on it (no ENOENT fallback), so the control plane crash-loops on every default deploy. The new commit treats the k8s-kind-e2e failure as a missing key, but on a fresh stack the key was already present (empty); the fix makes the empty file more common rather than less. Suggest a projected volume with a separate, optional moca-test-users Secret written only when SH_TEST_USERS>0.
  2. Turning test users off after they were on still keeps the stored secret (setup.sh:760), so boot fails with SH_TEST_USER_SECRET is set but SH_TEST_USERS is not.

The branch also still conflicts with main (mocactl/src/cli.ts, headless.ts, from main's --no-browser/LoginHelpers change), so no CI beyond DCO has run on this head. Earlier suggestions/nits on unchanged files are repeated inline.

Author: aslom (MEMBER — maintainer)
Agent/IDE config (.claude/.vscode): none
Commits: 1, signed-off: yes
CI status: not run (mergeable_state=dirty)

Comment thread deploy/k8s/base/control-plane.yaml Outdated
# leave this pod unable to mount and stuck in ContainerCreating. setup.sh therefore
# patches the key in on every run (empty when test users are off, which is a valid
# stored state), and identity.ts refuses an empty one alongside SH_TEST_USERS.
- { key: SH_TEST_USER_SECRET, path: SH_TEST_USER_SECRET }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

must-fix: Still crash-loops a default deploy, even with the key present. The key does exist on a fresh stack: setup.sh wrote it at the previous head too. The problem is its empty value. Kubelet projects it as a 0-byte file, SH_TEST_USER_SECRET is in CONTROL_PLANE_SECRETS (main.ts:120), and credentialValue throws systemd credential … is empty (systemd-credentials.ts:33) before identity.ts runs. Unlike ENOENT, it doesn't fall back. I reproduced this against this head's systemd-credentials.ts. So "an empty value is a legitimate stored state" (here and setup.sh:755) is not true for the pod. optional is whole-source on a secret volume, but a projected volume can mix a required moca-mu1 source with a separate moca-test-users Secret marked optional: true. Write that Secret only when SH_TEST_USERS>0 and delete it when off. Then a missing file is ENOENT → unset, and neither old nor new stacks need an empty key. Alternatively, let credentialValue treat an empty file as unset for this one name, but that weakens the general "empty is refused" rule.

Comment thread deploy/k8s/setup.sh Outdated
# So the early return also requires the test-user key to be present AND already what we want.
if [[ -n "$priv" && -n "$kek" && -n "$xchg" && -n "$tsecret_key_present" &&
"$tsecret" == "$stored_tsecret" ]]; then
if ((TEST_USERS == 0)) || [[ -n "$tsecret" ]]; then return 0; fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

must-fix: Still open from the previous head. When test users go off after being on (SH_TEST_USERS=), tsecret == stored_tsecret (non-empty), the key is present and TEST_USERS == 0, so this returns early and keeps the secret. On the next boot testUserSecretFromEnv throws SH_TEST_USER_SECRET is set but SH_TEST_USERS is not. When TEST_USERS==0, clear or remove the secret (with the separate-Secret design from #1, delete moca-test-users), and add a setup.test.sh case for on→off that asserts it.

Comment thread deploy/k8s/tests/setup.test.sh Outdated
! grep -q 'run moca-genkeys' "$MOCK_LOG" ||
fail 'patching the test-user key in started the key generator, though every other value existed'
# With test users off the value is empty, which is a legitimate stored state -- the KEY must exist.
[[ -z "$(sv moca moca-mu1 SH_TEST_USER_SECRET)" ]] || fail 'test users off must leave the value empty'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: This asserts the exact state that breaks boot: an empty SH_TEST_USER_SECRET in moca-mu1 with the volume projecting it. The setup test passes because the mock never starts a pod. Consider a control-plane unit test that runs withCredentials(env, CONTROL_PLANE_SECRETS) over a CREDENTIALS_DIRECTORY shaped like the k8s volume with test users off. That would have caught this without kind.

{
method: 'POST',
path: '/v1/auth/test-token',
auth: 'none',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Carried from 2c52de5. The route is mounted even when the feature is off, so server.ts validates the body before the handler's 404. An empty body gets 400 invalid_json, and malformed JSON gets 400, while a truly unrouted path gets 404. That contradicts the "indistinguishable from an unrouted path" claim in README/ADR/openapi. Either skip the route in matchRoute when deps.testUsers is absent, or parse after the provider check. Add a server-level empty-body test.

if (typeof secret !== 'string' || !provider.authenticates(secret)) {
// One code for a missing and a wrong secret alike: distinguishing them tells a prober which
// half of the problem to work on.
throw new CpError('unauthorized', 'test-user login refused: wrong or missing secret');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Carried. A refused secret leaves no audit, log or rate limit on an endpoint that mints real logins (possibly an admin, if test:N is in SH_ADMIN_SUBJECTS). An operator-supplied secret needs only 16 chars. At least log or audit the refused attempts, without the presented value.

* SBX0 §4.1: the sandbox profile needs no credential store, KEK, exchange token or inference
* settings, so none is required; the harness-only fields take inert values that no mounted route reads.
*/
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: Carried. testUserProvider splits sandboxProfileConfig's doc comment from its function. Move it above.

/** 'pending' until approved; 'expired' once the code lapsed unapproved (start a new one). */
pollDeviceAuth(deviceCode: string): Promise<ApiLogin | 'pending' | 'expired'>;
/** The next API token and refresh token. ApiError `invalid_grant` means "log in again". */
/** ADR-0045: a test-user login, gated by the control plane's shared secret. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: Carried. The new member sits between refreshAuth's doc comment and refreshAuth. Move it above.

'control-plane-url': { type: 'string' },
'harness-url': { type: 'string' },
session: { type: 'string' },
'test-user': { type: 'string' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: Carried. --test-user is silently ignored on commands other than login. Add a stray-flag guard like --all/--config/--repo have.

Comment thread deploy/k8s/test-users.py Outdated
# Each user's list holds only their own sessions.
ok_list, ids1 = driver.list_sessions(u1)
cross.append(Claim("user 1's session list excludes user 2's",
ok_list and u2.session_id not in ids1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Carried. Passes vacuously when user 2 has no session ('' not in ids1). Require u2.session_id. Likewise, an empty u1.session_id skips the ownership claims at 340-352 instead of failing them.

Comment thread deploy/k8s/test-users.py Outdated
}, indent=2))
return 0 if passed else 1
finally:
if args.keep or args.api:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: Carried. In --api mode, rundir from mkdtemp (line 322) is never removed.

@aslom
aslom force-pushed the feat/test-user-logins branch from d4ec0a4 to a6f2f73 Compare October 26, 2026 23:49

@ingpaolodettori-dev ingpaolodettori-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Follow-up at a6f2f73. This is a rebase onto c3bfa26, and the cli.ts/headless.ts conflict resolution with main's --no-browser is correct. Every other file is identical to d4ec0a4, so the earlier items are still open, including both must-fixes: (1) control-plane.yaml:140 projects an empty SH_TEST_USER_SECRET when test users are off, credentialValue throws on an empty credential file, and the control plane crash-loops; (2) setup.sh:760 keeps the secret on an on→off switch, so testUserSecretFromEnv throws on boot. For nit #11, the new --no-browser stray-flag guard is the pattern to copy for --test-user.

@ingpaolodettori-dev ingpaolodettori-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the rework: all 13 items from the previous rounds are verified fixed at this head. That includes both must-fixes: the test-user secret now lives in its own moca-test-users Secret, from an optional projected source, deleted when off, and covered by the on→off test. There is also the unmounted route with server-level 404 tests, the refused-login audit, the --target ocp ack, 10# normalisation, the stray-flag guard, and the test-users.py prerequisites.

One new must-fix: deploy/k8s/overlays/ocp-sbx/patch-control-plane.yaml still patches the credentials volume with secret:. The strategic merge (volumes are keyed by name) keeps the base's new projected: alongside it. kubectl kustomize deploy/k8s/overlays/ocp-sbx at this head renders the control-plane credentials volume with both projected (moca-mu1 KEK + exchange token, moca-test-users) and secret (signing key + janitor token). The API server refuses a volume with more than one type, so SBX0 deploys break. Even if it were accepted, the SBX control plane would mount the KEK/exchange token that §4.1 removed. deploy/sbx/tests/overlay.test.sh:121 checks for exactly that, so deploy-scripts should go red once CI runs. Adding projected: null to the overlay's credentials volume renders a plain secret volume again (checked locally). Please also add an overlay test that the volume has exactly one source type.

Also:

  • nit: the test-user Secret isn't part of moca.dev/settings-hash. Changing SH_TEST_USER_SECRET with the same count rewrites the Secret, but the running control plane keeps the old value until it restarts. Hash a digest of it, or document the restart.
  • The branch conflicts with main (deploy/k8s/README.md Secret table, where main added provisioner to moca-redis; docs/adrs/README.md, where ADR-0046 landed), so only DCO has run on this head. Please rebase so the full CI runs.

- { key: SH_SESSION_TOKEN_PRIVATE_KEY, path: SH_SESSION_TOKEN_PRIVATE_KEY }
- { key: SH_CREDENTIAL_KEK, path: SH_CREDENTIAL_KEK }
- { key: SH_EXCHANGE_TOKEN, path: SH_EXCHANGE_TOKEN }
projected:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

must-fix: overlays/ocp-sbx/patch-control-plane.yaml still patches this volume with secret:, and a strategic merge keeps this projected: alongside it. At this head kubectl kustomize deploy/k8s/overlays/ocp-sbx renders credentials with both projected and secret. The API server refuses that (more than one volume type), and if it didn't, the SBX control plane would mount SH_CREDENTIAL_KEK/SH_EXCHANGE_TOKEN (deploy/sbx/tests/overlay.test.sh:121 fails on exactly that). Add projected: null to the overlay's credentials volume (renders a plain secret volume again, checked locally), plus an overlay.test.sh assertion that it has exactly one source type.

Comment thread packages/control-plane/src/routes.ts Outdated
maxBodyBytes?: number;
/** Stable key the router and the OpenAPI drift test both use to name the operation. */
operationId: string;
operationId: string; /**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: the new feature JSDoc opens on the same line as operationId: string;. Put /** on its own line, or Prettier (lint) will likely reformat it.

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Responses to review on #620

Pushed as 753098ae (rebased onto c3bfa26c, still one commit). Every point below is addressed;
two needed a design change rather than a patch.


must-fix 1 — control-plane.yaml:140, empty secret still crash-loops

You were right, and my previous comment was wrong. I reproduced your finding before changing
anything: a 0-byte SH_TEST_USER_SECRET in a directory shaped like the mounted volume gives

SH_TEST_USER_SECRET: systemd credential /…/SH_TEST_USER_SECRET is empty

credentialValue returns fromEnv only for ENOENT and throws on an empty file
(systemd-credentials.ts:33), so this fires inside withCredentials before identity.ts is ever
reached. My claim that "an empty value is a legitimate stored state" was false for the pod, and it
is exactly the k8s-kind-e2e failure. Thank you for pinning it that precisely.

Taken your first option. credentials is now a projected volume with two sources: a required
moca-mu1 and a separate moca-test-users marked optional: true. setup.sh writes that Secret
only when SH_TEST_USERS > 0 and deletes it when they go off, so with test users off the file
is absent → ENOENT → unset, which the existing code already handles. No empty key anywhere, and
old stacks boot untouched.

I did not take the alternative (teaching credentialValue that one name may be empty): it weakens
a general rule for one caller, and the invariant "absent is the only 'off'" is easier to keep right.

must-fix 2 — setup.sh:760, on→off leaves the secret behind

Also correct, and fixed by the same change. The test-user secret is no longer entangled with
ensure_mu1_secret's early return; it has its own function whose first branch is:

if ((TEST_USERS == 0)); then
  # Deleted, not emptied: absent is the only state the credential loader reads as unset.
  kc delete secret moca-test-users -n "$NS" --ignore-not-found >/dev/null
  return 0
fi

With the setup.test.sh case you asked for: off → on → re-run → SH_TEST_USERS= asserts the
Secret appears with a 64-hex value, is not rotated by a plain re-run, is absent again after
off, and never appears in moca-mu1. The mock gained a delete secret handler, otherwise that
last assertion would have passed without the delete actually happening.

suggestion — setup.test.sh:473, a unit test that would have caught it without kind

Added, in packages/control-plane/test/main.test.ts: three cases running
withCredentials(env, CONTROL_PLANE_SECRETS) over a directory shaped like the mounted volume —
absent (boots, reads as 0 users), present but empty (throws, the regression itself), and present
with a value (reads it, and testUserSecretFromEnv accepts it). That is the boundary the setup test
structurally cannot see, as you noted.

suggestion — routes.ts:101, the 404 claim vs. body validation

Confirmed: server.ts parses and validates the body before dispatch, so a mounted route answers
400 invalid_json to an empty body and thereby reveals itself. Took your first option — the route
is now unmounted. RouteSpec gained an optional feature field, matchRoute takes a
RouteFeatures argument, and server.ts passes { testUsers: !!deps.testUsers }.

With the server-level test you asked for: with the feature off, /v1/auth/test-token and a
genuinely unrouted path both answer 404 not_found for an empty body, malformed JSON and a valid
body alike. With it on, the empty body becomes a 400 — which is now the honest signal that the route
exists. The handler's if (!provider) throw 404 stays as belt-and-braces for a future wiring
mistake, and says so.

suggestion — handlers.ts:503, no audit on a refused secret

Added. A refusal now emits decision: test_user_login_refused with subject: '-' and
reason: wrong_secret | no_secret, following refresh_refused's anonymous-audit shape. The
presented value is never recorded. No rate limit yet — that belongs with the control plane's
general request limiting rather than one endpoint, and I did not want to invent a scheme here; say
the word if you'd rather it block before merge.

suggestion — setup.sh:494, sticky on --target ocp

Agreed. --target ocp now refuses test users unless
SH_TEST_USERS_ACK=i-know-this-is-not-for-production is set, naming SH_TEST_USERS= as the other
way out. kind and ocp-single are dev/test targets by definition and need no acknowledgement. Tested
both directions, and documented in §13 and the configuration reference.

nit — setup.sh:493, leading zeros

Fixed with your suggestion: TEST_USERS="$((10#$TEST_USERS))" right after the regex, so 08 no
longer dies with a misleading "at most 1000" and 010 is stored as 10.

nit — setup.sh:1044, stray blank line and the "nine keys" header

Both fixed: blank line removed, header now says ten keys.

nit — main.ts:182, split doc comment

Fixed — testUserProvider moved above, so the SBX0 comment sits with sandboxProfileConfig again.

nit — types.ts:125, same problem

Fixed — testUserToken and its own comment now sit above refreshAuth's.

nit — cli.ts:133, --test-user silently ignored elsewhere

Fixed — a stray-flag guard matching --no-browser's, immediately after it.

suggestion — test-users.py:360, vacuous cross-user claims

Correct on both counts. Both session ids are now a prerequisite claim that fails loudly when
either is missing, instead of an if that skipped the ownership checks; the exclusion also asserts
u1.session_id in ids1 so it cannot be satisfied by an empty id.

nit — test-users.py:402, --api leaks its tempdir

Fixed — --keep is now the only thing that preserves rundir, in both modes.


Verification

pnpm -r typecheck clean
packages/control-plane 858 pass (900 with Redis up; +9 new)
supervisor k8s manifest tests 104 pass
deploy/k8s/tests/setup.test.sh 105 pass (+2 new)
lint (pre-commit --all-files) all hooks pass
check-links 209 files, 0 broken

Remaining full-suite failures are pre-existing flakes in files this PR does not touch —
handoff.integration.test.ts (fails 2-of-3 in isolation on its own), the mocactl ink overlays, and
an sbx subdirectory test from #611. Happy to file those separately if useful.

Still owed

The live N=10 re-run on OpenShift against this build. The previous head passed 10/10 plus 4/4
cross-user claims, but that was before the projected-volume change, so it needs redoing — and the
projected volume is precisely what the e2e exercises, so CI on this push is the first real check.

@aslom
aslom force-pushed the feat/test-user-logins branch from 753098a to 8937c2f Compare October 27, 2026 15:43

@ingpaolodettori-dev ingpaolodettori-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Follow-up on 8937c2f. The code changes look good: the ocp-sbx projected: null fix and the settings-hash fix both check out (verified with kubectl kustomize), and we would approve otherwise. Two things need fixing before this can go in:

  1. The required check job is red on this head. Its Test step (pnpm -r test) exits 1 after the typecheck passes (run 40533836790). check is green on main, so please find the failing test and fix it, or re-run the job if you're confident it's one of the known flakes (handoff.integration, mocactl ink overlays). If it passes on a re-run, please mention which test flaked.
  2. The branch conflicts with main again (mergeable: CONFLICTING; main has moved 12 commits past the base). Please rebase. That also triggers a fresh CI run.

Once check is green on the rebased head, we'll approve.

@aslom
aslom force-pushed the feat/test-user-logins branch from 8937c2f to 53d8764 Compare October 27, 2026 18:07

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Rebased onto 916014df as 53d8764, still one commit. Both blockers addressed.

1. The red check job — the handoff.integration flake

It is the known one, and I measured it rather than assuming. The failing test is:

packages/supervisor/test/handoff.integration.test.ts
  × socket hand-off, end to end > spreads connections across workers by least-in-flight

Running just test/main.test.ts test/handoff.integration.test.ts three times each, same machine,
back to back:

runs failed
clean main (2e53d445) 2 of 3
this branch 1 of 3

Same test, same assertion (expected 1 to be 2), and neither that file nor
packages/supervisor/src is in this branch's diff. main fails it more often than the branch
does, so this is the pre-existing flake, not a regression.

One thing worth knowing about how it presents: pnpm -r stops at the first failing package, so
a supervisor flake masks every package after it — including control-plane, where all of this
PR's code lives. Run directly, control-plane is 1010/1010 on this head.

On the rebased head I also saw two harness getBundleRedis reconnect tests fail inside the full
pnpm -r test, which pass 763/763 twice when harness is run on its own — load-induced, same
shape. Neither is in the diff either.

So: a re-run is the right call, and if CI stays red on something other than those, that would be
a real signal worth chasing.

2. The conflict — rebased, and the two gates are now one mechanism

The conflict was in routes.ts and server.ts, and it was a genuine one rather than adjacent
lines: main had independently added the same idea for a different feature —
RouteSpec.requiresEgress plus matchRoute(..., opts: { egress?: boolean }) — while this branch
added RouteSpec.feature plus matchRoute(..., features: RouteFeatures).

Rather than carry two parallel option objects, I folded mine into theirs. RouteFeatures now holds
both gates and matchRoute takes one opts:

export interface RouteFeatures {
  /** `config.sandbox.egress` is configured: mounts the credential routes on the sandbox profile. */
  egress?: boolean;
  /** SH_TEST_USERS > 0: mounts POST /v1/auth/test-token. */
  testUsers?: boolean;
}

// ...
if (profile === 'sandbox' && route.requiresEgress && !opts.egress) continue;
if (route.feature && !opts[route.feature]) continue;

The two conditions stay separate because they are not the same rule — requiresEgress is
per-profile, feature is global — but they share one options bag, so the next gate has an obvious
home. server.ts passes both. feature is typed 'testUsers' rather than
keyof RouteFeatures, so adding egress to the interface cannot accidentally make it a valid
feature value.

The merge had also welded this branch's doc comment onto operationId's line; fixed.

Verification on 53d8764

pnpm -r typecheck clean
packages/control-plane 1010 pass (Redis up)
harness 763 pass
supervisor k8s manifest tests 105 pass
deploy/k8s/tests/setup.test.sh 107 pass
deploy/sbx/tests/overlay.test.sh 16 pass
check-links 0 broken
lint (pre-commit --all-files) all hooks pass

Thanks for verifying the projected: null and settings-hash fixes with kubectl kustomize — and
for the earlier catch on the overlay. That one was a real break I had missed, and the
one-source-type assertion it prompted is now in overlay.test.sh.

Still owed, and I would rather say so than let it pass quietly

The live N=10 run on OpenShift has not been redone since the projected-volume rework. The
earlier run passed 10/10 users and 4/4 cross-user claims, but that was two designs ago; the
projected volume is exactly what k8s-kind-e2e exercises, so CI is the current evidence and a
fresh manual run is still worth doing before anyone relies on this on a real cluster.

@ingpaolodettori-dev ingpaolodettori-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Follow-up on 8937c2f. The rebase looks correct. The only change from 8937c2f is the routes.ts/server.ts conflict with #630, and folding testUsers into RouteFeatures alongside egress keeps main's per-profile requiresEgress check intact. feature?: 'testUsers' is a good guard. The JSDoc placement nit is fixed too. Re-rendered kubectl kustomize deploy/k8s/overlays/ocp-sbx at this head, and credentials is still a single secret volume.

On check: thanks for measuring the handoff.integration flake. It's consistent with main's own check/Test failure at 0b2339b today. Approving on a green re-run. The earlier nits on --test-user stray-flag handling and the test-users.py vacuous pass stay open as non-blocking follow-ups.

Fixes rossoctl#616

Every login to the control plane is GitHub's device flow, which needs a
person with a browser. smoke.sh works around that by minting an API token
inside the control-plane pod with the signing key, which skips
refresh.issue(), rolesFor() and the audit login entry -- so the smoke
authenticates in a shape no real user has, and needs kubectl exec to do
it. Multi-user behaviour (per-subject credentials, cross-user session
denial) had no automated coverage above one user.

With SH_TEST_USERS=N the control plane now serves logins for N synthetic
subjects test:1..test:N via POST /v1/auth/test-token, and
`mocactl login --test-user N` drives it. A test user is configured
exactly like a GitHub user: the handler makes the same two calls as
completeDeviceAuth -- refresh.issue() then loginBody() -- so token TTLs,
rotation, roles, ownership, audit and credential storage cannot tell them
apart. Only the way identity is established differs.

The path is gated by SH_TEST_USER_SECRET, which the client presents and
the control plane compares in constant time (both sides hashed to 32
bytes first, so a length mismatch is neither an exception nor an oracle).
The endpoint issues a real login for an arbitrary subject, so without a
secret anyone who could route to the control plane would be every test
user -- including a compromised sandbox. The secret is a credential FILE
via CONTROL_PLANE_SECRETS, never an env line a kubectl child inherits,
and setup.sh generates it into moca-mu1 without rotating it on a re-run.

Fails closed: 404 when SH_TEST_USERS is unset (indistinguishable from an
unrouted path), the route is not mounted on the sandbox profile at all
and the control plane refuses to boot if the two are combined, N is
capped at 1000, the secret must be at least 16 characters, and a warning
names the count at boot and on every setup run.

deploy/k8s/test-users.py drives N users (default 10) through real
mocactl commands, one XDG_CONFIG_HOME each -- auth.json holds exactly one
login, so one directory cannot hold ten -- and asserts the cross-user
claims: user 2 is refused user 1's real session, user 1 still resumes it,
and each list holds only its owner's sessions. --api runs the same claims
over HTTP as a reference for a non-CLI client.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Aleksander Slominski <aslom@us.ibm.com>
@aslom
aslom force-pushed the feat/test-user-logins branch from 53d8764 to 0c1ccd7 Compare October 27, 2026 21:39

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Rebased onto 5bcff7a6 as 0c1ccd7, still one commit, no conflicts.

The check failure should be gone

It was not the handoff.integration flake I measured last round — or rather, that was only part of
it. The actual blocker was a second, deterministic timeout that I had misattributed to contention:

packages/mocactl/test/sbx-push.test.ts
  × the push list (SBX0 §6.2) > splits a large tree into several Execs  (timed out in 120000ms)

Measured per phase, that test spent 0.6 s in planPush, 1.3 s in pushTree, and 19.6 s in the
assertions
— almost all of it on one line comparing two ~12 MiB buffers with toEqual, which
walks them element by element. Buffer.equals asserts the same thing in ~0 ms. It reproduced with
all 128 sibling tests skipped, so it was never about parallelism; I was wrong to call it starvation.

That is fixed on main now (#642, merged as 5bcff7a), so this rebase picks it up rather than
carrying a duplicate. On the rebased head the test runs in 2667 ms, down from 23967 ms, and
still fails if a single byte of the pushed copy differs — I checked by flipping one.

So check should now be red only if something real is wrong, which is the point.

What remains, and it is the flake you already accepted

pnpm -r test on this head still shows the supervisor pair:

× shutdown (§3.9) > stops after the grace period when a turn never finishes
× socket hand-off, end to end > spreads connections across workers by least-in-flight

Three consecutive runs of that package on this head gave 2, 1 and 0 failures — same code, same
machine. Neither file is in this branch's diff, and you confirmed main's own check fails the
same way at 0b2339b. pnpm -r stops at the first failing package, so this one also masks
everything after supervisor.

One honest correction to my own numbers: an earlier run of this verification reported 57 mocactl
failures, which alarmed me. Re-running on an otherwise-idle machine gave 7, all of them the same
Test timed out in 5000ms with no assertion failures, and the two worst files passed 16/16 in
isolation. The 57 was my own concurrent test runs competing for CPU — worth saying plainly so the
number is not read as a signal.

Verification on 0c1ccd7

pnpm -r typecheck clean
packages/control-plane 1010 pass
packages/mocactl 1551 pass, 7 timeout-flakes (0 assertion failures)
supervisor k8s manifest tests 105 pass
sbx-push suite (the formerly-failing one) 129 pass, the big test now 2667 ms
deploy/k8s/tests/setup.test.sh 107 pass
deploy/sbx/tests/overlay.test.sh 16 pass
lint (pre-commit --all-files) 7/7 hooks
check-links 212 files, 0 broken

Also worth noting from the last CI run: k8s-kind-e2e passed on 53d8764. That was the job the
projected-volume rework was for, and it is the first green run of it on this branch.

The two follow-ups you left open

Both are already in this commit, so they should not carry forward:

  • --test-user stray-flag handling — cli.ts has the guard next to --no-browser's:
    --test-user only applies to \mocactl login``, exit 2.
  • test-users.py vacuous pass — both session ids are now a prerequisite claim that fails loudly
    when either is missing, and the exclusion asserts u1.session_id in ids1 so it cannot be
    satisfied by an empty id.

If you meant something narrower by either, say which and I will take another look.

Still owed

The live N=10 run on OpenShift has not been redone since the projected-volume rework. The earlier
run passed 10/10 users and 4/4 cross-user claims, but that was two designs ago; k8s-kind-e2e going
green is the current evidence, and a fresh manual run is still worth doing before anyone relies on
this on a real cluster.

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Rebased onto 5bcff7a6 as 0c1ccd7, still one commit, no conflicts.

The check failure should be gone

It was not the handoff.integration flake I measured last round — or rather, that was only part of
it. The actual blocker was a second, deterministic timeout that I had misattributed to contention:

packages/mocactl/test/sbx-push.test.ts
  × the push list (SBX0 §6.2) > splits a large tree into several Execs  (timed out in 120000ms)

Measured per phase, that test spent 0.6 s in planPush, 1.3 s in pushTree, and 19.6 s in the
assertions
— almost all of it on one line comparing two ~12 MiB buffers with toEqual, which
walks them element by element. Buffer.equals asserts the same thing in ~0 ms. It reproduced with
all 128 sibling tests skipped, so it was never about parallelism; I was wrong to call it starvation.

That is fixed on main now (#642, merged as 5bcff7a), so this rebase picks it up rather than
carrying a duplicate. On the rebased head the test runs in 2667 ms, down from 23967 ms, and
still fails if a single byte of the pushed copy differs — I checked by flipping one.

So check should now be red only if something real is wrong, which is the point.

What remains, and it is the flake you already accepted

pnpm -r test on this head still shows the supervisor pair:

× shutdown (§3.9) > stops after the grace period when a turn never finishes
× socket hand-off, end to end > spreads connections across workers by least-in-flight

Three consecutive runs of that package on this head gave 2, 1 and 0 failures — same code, same
machine. Neither file is in this branch's diff, and you confirmed main's own check fails the
same way at 0b2339b. pnpm -r stops at the first failing package, so this one also masks
everything after supervisor.

One honest correction to my own numbers: an earlier run of this verification reported 57 mocactl
failures, which alarmed me. Re-running on an otherwise-idle machine gave 7, all of them the same
Test timed out in 5000ms with no assertion failures, and the two worst files passed 16/16 in
isolation. The 57 was my own concurrent test runs competing for CPU — worth saying plainly so the
number is not read as a signal.

Verification on 0c1ccd7

pnpm -r typecheck clean
packages/control-plane 1010 pass
packages/mocactl 1551 pass, 7 timeout-flakes (0 assertion failures)
supervisor k8s manifest tests 105 pass
sbx-push suite (the formerly-failing one) 129 pass, the big test now 2667 ms
deploy/k8s/tests/setup.test.sh 107 pass
deploy/sbx/tests/overlay.test.sh 16 pass
lint (pre-commit --all-files) 7/7 hooks
check-links 212 files, 0 broken

Also worth noting from the last CI run: k8s-kind-e2e passed on 53d8764. That was the job the
projected-volume rework was for, and it is the first green run of it on this branch.

The two follow-ups you left open

Both are already in this commit, so they should not carry forward:

  • --test-user stray-flag handling — cli.ts has the guard next to --no-browser's:
    --test-user only applies to \mocactl login``, exit 2.
  • test-users.py vacuous pass — both session ids are now a prerequisite claim that fails loudly
    when either is missing, and the exclusion asserts u1.session_id in ids1 so it cannot be
    satisfied by an empty id.

If you meant something narrower by either, say which and I will take another look.

Still owed

The live N=10 run on OpenShift has not been redone since the projected-volume rework. The earlier
run passed 10/10 users and 4/4 cross-user claims, but that was two designs ago; k8s-kind-e2e going
green is the current evidence, and a fresh manual run is still worth doing before anyone relies on
this on a real cluster.

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A correction to my previous comment, before it misleads anyone reading back.

I wrote that the only failure left on this head was the supervisor handoff.integration flake.
That was wrong. proto was also red, and not from a flake — go test -race ./... was reporting
a genuine data race in internal/guestagent:

Read at ... by goroutine 433:
  testing.(*common).log()
  guestagent.(*EgressForwarder).logf()    egress.go:52
  guestagent.(*EgressForwarder).handle()  egress.go:61
Previous write at ... by goroutine 110:
  testing.tRunner.func1()                testing.go:2023

tRunner.func1 is the test finishing, so a forwarder goroutine was calling t.Logf on a
*testing.T that had already returned. startForwarder passes t.Logf straight to the forwarder;
its t.Cleanup closes the listener, which ends Serve, but Serve spawns each connection with a
bare go f.handle(c) and nothing joins those goroutines.

It predates this branch — main's own egress_test.go, untouched by #620 — and it is fixed
separately in #645 rather than here, since it has nothing to do with test users.

Two things I got wrong on the way to that, worth recording because they are the reason the first
answer was confident and incorrect:

  • I called it a flake before reading the log. I could not read the job log (403 without admin
    rights) and inferred from the step name that it was the same -race timing noise as the
    supervisor pair. The log said otherwise on the first line of the trace.
  • My first attempt to verify the fix locally reported "5 runs, 0 races" and meant nothing: this
    sandbox forbids Unix socket bind, so every affected test had already failed at bind before
    reaching the racing code. The one TCP-only test on the same path passes 20 consecutive -race
    runs both with and without the fix, so the race needs CI's timing. That fix is reasoned from the
    stack trace, and CI is its test — stated as such in its own PR rather than claimed as verified.

Nothing about this changes #620's own verification: control-plane is 1010/1010 on this head,
k8s-kind-e2e is green, and the deploy-scripts, docs and lint jobs pass. The check job's
sbx-push timeout is gone since the #642 rebase. Once #645 lands and this branch is rebased
onto it, proto should be green too — and at that point every red check on this PR will have had a
cause found rather than a re-run.

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A correction to my previous comment, before it misleads anyone reading back.

I wrote that the only failure left on this head was the supervisor handoff.integration flake.
That was wrong. proto was also red, and not from a flake — go test -race ./... was reporting
a genuine data race in internal/guestagent:

Read at ... by goroutine 433:
  testing.(*common).log()
  guestagent.(*EgressForwarder).logf()    egress.go:52
  guestagent.(*EgressForwarder).handle()  egress.go:61
Previous write at ... by goroutine 110:
  testing.tRunner.func1()                testing.go:2023

tRunner.func1 is the test finishing, so a forwarder goroutine was calling t.Logf on a
*testing.T that had already returned. startForwarder passes t.Logf straight to the forwarder;
its t.Cleanup closes the listener, which ends Serve, but Serve spawns each connection with a
bare go f.handle(c) and nothing joins those goroutines.

It predates this branch — main's own egress_test.go, untouched by #620 — and it is fixed
separately in #645 rather than here, since it has nothing to do with test users.

Two things I got wrong on the way to that, worth recording because they are the reason the first
answer was confident and incorrect:

  • I called it a flake before reading the log. I could not read the job log (403 without admin
    rights) and inferred from the step name that it was the same -race timing noise as the
    supervisor pair. The log said otherwise on the first line of the trace.
  • My first attempt to verify the fix locally reported "5 runs, 0 races" and meant nothing: this
    sandbox forbids Unix socket bind, so every affected test had already failed at bind before
    reaching the racing code. The one TCP-only test on the same path passes 20 consecutive -race
    runs both with and without the fix, so the race needs CI's timing. The fix therefore went out
    reasoned from the stack trace rather than claimed as verified — and #645 is now green on all 15
    checks, proto included
    , which is the evidence I could not produce locally.

Nothing about this changes #620's own verification: control-plane is 1010/1010 on this head,
k8s-kind-e2e is green, and the deploy-scripts, docs and lint jobs pass. The check job's
sbx-push timeout is gone since the #642 rebase. Once #645 lands and this branch is rebased onto
it, proto will be green here too — it already is on #645. At that point every red check this PR
has had will have had a cause found and fixed, rather than a re-run.

@ingpaolodettori-dev ingpaolodettori-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-approving on the rebase. git range-diff against 53d8764 shows the commit is unchanged, and the one overlap with main (mocactl cli.ts, #638's --prompt-file) merged cleanly with both flag guards intact. Thanks for tracking the check failure down to the sbx-push toEqual timeout fixed in #642.

@pdettori
pdettori merged commit c10192d into rossoctl:main Oct 28, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature: Test users: let automation log in without the GitHub device flow

3 participants