Fix Claude subscription auth for orchestrated bots - #2899
Merged
Conversation
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
|
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
This was referenced Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ValidateSubscriptiongave expired OAuth credentials unconditional benefit ofthe doubt — "Claude Code refreshes those in-container" — and returned
ProbeInconclusive.revalidateClaudeSubscriptionleavesStatusuntouched onInconclusive, and the status endpoint computes
Valid = Status == "active".So a subscription whose refresh token was dead kept reporting
connected: true, valid: true, status: activeindefinitely while every desktopfailed to authenticate. Observed live: a token that expired 2026-07-09 still
returning
valid: trueon 2026-07-29.A healthy in-container refresh pushes new credentials back
(
PUT /sessions/{id}/claude-credentials), which movesExpiresAtforward. Pasta 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 botunder 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 ontoSessionMetadataand honouredby 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 itPUT /claude-subscriptions/{id}/delegation— owner-only, and restricted toorgs the owner actually belongs to
than failing, so withdrawing a grant degrades instead of bricking an agent
Known gap: scheduled runs are not routed
CredentialOwnerIDonly reaches a task that comes through the orchestrator'sdispatch 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_keyagents were being handed subscription credentialsThe gate that decides whether to sync Claude/Codex credentials into a session's
container checked the runtime (
claude_code/codex_cli) and never theagent's credential type. An agent configured for
api_keyauth — meant to bebilled per token through the Helix proxy — therefore still got the owner's
subscription credentials written into
~/.claude/.credentials.json. Claude Codeprefers 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 thefailure was an expired OAuth token.
CodeAgentConfig.UsesSubscriptionnow carries the assistant's credential typeexplicitly (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_keythat has beenquietly 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 thatposition, 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
subscriptionif 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
!hasSubscriptionand the owner dropdown behind a second gate — so themoment 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.
(matching the server's
authorizeOrgOwner), and states plainly what an orgsubscription is: the shared fallback for members who have not connected one
subscription deletes the old one (existing server behaviour, previously silent)
org subscription no longer silently creates a personal one
Testing
go build ./...clean;go test ./pkg/anthropic/...passesValidateSubscriptioncases (long-expired → invalid, recently expired →inconclusive, no-refresh-token → probed, live → valid, rejected setup token →
invalid)
TestSubscriptionDelegatedTopins the consent gate, including that thedefault (no grant) never delegates
TestBuildCodeAgentConfigFromAssistantextended to pinUsesSubscriptiononevery subscription-mode case; passes (
CGO_ENABLED=1 go test -run TestBuildCodeAgentConfig ./pkg/server/)mockgen), +15 lines, no unrelated churnNOT 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_keyagent now actually routesthrough 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
tscreportsone 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_openapineeds thestack), so the new endpoint is called via
useApi— matching every other callin that component — rather than the generated client.
🔗 Open in Helix
📋 Spec:
🚀 Built with Helix