Skip to content

Fix Claude subscription auth for orchestrated bots - #2899

Merged
lukemarsden merged 7 commits into
mainfrom
feature/002391-fix-critical-helixos
Jul 30, 2026
Merged

Fix Claude subscription auth for orchestrated bots#2899
lukemarsden merged 7 commits into
mainfrom
feature/002391-fix-critical-helixos

Conversation

@lukemarsden

Copy link
Copy Markdown
Collaborator

Branch: feature/002391-fix-critical-helixos (helixml/helix)

Two independent defects, both found while debugging why every HelixOS sales bot
failed to authenticate with Failed to authenticate API … 401 OAuth access token has expired … Re-authenticate to continue.

1. A long-dead subscription reported itself healthy

ValidateSubscription gave expired OAuth credentials unconditional benefit of
the doubt — "Claude Code refreshes those in-container" — and returned
ProbeInconclusive. revalidateClaudeSubscription leaves Status untouched on
Inconclusive, and the status endpoint computes Valid = Status == "active".

So a subscription whose refresh token was dead kept reporting
connected: true, valid: true, status: active indefinitely while every desktop
failed to authenticate. Observed live: a token that expired 2026-07-09 still
returning valid: true on 2026-07-29.

A healthy in-container refresh pushes new credentials back
(PUT /sessions/{id}/claude-credentials), which moves ExpiresAt forward. Past
a one-hour grace nothing is refreshing it, so it is now reported invalid with a
legible reason rather than a false green.

2. Every orchestrated bot authenticated as the dispatching service account

Credentials resolved as specTask.CreatedBy → session.Owner → GetEffectiveClaudeSubscription. An orchestrator (HelixOS) dispatches every bot
under one service API key, so that account's subscription authenticated
everyone's bots: one expired token broke the entire fleet, and no one could run
their bot on their own Claude account.

Adds SpecTask.CredentialOwnerID, mirrored onto SessionMetadata and honoured
by a new GetSessionClaudeSubscription, used at every credential call site —
including the container's fetch and the refresh write-back, so a refreshed
token lands on the subscription it came from rather than the session owner's.

This changes credential resolution only. Sessions remain owned by and
attributed to CreatedBy; nothing "runs as" the credential owner.

Consent gate

Honouring an arbitrary credential owner would let any member who can create a
task spend another user's Claude quota. It is therefore gated on an explicit
grant from the subscription owner:

  • ClaudeSubscription.DelegatedOrgIDs — orgs allowed to use it
  • PUT /claude-subscriptions/{id}/delegation — owner-only, and restricted to
    orgs the owner actually belongs to
  • Account-settings toggle so the grant is reachable at all
  • Ungranted / missing / revoked owners fall back to today's resolution rather
    than failing, so withdrawing a grant degrades instead of bricking an agent

Known gap: scheduled runs are not routed

CredentialOwnerID only reaches a task that comes through the orchestrator's
dispatch call. HelixOS scheduled bot runs are Helix cron triggers: they fire
inside Helix and create the task directly, so the field is never set and those
runs still resolve credentials the old way — the dispatching service account's
subscription. Per-owner routing therefore applies to manual "Run now" only.

This is a genuine hole, not a rounding error: it is the majority of runs. It
degrades safely (scheduled runs behave exactly as they do today), and it is
deferred rather than fixed here because it needs the trigger→task path to carry
an owner, which is a larger change than this PR. The archived spec 002332 hit the
same wall and deferred it too.

3. api_key agents were being handed subscription credentials

The gate that decides whether to sync Claude/Codex credentials into a session's
container checked the runtime (claude_code / codex_cli) and never the
agent's credential type. An agent configured for api_key auth — meant to be
billed per token through the Helix proxy — therefore still got the owner's
subscription credentials written into ~/.claude/.credentials.json. Claude Code
prefers those over the proxy, so the agent authenticated straight upstream and
bypassed Helix billing, and a dead subscription broke an agent that was never
supposed to depend on one.

That is the mechanism behind the confusing symptom that started this task: the
HelixOS agent app is configured code_agent_credential_type = api_key, yet the
failure was an expired OAuth token.

CodeAgentConfig.UsesSubscription now carries the assistant's credential type
explicitly (rather than the previous implicit "subscription mode means empty
BaseURL" convention) and both injection paths are gated on it.

Scope note: the Codex branch had the identical defect three lines away and is
fixed in the same commit. Fixing only the Claude half would have knowingly left
the same billing bypass in place for Codex.

Billing impact, deliberate: any agent configured for api_key that has been
quietly running on injected subscription credentials will move onto the metered
proxy when this deploys. Prod's "HelixOS Agent" app
(app_01kvg10f2axmwb4ehb1pg2m979, api_key + claude_code) is in exactly that
position, so its bot runs will start billing per token instead of drawing on the
dispatching account's Claude Max quota. That is the correct behaviour for how the
app is configured; switch the app to subscription if the quota was intended.

4. Org-level Claude subscriptions were unreachable in the UI

The API has supported org-owned subscriptions (and falls back to one when a user
has none) for some time, but the account panel hid the entire Connect block
behind !hasSubscription and the owner dropdown behind a second gate — so the
moment you had any subscription there was no route to creating an org-level one.
In practice nobody could set up the fallback that Bug 2 depends on.

  • "Add subscription" is now always offered when there is another owner to add for
  • The owner picker moved into the connect dialog, restricted to orgs you own
    (matching the server's authorizeOrgOwner), and states plainly what an org
    subscription is: the shared fallback for members who have not connected one
  • Replacing is warned about — connecting a token for an owner that already has a
    subscription deletes the old one (existing server behaviour, previously silent)
  • "Update Token" pins the dialog to that subscription's owner, so re-authing an
    org subscription no longer silently creates a personal one
  • Each subscription is labelled with its owner (Personal / Shared: <org>)

Testing

  • go build ./... clean; go test ./pkg/anthropic/... passes
  • New: 5 ValidateSubscription cases (long-expired → invalid, recently expired →
    inconclusive, no-refresh-token → probed, live → valid, rejected setup token →
    invalid)
  • New: TestSubscriptionDelegatedTo pins the consent gate, including that the
    default (no grant) never delegates
  • TestBuildCodeAgentConfigFromAssistant extended to pin UsesSubscription on
    every subscription-mode case; passes (CGO_ENABLED=1 go test -run TestBuildCodeAgentConfig ./pkg/server/)
  • Store mocks regenerated (mockgen), +15 lines, no unrelated churn

NOT tested end to end. No Helix dev stack was available in this session, so
the credential-resolution path was not exercised against a live Zed desktop.
Specifically not verified: that an api_key agent now actually routes
through the proxy (only the config-building unit test is), and that a
subscription-mode agent still receives its credentials. Neither the delegation UI
nor the new org-subscription UX was rendered in a browser. Frontend tsc reports
one pre-existing error in a file this PR does not touch
(sampleProjectIcons.tsx), an artifact of installing with npm rather than yarn;
no errors in the changed component.

The OpenAPI client was not regenerated (./stack update_openapi needs the
stack), so the new endpoint is called via useApi — matching every other call
in that component — rather than the generated client.


🔗 Open in Helix

📋 Spec:

🚀 Built with Helix

lukemarsden and others added 6 commits July 29, 2026 17:52
ValidateSubscription gave expired OAuth credentials unconditional benefit of the
doubt ("Claude Code refreshes those in-container"), returning Inconclusive.
revalidateClaudeSubscription leaves Status untouched on Inconclusive, so a
subscription whose refresh token was dead kept reporting status=active and
valid=true indefinitely while every desktop failed with "OAuth session expired
and could not be refreshed".

A healthy in-container refresh pushes new credentials back, moving ExpiresAt
forward. Past a one-hour grace nothing is refreshing it, so report it invalid
with a legible reason instead of a false green.

Spec-Ref: helix-specs@5501074:002391_fix-these-critical-bugs
Bot desktops authenticated with whichever subscription belonged to the spec
task's creator (specTask.CreatedBy -> session.Owner -> subscription lookup). An
orchestrator like HelixOS dispatches every bot under one service API key, so
that service account's Claude subscription authenticated everyone's bots: one
expired token broke every bot, and no one could use their own Claude account.

Add SpecTask.CredentialOwnerID, mirrored onto SessionMetadata and honoured by a
new GetSessionClaudeSubscription used at every credential call site — including
the container's fetch and refresh write-back, so a refreshed token lands on the
subscription it came from.

This changes credential resolution ONLY. Sessions are still owned by and
attributed to CreatedBy; nothing runs as the credential owner.

Honouring an arbitrary credential owner would let any member spend another
user's Claude quota, so it is gated on an explicit grant: the subscription owner
lists the orgs allowed to use it (PUT /claude-subscriptions/{id}/delegation,
owner-only, restricted to orgs they belong to). Ungranted or missing owners fall
back to today's resolution rather than failing.

Spec-Ref: helix-specs@9981f1e:002391_fix-these-critical-bugs
Adds the consent control for delegated agent credentials: the subscription
owner picks which of their organizations may authenticate orchestrated agents
as them. Without this the backend grant is unreachable, so the delegation can
never be turned on.

Owner-only and off by default — an ungranted subscription is still used solely
for sessions its owner owns.

Spec-Ref: helix-specs@9981f1e:002391_fix-these-critical-bugs
The claudeSubAvailable/codexSubAvailable gates checked the runtime
(claude_code / codex_cli) but never the agent's credential type, so an
agent configured for api_key auth still had the owner's Claude/Codex
subscription credentials synced into its container. The CLI prefers
those credentials over the proxy, so an api_key agent silently
authenticated upstream and bypassed Helix billing entirely - and a dead
subscription broke it even though it was never supposed to use one.

Add CodeAgentConfig.UsesSubscription, set from the assistant's
CodeAgentCredentialType at the single construction site, and gate both
injection paths on it rather than inferring subscription mode from the
empty-BaseURL convention.

Spec-Ref: helix-specs@5382bc2:002391_fix-these-critical-bugs
…itical-helixos

Spec-Ref: helix-specs@5382bc2:002391_fix-these-critical-bugs
The account panel hid the whole Connect block behind `!hasSubscription`,
and the owner dropdown behind a second gate, so the moment you had any
subscription there was no route to creating an org-level one - the org
fallback existed in the API and was unreachable in the UI.

- Always offer "Add subscription" when there is another owner to add for
- Move the owner picker into the connect dialog, restricted to orgs you
  own (matching the server's authorizeOrgOwner check), and spell out what
  an org subscription means: the shared fallback for members with none
- Warn before replacing: connecting a token for an owner that already has
  a subscription deletes the old one
- "Update Token" now pins the dialog to that subscription's owner, so
  re-authenticating an org subscription no longer silently creates a
  personal one
- Label each subscription with its owner (Personal / Shared: <org>)

Spec-Ref: helix-specs@5382bc2:002391_fix-these-critical-bugs
@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

maybeReclassifySubscriptionAuthError now resolves credentials via
GetSessionClaudeSubscription so a session carrying a CredentialOwnerID
authenticates as that person. The suite still mocked the call it
replaced, so gomock aborted on an unexpected call plus a missing one —
the only red step in build 3156.

Expect the whole session rather than the owner/org pair, so the test
pins that the session (and the delegated owner on it) is what reaches
the store.

Spec-Ref: helix-specs@5d2aa66:002391_fix-these-critical-bugs
@lukemarsden
lukemarsden merged commit e9ba52d into main Jul 30, 2026
4 of 5 checks passed
@lukemarsden
lukemarsden deleted the feature/002391-fix-critical-helixos branch July 30, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants