Skip to content
Open
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,41 @@ so refusal is the common path until the convention spreads.
| `MAX_TICKETS_PER_RUN` | `1` | Tickets one cycle may start |
| `MAX_CONCURRENT_TICKETS` | `1` | Tickets in flight at once |
| `GITHUB_TOKEN` | *optional* | Bearer token for repo lookups. A PAT locally; a short-lived App installation token in the cluster once MAPCO-11428 lands. Unauthenticated works at a lower rate limit |
| `MODEL_AUTH` | `api-key` | Which account model calls are billed to: `api-key` or `subscription`. An unrecognised value refuses to start rather than falling back |
| `ANTHROPIC_API_KEY` | required for `api-key` | Anthropic API key, from a Secret. Billed to that Anthropic account |
| `CLAUDE_CODE_OAUTH_TOKEN` | required for `subscription` | A Claude subscription token from `claude setup-token`. Billed to, and rate-limited as, that person — see the warning below |

Raise `MAX_TICKETS_PER_RUN` before ever raising `MAX_CONCURRENT_TICKETS`.

The two bot-identity variables look redundant and are not: Jira takes an *identifier* on
write and hands back a *display name* on read, and neither is derivable from the other in
this instance. Set them inconsistently and every claim reads as lost.

### Which account pays for the model

`MODEL_AUTH` is explicit, and deliberately not inferred from whichever credential happens to
be present. Both credentials look alike to the SDK and bill completely differently, so letting
the environment decide would make the billed party a property of the pod 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.

> **⚠️ `subscription` needs Anthropic's approval.** Anthropic's Agent SDK documentation states
> that, unless previously approved, claude.ai login and its rate limits may not be used for
> products built on the Agent SDK. Setting `MODEL_AUTH=subscription` asserts that this
> deployment has that approval — the code cannot check it.

Three consequences of `subscription` mode that no code can fix:

- **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 this README
already records for the shared Jira service account, now for the model too.
- **Expiry.** Subscription tokens lapse, and when one does the pod crash-loops rather than
running on unclear credentials. That is the intended failure, not a bug.

`api-key` mode has none of these, and is the default for that reason.

## Claiming and releasing

Jira is the only state store — no database, no files that outlive a run — so there is no
Expand Down
15 changes: 15 additions & 0 deletions helm/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@ spec:
value: {{ .Values.worker.maxTicketsPerRun | quote }}
- name: MAX_CONCURRENT_TICKETS
value: {{ .Values.worker.maxConcurrentTickets | quote }}
- name: MODEL_AUTH
value: {{ .Values.worker.modelAuth | quote }}
{{- if eq .Values.worker.modelAuth "subscription" }}
- name: CLAUDE_CODE_OAUTH_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.worker.modelSecretName | quote }}
key: oauthToken
{{- else }}
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.worker.modelSecretName | quote }}
key: apiKey
{{- end }}
{{- if .Values.caSecretName }}
- name: REQUESTS_CA_BUNDLE
value: {{ printf "%s/%s" .Values.caPath .Values.caKey | quote }}
Expand Down
12 changes: 12 additions & 0 deletions helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ worker:
pollIntervalMs: 300000
maxTicketsPerRun: 1
maxConcurrentTickets: 1
# Which account the model calls are billed to. `api-key` reads ANTHROPIC_API_KEY from the
# Secret below; `subscription` reads CLAUDE_CODE_OAUTH_TOKEN from it instead.
#
# `subscription` bills, and is rate-limited as, the person whose token it is. 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 — setting this asserts
# that this deployment has that approval. The worker and that person's own interactive use
# also share one quota, and the pod will crash-loop when the token expires.
modelAuth: 'api-key'
# Secret holding the model credential. Key name must be `apiKey` for modelAuth: api-key,
# or `oauthToken` for modelAuth: subscription. The pod will not start without it.
modelSecretName: ''

env:
logLevel: info
Expand Down
1 change: 1 addition & 0 deletions node_modules
84 changes: 0 additions & 84 deletions src/agent/apiKey.ts

This file was deleted.

141 changes: 141 additions & 0 deletions src/agent/credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* How the worker gets the credential it talks to the model with.
*
* This lives here rather than in `WorkerConfig` for one reason that is not tidiness: every
* other field of `WorkerConfig` is safe to log, and this one is not. Keeping it out of that
* object means the config a cycle carries around — and that ends up in a log line the day
* someone logs it — never contains a credential. It is read once, at the entry point, and
* handed straight to `AgentSettings`.
*
* There are two modes, and which one is in use is **explicit configuration, never inference**.
* That is the whole design of this file. Both credentials look alike to the SDK and bill
* completely differently: one draws on an organisation's Anthropic account, the other on a
* person's Claude subscription. Picking whichever happened to be present in the environment
* would make the billed party a property of the pod's env rather than of a decision, and the
* failure is silent — a run that quietly spends someone's personal quota looks exactly like a
* working one.
*
* ## ⚠️ `subscription` mode needs Anthropic's approval
*
* Anthropic's Agent SDK documentation states that, unless previously approved, claude.ai login
* and its rate limits may not be used for products built on the Agent SDK. This module makes
* the mode reachable because the operator asked for it; it cannot make it permitted. Whoever
* sets `MODEL_AUTH=subscription` is asserting that this deployment has that approval.
*
* Three operational consequences, none of which code can fix:
*
* - Rate limits belong to the account, so the worker and that person's own interactive use
* share one quota and starve each other.
* - Attribution is that person, not the worker — the same problem the README records for the
* shared Jira service account, now for the model too.
* - Subscription tokens expire. When one lapses the pod crash-loops, by design (see below),
* rather than running on unclear credentials.
*/

/** The credential for an organisation's Anthropic account. Billed to that account. */
const API_KEY_ENV = 'ANTHROPIC_API_KEY';

/** The credential for a person's Claude subscription. Billed to, and rate-limited as, them. */
const SUBSCRIPTION_ENV = 'CLAUDE_CODE_OAUTH_TOKEN';

/** Selects which of the two the worker authenticates with. Defaults to the org-billed key. */
const AUTH_MODE_ENV = 'MODEL_AUTH';

type ModelAuthMode = 'api-key' | 'subscription';

const AUTH_MODES: readonly ModelAuthMode[] = ['api-key', 'subscription'];

const DEFAULT_AUTH_MODE: ModelAuthMode = 'api-key';

/** Which environment variable each mode reads, and nothing else may be substituted for it. */
const CREDENTIAL_ENV: Record<ModelAuthMode, string> = {
'api-key': API_KEY_ENV,
subscription: SUBSCRIPTION_ENV,
};

/**
* The credential, carrying which kind it is.
*
* A tagged value rather than a bare string because the two are injected into the model's
* subprocess under *different* variable names, and getting that wrong does not fail loudly —
* the SDK simply finds no credential where it looked. See `modelEnv` in sdkOptions.ts.
*/
interface ModelCredential {
readonly mode: ModelAuthMode;
/** The variable it was read from. Reported in the boot log; the value never is. */
readonly source: string;
readonly value: string;
}

/**
* A missing or unusable model credential.
*
* Distinct class rather than a bare `Error` for the same reason as `ConfigError` in
* src/common/workerConfig.ts: this is a deployment fault, discovered at boot, and it must not
* read as a ticket that failed.
*/
class AgentConfigError extends Error {
public constructor(message: string) {
super(message);
this.name = 'AgentConfigError';
}
}

function readMode(env: NodeJS.ProcessEnv): ModelAuthMode {
const raw = env[AUTH_MODE_ENV]?.trim() ?? '';

if (raw === '') {
return DEFAULT_AUTH_MODE;
}

const mode = AUTH_MODES.find((candidate) => candidate === raw.toLowerCase());

if (mode === undefined) {
// Not a fallback to the default: a typo in the mode would silently bill the wrong party,
// which is the exact failure this file exists to prevent.
throw new AgentConfigError(`${AUTH_MODE_ENV} must be one of ${AUTH_MODES.join(', ')} — got '${raw}'.`);
}

return mode;
}

/**
* The model credential, or a thrown `AgentConfigError`.
*
* Thrown rather than refused-as-a-value on purpose: a worker with no credential cannot do the
* one thing it exists for, and every ticket it claimed in the meantime would be a claim burnt
* for nothing. Boot is the cheapest place to find out.
*
* The mode's own variable is the *only* one consulted. The other mode's credential being
* present is never a fallback, and is reported when the expected one is missing — a deployment
* that set the token but not the mode is a likely mistake and worth naming, whereas quietly
* using it would be the silent mis-billing this module refuses to allow.
*
* The env map is a parameter so tests drive it with plain objects, exactly as
* `loadWorkerConfig` does — nothing in the suite mutates `process.env`.
*/
function readModelCredential(env: NodeJS.ProcessEnv = process.env): ModelCredential {
const mode = readMode(env);
const source = CREDENTIAL_ENV[mode];
const value = env[source]?.trim() ?? '';

if (value !== '') {
return { mode, source, value };
}

// The other mode's credential, when that is the one that happens to be present. Naming it is
// the actionable half of the message: setting a token and forgetting the mode is the mistake
// an operator actually makes, and using it anyway is the silent mis-billing this refuses.
const found = AUTH_MODES.filter((candidate) => candidate !== mode)
.map((candidate) => CREDENTIAL_ENV[candidate])
.filter((name) => (env[name]?.trim() ?? '') !== '');
const hint =
found.length > 0
? ` ${found.join(' and ')} is set, but ${AUTH_MODE_ENV} is '${mode}', and one mode's credential is never used for the other.`
: '';

throw new AgentConfigError(`${source} must be set — ${AUTH_MODE_ENV} is '${mode}' and the worker has no other way to reach the model.${hint}`);
}

export { AgentConfigError, API_KEY_ENV, AUTH_MODE_ENV, AUTH_MODES, CREDENTIAL_ENV, DEFAULT_AUTH_MODE, readModelCredential, SUBSCRIPTION_ENV };
export type { ModelAuthMode, ModelCredential };
16 changes: 9 additions & 7 deletions src/agent/implementer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import type { AgentLimits, DescriptionPort, ReleasePort } from './types';
* Kept beside the code it composes rather than in an entry point on purpose. `src/index.ts` and
* `runCycle` belong to the wiring slice, and every collaborator they would otherwise construct
* by hand is one more thing that can be wired subtly wrong — a `NpmTestRunner` built on a
* command runner with no environment scrubbing, say, or an agent constructed with a key read
* somewhere other than `readApiKey`. Calling this leaves them one line and no choices.
* command runner with no environment scrubbing, say, or an agent constructed with a credential
* read somewhere other than `readModelCredential`. Calling this leaves them one line and no
* choices.
*/
interface ImplementerOptions {
readonly logger: Logger;
Expand All @@ -28,18 +29,19 @@ interface ImplementerOptions {
readonly description: DescriptionPort;
/** Overridden only to spend less. The defaults are the conservative ones. */
readonly limits?: AgentLimits;
/** Read for the API key, and stripped of the worker's own secrets before the model sees it. */
/** Read for the model credential, and stripped of every secret before the model sees it. */
readonly env?: NodeJS.ProcessEnv;
readonly model?: string;
}

/**
* Everything `implementTicket` needs, built from the environment the pod was given.
*
* Throws `AgentConfigError` if there is no `ANTHROPIC_API_KEY`, which is why this belongs at
* boot and not inside a cycle: a worker with no credential cannot do the one thing it exists
* for, and finding that out mid-cycle means a ticket claimed and handed straight back. Failing
* at start-up makes it a pod that will not come up — the loudest thing a missing Secret can be.
* Throws `AgentConfigError` if the credential for the configured `MODEL_AUTH` mode is missing,
* which is why this belongs at boot and not inside a cycle: a worker with no credential cannot
* do the one thing it exists for, and finding that out mid-cycle means a ticket claimed and
* handed straight back. Failing at start-up makes it a pod that will not come up — the loudest
* thing a missing Secret, or an expired subscription token, can be.
*/
function createImplementer(options: ImplementerOptions): ImplementDeps {
const { logger, release, description, limits = DEFAULT_AGENT_LIMITS, env = process.env, model } = options;
Expand Down
Loading
Loading