Skip to content

feat(MAPCO-11434): let the worker bill a Claude subscription instead of an API key - #13

Open
razbroc wants to merge 6 commits into
feat/implement-and-verifyfrom
feat/subscription-auth
Open

razbroc wants to merge 6 commits into
feat/implement-and-verifyfrom
feat/subscription-auth

Conversation

@razbroc

@razbroc razbroc commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Stacked on #7 — review that first.

Question Answer
Bug fix
New feature
Breaking change
Deprecations
Documentation
Tests added
Chore

Adds a second authentication mode so the worker can bill a Claude subscription token instead of a metered Anthropic API key, and wires the Secret and README rows that were the unmet half of #7's first acceptance criterion.

⚠️ Read this before merging

Anthropic's Agent SDK documentation states:

Unless previously approved, Anthropic does not allow third party developers to offer claude.ai login or rate limits for their products, including agents built on the Claude Agent SDK.

This PR makes the mode reachable, not permitted. Setting MODEL_AUTH=subscription asserts that this deployment has that approval; no code can check it. api-key stays the default.

Three consequences no code can fix, recorded in both README.md and credential.ts:

  • Shared quota. Rate limits belong to the account, so the worker and that person's own interactive Claude Code use starve each other.
  • Attribution. Runs are that person's, not the worker's — the same problem the README already records for the shared Jira service account, now for the model.
  • Expiry. Subscription tokens lapse, and the pod crash-loops when one does. That is the intended failure, not a bug.

The mode is explicit, never inferred

The design decision worth reviewing. MODEL_AUTH is read from configuration; the worker does not pick whichever credential happens to be present.

Both credentials look alike to the SDK and bill completely differently. Inferring would make the billed party a property of the pod's environment rather than of a decision, and the failure mode is silent — a run that quietly spends someone's personal quota looks exactly like a working one. So:

  • One mode's credential is never used for the other.
  • When the expected one is missing and the other is set, the error names it. Setting a token and forgetting the mode is the mistake an operator actually makes.
  • An unrecognised mode refuses to start rather than falling back to the default, because a typo would otherwise bill the wrong account.

modelEnv now scrubs then injects

It previously injected ANTHROPIC_API_KEY over a partially-scrubbed environment. With two modes reading different variables, leaving the unused one in place would let the SDK pick the other — so every credential is scrubbed and exactly one goes back in, making the choice singular by construction. Tested in both modes.

What this closes on #7

  • The worker authenticates with a credential from an OpenShift Secret, never an interactive login — now genuinely both halves. helm/templates/deployment.yaml gets MODEL_AUTH plus a secretKeyRef for whichever credential the mode needs (apiKey or oauthToken from worker.modelSecretName), and the README documents all three variables.

Note the criterion's wording is now strained: subscription mode is an interactive-login credential, obtained with claude setup-token. It is opt-in, off by default, and the reason is in the file — but if you'd rather the ticket's wording hold literally, this PR is the thing to drop.

Verification

tsc --noEmit clean, eslint clean, 202 tests pass (17 files) on this branch.

Not verified: the chart change. helm template cannot run here — Error: found in Chart.yaml, but missing in charts/ directory: mclabels, the known gap the README already records. The template edit is unrendered. Worth a second pair of eyes on the {{- if eq .Values.worker.modelAuth "subscription" }} branch.

apiKey.ts is renamed to credential.ts, since it no longer reads only a key.

Refs: MAPCO-11434

Update: renamed to sdk, now the default, and the chart is verified

Three commits since the description above was written.

MODEL_AUTH takes sdk, not subscription, and is the default. A deployment that sets nothing now authenticates with a Claude subscription token. The trade is explicit: the safe direction is no longer the default, so a deployment or laptop carrying only ANTHROPIC_API_KEY refuses to start and names the credential it found, rather than quietly billing an account nobody chose. api-key is one variable away.

Since sdk is the default, the approval caveat at the top now applies to running this worker at all, not to an opt-in.

The chart could not render — and not because of this PR. helm/values.yaml set mclabels.component: worker, which the subchart's values.schema.json rejects (allowed: frontend | backend | database | proxy-server | cache-server | infrastructure). Every helm template and helm lint failed on it regardless of the auth work; this would have failed CI and blocked a deploy. Set to backend.

⚠️ That label feeds org-wide dashboards and ownership tooling. If MapColonies conventionally tags an outbound-only poller as infrastructure, change it before merge.

The MODEL_AUTH block is now verified, not assumed. Rendered against the real mclabels chart in both modes and parsed with a YAML parser — the expected variable is present at the right depth in containers[0].env, the other branch's variable is absent, and indentation matches the surrounding entries. helm lint passes with only the icon is recommended info.

# default
- name: MODEL_AUTH
  value: "sdk"
- name: CLAUDE_CODE_OAUTH_TOKEN
  valueFrom: { secretKeyRef: { name: "…", key: oauthToken } }

# --set worker.modelAuth=api-key
- name: MODEL_AUTH
  value: "api-key"
- name: ANTHROPIC_API_KEY
  valueFrom: { secretKeyRef: { name: "…", key: apiKey } }

Also fixed: trailing whitespace in deployment.yaml leaked into rendered output, landing on the new block's last line as key: apiKey .

mclabels is documented, not solved. Anonymous pull from oci://acrarolibotnonprod.azurecr.io/helm/infra is refused with a 401. A developer needs az acr login --name acrarolibotnonprod (or helm registry login) first; the README now says so, replacing the "fails without registry access" known-gap line. Nothing vendored is committed — helm/charts/ is already covered by the root .gitignore.

Gate: tsc --noEmit clean, eslint clean, 204 tests pass, helm lint passes.

MAPCO-11434. Adds a second authentication mode so a deployment can run against a
Claude subscription token rather than a metered Anthropic API key, and wires the
Secret and the docs that were the unmet half of the first acceptance criterion.

Which mode is in use is explicit configuration, `MODEL_AUTH`, and is never
inferred from whichever credential happens to be set. Both credentials look
alike to the SDK and bill completely differently, so inferring would make the
billed party a property of the pod's environment rather than of a decision — and
the failure is silent, because a run that quietly spends someone's personal quota
looks exactly like a working one. One mode's credential is never used for the
other; the worker refuses to start and names the one it found, since setting a
token and forgetting the mode is the mistake an operator actually makes. An
unrecognised mode also refuses rather than falling back to the default.

`modelEnv` now scrubs every credential and injects exactly one, the configured
mode's. Previously it injected ANTHROPIC_API_KEY over a partially-scrubbed
environment; with two modes reading different variables, leaving the unused one
in place would let the SDK pick the other.

Chart: MODEL_AUTH plus a secretKeyRef for whichever credential the mode needs,
from worker.modelSecretName. README documents both variables and the mode.

subscription mode is reachable, not blessed. Anthropic's Agent SDK documentation
states that claude.ai login and its rate limits may not be used for products
built on the Agent SDK unless previously approved, so setting the mode asserts
this deployment has that approval — code cannot check it. Three consequences no
code can fix are recorded in README.md and credential.ts: the quota is shared
with that person's own interactive use, runs are attributed to them rather than
to the worker, and the pod crash-loops when the token expires. api-key remains
the default for those reasons.

Renames apiKey.ts to credential.ts, since it no longer only reads a key.
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

🎫 Related Jira Issue: MAPCO-11434

…fault

MODEL_AUTH takes 'sdk' rather than 'subscription', and a deployment that sets
nothing now gets it. This worker is meant to run on a Claude subscription, and a
default of api-key meant every manifest had to remember to say so.

The cost is that the safe direction is no longer the default. A deployment or a
laptop carrying only ANTHROPIC_API_KEY used to work and now refuses to start,
naming the credential it found — which is the loud half of the trade, and better
than billing an account nobody chose.
Three things, all of which had to happen before the MODEL_AUTH block could be
trusted.

**The chart could not render at all.** `helm/values.yaml` set
`mclabels.component: worker`, and the subchart's values.schema.json restricts
that field to frontend | backend | database | proxy-server | cache-server |
infrastructure. Every `helm template` and `helm lint` failed on it, with or
without the dependency present — this would have failed CI and blocked a deploy
regardless of the auth work. Set to `backend`, with the allowed set named in a
comment. That label feeds org-wide dashboards and ownership tooling, so change it
if MapColonies conventionally tags pollers as `infrastructure`.

**The MODEL_AUTH block is now verified rather than assumed.** Rendered in both
modes against the real mclabels chart and parsed with a YAML parser: the expected
variable is present at the right depth in containers[0].env and the other
branch's variable is absent. `helm lint` passes.

**Trailing whitespace in deployment.yaml** leaked into rendered output, landing
on the new block's last line as `key: apiKey            `. Stripped.

The chart now defaults to `modelAuth: sdk` to match DEFAULT_AUTH_MODE, and the
secretKeyRef key follows the mode — `oauthToken` for sdk, `apiKey` for api-key.

README records how to fetch mclabels. Anonymous pull from the OCI registry is
refused (401), so a developer needs `az acr login --name acrarolibotnonprod` or
`helm registry login` first; that is documented rather than presented as solved.
Compared against mapproxy-api, cleaner, exporter-trigger, geojson-viewer and the
rest. Two things were wrong and one is now documented.

**prometheus.enabled is false.** Almost every chart in the org sets it true, and
this one must not: the worker is outbound-only — no Service, no Route, no
container port — and although containerConfig.ts builds a prom-client Registry,
nothing ever serves it over HTTP. Left true, mclabels stamped
`prometheus.io/scrape: "true"` with port 8080 and /metrics onto the pod, giving
Prometheus a target that could only ever fail. geojson-viewer, the other chart
with nothing to scrape, does the same.

**component: backend is confirmed, not guessed.** Every non-frontend chart in the
org uses it, and `worker` appears in no schema. The earlier open question about
`infrastructure` is settled: nothing uses it.

**The environment label is documented.** Only `mclabels.environment` feeds
`mapcolonies.io/environment` — `global.environment` and a top-level `environment`
are both ignored by the subchart — and leaving it unset stamps the literal string
`undefined`. Added the commented `#environment:` line the other charts carry, so
a deploy-time values file supplies it.

`gisDomain` stays absent: the raster-owned charts carry it, the common-owned
tooling charts do not, and this is one of those.
A `node_modules` symlink pointing at the main checkout was committed with
this branch. `.gitignore` listed `node_modules/` with a trailing slash, which
matches a directory but not a symlink, so `git add -A` picked it up.

On CI the symlink dangles, which broke two jobs:
  - eslint: `npm ci` could not populate it, so lint-action fell through to
    `npx eslint` and failed with 'npx canceled due to missing packages'.
  - build_docker_image: 'cannot replace to directory .../node_modules with file'.

Drop the entry and drop the trailing slash so the ignore rule covers both.
The README still said the current slice was MAPCO-11431 and described a worker
that writes no code. It now covers what is actually on this branch.

New section on implementing a ticket: why the model's tool surface is the
security control rather than the prompt, why a write counts only when its
tool_result comes back (reading the attempt alone certified diffs that did not
exist), and why the test command is taken off the pristine clone before the first
hand-off so the model cannot rewrite what grades it.

Two chart decisions added to "Things that look wrong but aren't": this chart sets
prometheus.enabled false where almost every other one sets it true, because there
is nothing listening to scrape; and mclabels.environment is the only key that
feeds the environment label, with global.environment and a top-level environment
both ignored and the unset case stamping the literal string "undefined".

Two known gaps recorded rather than left for a reader to discover: nothing is
wired into runCycle, so this is a library with tests and not a behaviour the
deployed worker has; and DescriptionPort has no implementation, so every ticket
is refused before the first model turn.
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.

1 participant