Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 0 additions & 49 deletions .github/workflows/vault-bootstrap-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,6 @@ jobs:
# with the script when a policy lands or is retired.
required_policies=(
agents-api
agents-oauth-writer
auth-api
downloads
knowledge-api
Expand All @@ -228,51 +227,3 @@ jobs:
exit 1
fi
echo "all required policies uploaded successfully"

- name: Assert the OAuth-credential RBAC boundary
# The bootstrap log only proves a policy was uploaded, not that
# its capabilities are correct. Mint a token under each policy and
# exercise the boundary that matters: agents-api (deploy keys) must
# NOT be able to write the OAuth paths or destroy their version
# history, and agents-oauth-writer must be able to write the OAuth
# data and read its metadata but NOT delete it. A helper inverts
# the exit code so a *successful* denied-operation fails the step.
env:
VAULT_ADDR: http://127.0.0.1:8200
VAULT_TOKEN: dev-root-token
run: |
set -euo pipefail

must_deny() {
# Runs "$@" with a scoped token; fails the step if it SUCCEEDS.
if VAULT_TOKEN="${SCOPED}" "$@" >/dev/null 2>&1; then
echo "FAIL: expected permission denied but the operation succeeded: $*" >&2
exit 1
fi
}

AGENTS_API=$(vault token create -policy=agents-api -ttl=5m -field=token)
OAUTH_WRITER=$(vault token create -policy=agents-oauth-writer -ttl=5m -field=token)

# agents-api keeps its deploy-key write.
VAULT_TOKEN="${AGENTS_API}" vault kv put secret/agents/repositories/ci-test key=value >/dev/null
echo "ok: agents-api can write agents/repositories/*"

# agents-api must NOT reach the OAuth data or destroy its metadata.
SCOPED="${AGENTS_API}" must_deny vault kv put secret/agents/claude-oauth credentials_json=nope
echo "ok: agents-api denied write on agents/claude-oauth"
vault kv put secret/agents/claude-oauth credentials_json=seed >/dev/null
SCOPED="${AGENTS_API}" must_deny vault kv metadata delete secret/agents/claude-oauth
echo "ok: agents-api denied metadata delete on agents/claude-oauth"

# agents-oauth-writer can write data + read metadata for CAS,
# but must NOT be able to delete (destroy) the version history.
VAULT_TOKEN="${OAUTH_WRITER}" vault kv put secret/agents/claude-oauth credentials_json=v1 >/dev/null
VAULT_TOKEN="${OAUTH_WRITER}" vault kv put secret/agents/codex-oauth auth_json=v1 >/dev/null
echo "ok: agents-oauth-writer can write the OAuth data paths"
VAULT_TOKEN="${OAUTH_WRITER}" vault kv metadata get secret/agents/claude-oauth >/dev/null
echo "ok: agents-oauth-writer can read OAuth metadata (CAS version checks)"
SCOPED="${OAUTH_WRITER}" must_deny vault kv metadata delete secret/agents/claude-oauth
echo "ok: agents-oauth-writer denied metadata delete on agents/claude-oauth"

echo "OAuth-credential RBAC boundary holds"
7 changes: 3 additions & 4 deletions platform/cluster/flux/apps/agents/AGENT-PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,9 @@ default home.

The `agents-login` portal keeps the two in lockstep too: its image
bakes both CLIs, the worker captures both credential sets in the same
login session, and it writes both Vault paths
(`secret/agents/claude-oauth` with `credentials_json`/`claude_json`,
`secret/agents/codex-oauth` with `auth_json`/`config_toml`) — a login
that refreshed only one provider is a parity regression.
login session, and it posts both provider payloads to agents-api
(`CLAUDE` with `oauth_token`, `CODEX` with `auth_json`/`config_toml`) —
a login flow that cannot refresh both providers is a parity regression.

The `auth-bootstrap` Pod populates both with one human
exec-and-paste session each (see `README.md`). The
Expand Down
9 changes: 4 additions & 5 deletions platform/cluster/flux/apps/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,12 @@ Deployment), plus the Docker socket gid and runner egress node IP.
The browser-driven login portal (`agents-login`, controller + worker
Deployments in this directory) is the preferred way to refresh
credentials: it runs the CLI logins server-side and writes the captured
OAuth to Vault (`secret/agents/claude-oauth`, `secret/agents/codex-oauth`)
from where VSO projects them to every runner — no terminal copy-paste,
one sign-in for the whole fleet. See `SETUP.md` for the portal flow, the
Vault schema, the Lease+CAS writeback model, and the node-pin
OAuth credentials to agents-api, which persists them in Postgres — no
terminal copy-paste, one sign-in for the whole fleet. See `SETUP.md` for
the portal flow, the internal ingest model, and the node-pin
classification. The terminal bootstrap below remains the fallback until
the companion runner change (in the `agents` repo) makes runners consume
the projected Secrets instead of the PVCs.
credentials from agents-api instead of the PVCs.

## First-time auth bootstrap

Expand Down
82 changes: 32 additions & 50 deletions platform/cluster/flux/apps/agents/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,24 @@ for when the portal is not yet deployed.
### Login portal (preferred)

`agents-login.jorisjonkers.dev` is a browser-driven portal that runs
`claude /login` and `codex login --device` server-side and writes the
captured OAuth credentials to Vault, from where VSO projects them to
every runner — so one re-sign-in renews the whole fleet and no text has
to be copied out of a terminal. It is forward-auth protected by a
`claude setup-token` and `codex login --device` server-side and posts the
captured OAuth credentials to agents-api, which persists them in Postgres
for runner consumption — so one re-sign-in renews the whole fleet and no
text has to be copied out of a terminal. It is forward-auth protected by a
dedicated `AGENTS_LOGIN` permission, NOT the shared `AGENTS` one: a
holder of that permission can mint the fleet's root credentials, so it
is granted deliberately.

The portal is two Deployments in `agents-system`:

- **controller** — the public UI. Holds no Vault token and stores no
- **controller** — the public UI. Holds no credential store access and stores no
credential files. Uses short HTTP polling (not a WebSocket: the
Cloudflare edge kills idle sockets ~100 s and the OAuth approval idles
longer) and proxies the flow to the worker over an internal token.
- **worker** — owns the PTY that runs the CLI login, captures the
credential files, and writes Vault under a single-writer Kubernetes
`Lease` (`agents-login-write`) with KV v2 Compare-And-Set. Bound to the
`agents-oauth-writer` Vault policy via the `agents-login-worker`
ServiceAccount.
credentials, and posts them to agents-api under a single-writer Kubernetes
`Lease` (`agents-login-write`). The worker uses the same
`agents-login-internal` Secret token that agents-api uses for worker calls.

To re-authenticate: open the portal, start the Claude login (copy the
authorize URL into a browser tab signed in to the Claude Pro account,
Expand All @@ -61,77 +60,60 @@ codex login --device # enter the device code at chatgpt.com/codex/auth
infinity`, a later exec session's stdout is not the Pod's logs, and the
Claude flow needs the redirect URL fed back on stdin.

## Vault schema for the OAuth credentials
## agents-api credential ingest

The portal writes two KV v2 paths under `secret/agents`:
The worker calls agents-api's internal ingest endpoint:

- `secret/agents/claude-oauth` — fields `credentials_json`, `claude_json`
(+ `schema_version`, `updated_at`, `updated_by`).
- `secret/agents/codex-oauth` — fields `auth_json`, `config_toml` (+ the
same metadata).
- `POST http://agents-api.agents-system.svc.cluster.local:8082/api/v1/internal/credentials`
- `Authorization: Bearer <agents-login-internal token>`
- Body: `userId`, uppercase `provider`, and a provider payload.

Field names are underscored because `vault kv put` with leading-dot keys
is awkward; the `VaultStaticSecret` transformation templates re-emit the
exact CLI filenames (`.credentials.json`, `.claude.json`, `auth.json`,
`config.toml`) into the projected Secrets `agents-claude-oauth` /
`agents-codex-oauth`. `.claude.json` is a SIBLING of `~/.claude/`, not a
file inside it, so a consumer init-copy must place it at
`$HOME/.claude.json`.
Claude payloads contain `oauth_token`. Codex payloads contain `auth_json`
and `config_toml`. agents-api owns persistence in Postgres; the worker does
not read back stored credential status.

The shared controller↔worker token lives at
`secret/agents/login-internal-token` (field `token`). The portal 403s on
writes until the `agents-oauth-writer` policy and the worker auth role
are live, so re-run the `vault-bootstrap-auth` Job first if a fresh init
is involved, then seed:
`secret/agents/login-internal-token` (field `token`) and is projected into
the Kubernetes Secret `agents-login-internal`. Seed it once:

```sh
vault kv put -mount=secret agents/login-internal-token token="$(openssl rand -hex 32)"
```

VSO reconciles every `refreshAfter: 1h`. To pull a fresh login through
immediately:

```sh
kubectl -n agents-system annotate vaultstaticsecret agents-claude-oauth force-sync="$(date +%s)" --overwrite
kubectl -n agents-system annotate vaultstaticsecret agents-codex-oauth force-sync="$(date +%s)" --overwrite
```

## Writeback and failure modes

Both CLIs rotate their refresh token in place on disk, so a read-only
projected Secret would go stale and force a premature re-login. The
worker is the single writer that pushes a rotated credential back to
Vault, serialized by the `agents-login-write` Lease and KV v2 CAS against
`metadata.version`:
The worker is the single writer that posts captured credentials to agents-api,
serialized by the `agents-login-write` Lease:

- **CAS conflict** — the loser re-reads and retries with bounded backoff;
a persistent conflict surfaces an error rather than blind-overwriting.
- **No captured credential** — the session fails and nothing is posted.
- **agents-api ingest failure** — the session fails and nothing is persisted by
the worker.
- **Worker down** — credentials go stale until the next login; the
refresh-ping CronJob is the early-warning signal.
- **Two writers** — prevented by `replicas: 1` + `Recreate` + the Lease.

## Node-pin classification

Moving credentials into Vault does NOT by itself let runners schedule
Moving credentials into Postgres does NOT by itself let runners schedule
anywhere. `AGENT_RUNTIME_NODE=enschede-gtx-960m-1` is **multi-reason**:
the docker-socket GID, the hard-coded egress callback IP in
`network-policy/runner-egress.yaml`, and the untrusted-workloads-at-home
policy. Only the **credential** justification is removed by the Vault
policy. Only the **credential** justification is removed by the credential-store
migration; the others keep the pin until the companion runner change
tolerates capability scheduling. The agents-login worker itself is **not**
pinned — it reads Vault, so it runs anywhere.
pinned — it posts to agents-api, so it runs anywhere.

## Companion change (agents repo)

The cluster projects `agents-claude-oauth` / `agents-codex-oauth` as
read-only Secrets. Making runners consume them instead of the PVCs is a
Making runners consume agents-api-stored credentials instead of the PVCs is a
companion change in the `agents` repo:

- `services/agent-runner/Dockerfile` + `/opt/entrypoint.sh` — create a
writable `$HOME/.claude` / `$CODEX_HOME` and init-copy the projected
files into place (`.claude.json` as a sibling of `.claude/`).
- `Fabric8AgentRunnerOrchestrator.kt` — swap the two `claude-credentials`
/ `codex-credentials` PVC volume sources for the projected Secrets.
writable `$HOME/.claude` / `$CODEX_HOME` and materialize the fetched
credential files into place.
- `Fabric8AgentRunnerOrchestrator.kt` — stop mounting the two
`claude-credentials` / `codex-credentials` PVC volume sources once runners
can fetch credentials through agents-api.

Until that lands, runners still read the PVCs, so the legacy bootstrap
(sections 3-5 below) and the `claude-credentials` / `codex-credentials`
Expand Down
23 changes: 13 additions & 10 deletions platform/cluster/flux/apps/agents/agents-login/network-policy.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# The worker is the sensitive half of the credential portal: it holds the
# Vault-writer token and the Lease. It is reached only by agents-api, which
# proxies the login flow from the agents-ui Credentials page (served at
# The worker is the sensitive half of the credential portal: it runs the
# login CLIs and posts captured credentials to agents-api. It is reached only by
# agents-api, which proxies the login flow from the agents-ui Credentials page (served at
# agents.jorisjonkers.dev). Ingress is therefore restricted to the agents-api
# pods on 8081; there is no public-facing component in this namespace.
#
# Egress: DNS, the in-cluster Vault and Kubernetes API (the service CIDR on
# 443/8200), and outbound HTTPS for the Anthropic/OpenAI OAuth flows — RFC1918
# Egress: DNS, agents-api, the Kubernetes API (the service CIDR on
# 443/6443), and outbound HTTPS for the Anthropic/OpenAI OAuth flows — RFC1918
# is excluded from the internet rule so the worker cannot reach arbitrary
# cluster-private hosts (the explicit service-CIDR rule re-allows Vault + apiserver).
# cluster-private hosts (explicit rules re-allow agents-api + apiserver).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
Expand Down Expand Up @@ -45,14 +45,17 @@ spec:
port: 53
- protocol: TCP
port: 53
# In-cluster Vault (KV v2 writes + kubernetes auth login).
# In-cluster agents-api credential ingest.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: data-system
kubernetes.io/metadata.name: agents-system
podSelector:
matchLabels:
app.kubernetes.io/name: agents-api
ports:
- protocol: TCP
port: 8200
port: 8082
# Kubernetes API (the Lease lock) reached via the kubernetes.default
# ClusterIP in the service CIDR. NetworkPolicy cannot select the apiserver
# by label, so allow the service CIDR on the apiserver ports.
Expand All @@ -66,7 +69,7 @@ spec:
port: 6443
# Outbound HTTPS for the Claude/Codex OAuth endpoints. RFC1918 + the
# link-local range are excluded so this rule cannot reach cluster-private
# hosts; Vault and the apiserver are covered by the explicit rules above.
# hosts; agents-api and the apiserver are covered by the explicit rules above.
- to:
- ipBlock:
cidr: 0.0.0.0/0
Expand Down
4 changes: 2 additions & 2 deletions platform/cluster/flux/apps/agents/agents-login/rbac.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Single-writer lock for credential writeback. The worker holds this one named
# Lease while it writes Vault, so two worker instances (e.g. across a Recreate
# rollover) cannot race a Compare-And-Set. Scoped to the single Lease name the
# Lease while it posts to agents-api, so two worker instances (e.g. across a
# Recreate rollover) cannot race. Scoped to the single Lease name the
# worker uses (LEASE_NAME=agents-login-write) — not the whole leases resource.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# The worker is the only identity that writes the OAuth credential paths in
# Vault (bound to the agents-oauth-writer policy via the agents-login-worker
# kubernetes auth role in bootstrap-auth.sh) and the only one that takes the
# write Lease. agents-api proxies the browser login flow to it but holds no
# Vault token itself.
# The worker runs the OAuth CLIs, posts captured credentials to agents-api, and
# takes the write Lease. agents-api proxies the browser login flow to it.
apiVersion: v1
kind: ServiceAccount
metadata:
Expand Down
24 changes: 14 additions & 10 deletions platform/cluster/flux/apps/agents/agents-login/worker.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Private half of the portal. Owns the PTY that runs `claude /login` and
# `codex login --device`, captures the credential files, and writes them to
# Vault via KV v2 Compare-And-Set under a Lease lock. Single instance + Recreate
# so the Lease is never contended by two writers across a rollover.
# Private half of the portal. Owns the PTY that runs `claude setup-token` and
# `codex login --device`, captures credentials, and posts them to agents-api
# under a Lease lock. Single instance + Recreate so the Lease is never
# contended by two writers across a rollover.
#
# HOME is a writable emptyDir, not the legacy credential PVCs: Vault is the
# source of truth, so the worker needs no node pin and can schedule anywhere.
# HOME is a writable emptyDir, not the legacy credential PVCs: agents-api
# persists credentials in Postgres, so the worker needs no node pin and can
# schedule anywhere.
apiVersion: apps/v1
kind: Deployment
metadata:
Expand Down Expand Up @@ -61,10 +62,13 @@ spec:
value: /home/agent
- name: CODEX_HOME
value: /home/agent/.codex
- name: VAULT_ADDR
value: http://vault.data-system.svc.cluster.local:8200
- name: VAULT_K8S_ROLE
value: agents-login-worker
- name: AGENTS_API_INTERNAL_URL
value: http://agents-api.agents-system.svc.cluster.local:8082
- name: AGENTS_API_INTERNAL_BEARER
valueFrom:
secretKeyRef:
name: agents-login-internal
key: token
- name: LEASE_NAME
value: agents-login-write
- name: LEASE_NAMESPACE
Expand Down

This file was deleted.

30 changes: 0 additions & 30 deletions platform/cluster/flux/apps/agents/credentials/codex-oauth-vss.yaml

This file was deleted.

Loading