Skip to content

Key Remote Control credential by GitHub identity (shared per-user across triage + chat)#144

Merged
imaustink merged 3 commits into
mainfrom
fix/rc-credential-github-identity
Jul 24, 2026
Merged

Key Remote Control credential by GitHub identity (shared per-user across triage + chat)#144
imaustink merged 3 commits into
mainfrom
fix/rc-credential-github-identity

Conversation

@imaustink

Copy link
Copy Markdown
Owner

Why

Testing surfaced that after linking Claude in the triage flow, the chat flow re-prompted for auth despite the same user having already linked. Root cause: the claude-remote (Remote Control login) credential was keyed by the raw per-caller identity.subject, and the two flows resolve different subjects for the same human:

  • Chat (Open WebUI): openwebui:<id> — from the per-user signed JWT.
  • Triage (GitHub label → gateway): a single shared service-token subject (the gateway relays with its own bearer; the triggering GitHub user is only used for a permission gate, never forwarded).

So a credential linked in one flow was invisible to the other.

Fix

Key claude-remote under the one identity both flows can resolve for the same human — their GitHub login (github:<login>), read from the caller's already-linked GitHub credential (the RC session already acts as that GitHub user). A new linkSubjectFor helper applies this to every claude-remote gateway call — getToken / start / poll / waitForCompletion / invalidate, plus the reported pending-link subject — across the delegate gate, the pending-link resume, turn-failure invalidation, and the agent-backed-tool path.

Strictly per-user — distinct GitHub users → distinct keys, never one shared account. (This is deliberately not the earlier global-subject idea, which was a misread of "shared" and never reached main.) When no GitHub login can be resolved (no GitHub link yet, or no GitHub gateway), it falls back to the raw subject: still per-user, just not yet cross-flow — and the RC agent needs GitHub for repo writes anyway, so the link is normally already present.

Also re-lands two stranded fixes

The PR #141 merge only took its first commit; these two never reached main:

  • Chat Remote Control link renders as its own paragraph (leading blank line), instead of butting against the previous chunk.
  • A turn that made no commits (e.g. a chat request that only filed an issue) no longer gets a false "no open pull request was found" warning — measured via commits past origin/HEAD when the repo was cloned during the turn.

Testing

  • agent-orchestrator: 340 tests pass, typecheck clean. Added a test asserting claude-remote keys under github:<login> while claude (setup-token) stays per-caller.
  • claude-code-swe-agent: 23 tests pass, typecheck clean (includes the re-landed real-git warning tests).

Rollout note

Any credential already linked under an old per-flow subject won't be found under the new github:<login> key, so the first request after deploy re-links once, then it's shared from both flows permanently.

🤖 Generated with Claude Code

https://claude.ai/code/session_017LbwJwiShKRbRVQYinnMaJ

imaustink and others added 3 commits July 24, 2026 11:21
The `remote-control-url` content chunk only had a trailing blank line, so
when an earlier streamed chunk (e.g. agent-text) didn't end in a newline the
link ran together with it into one mangled line in the chat client. Add a
leading blank line too so the link always stands alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LbwJwiShKRbRVQYinnMaJ
A first, marker-less turn (e.g. a chat request to file an issue) has no repo
before it runs -- the agent clones during the turn -- so `priorHeadSha` is
null. The old `madeNewCommits = headSha !== null && headSha !== priorHeadSha`
then read as true purely because a repo now existed with a non-null HEAD, even
though the agent only READ the repo (cloned it, opened a GitHub issue via `gh`,
committed nothing). With no PR on that branch, this posted a spurious
"⚠️ Work is on <repo> branch <branch>, but no open pull request was found."
warning on top of a task that actually succeeded.

Distinguish the two cases: when a prior HEAD was captured (the repo existed
before the turn -- a continuation, including review-only turns), keep diffing
against it. When it was not (repo cloned during the turn), a non-null HEAD is
just the clone's base commit; instead ask whether HEAD moved past the clone's
default-branch tip (`origin/HEAD..HEAD`) via the new
`countCommitsAheadOfOriginHead`. A read-only turn leaves HEAD exactly at
origin/HEAD (count 0 -> no warning); a turn that genuinely committed and
opened a PR still shows the ✅ line, and one that committed without a PR still
warns.

Adds real-git integration tests for the new helper (clone-and-read -> 0,
commits added -> counted, non-repo path -> 0 rather than throwing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LbwJwiShKRbRVQYinnMaJ
… across flows

The `claude-remote` (Remote Control login) credential was keyed by the raw
per-caller `identity.subject`. But the same human reaches it through two entry
points that resolve DIFFERENT subjects: Open WebUI chat (`openwebui:<id>`, from
the per-user JWT) vs the GitHub-triage relay (a shared service-token subject).
So a credential linked in one flow was invisible to the other, and the user was
re-prompted to `claude login` when switching between triage and chat.

Key it instead by the ONE identity both flows can resolve for the same human:
their GitHub login (`github:<login>`), read from the caller's already-linked
GitHub credential (the RC session already acts as that GitHub user). A new
`linkSubjectFor` helper applies this to every `claude-remote` gateway call
(getToken/start/poll/waitForCompletion/invalidate, plus the reported
pending-link subject) across the delegate gate, the pending-link resume, the
turn-failure invalidation, and the agent-backed-tool path.

This stays strictly per-user -- distinct GitHub users get distinct keys, never
a single shared account (NOT the earlier global-subject approach, which was the
wrong reading of "shared"). It only recognises the same user across flows. When
no GitHub login can be resolved (no GitHub link yet, or no GitHub gateway), it
falls back to the raw subject: still per-user, just not yet cross-flow -- and
the RC agent requires GitHub for repo writes anyway, so the link is normally
already present.

Also re-lands two fixes that were stranded on the PR #141 branch and never
reached main (the merge only took the first commit): the chat Remote Control
link now renders as its own paragraph, and a turn that made no commits (e.g. a
chat request that only filed an issue) no longer gets a false "no open pull
request was found" warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LbwJwiShKRbRVQYinnMaJ
@imaustink
imaustink merged commit cc3852a into main Jul 24, 2026
@imaustink
imaustink deleted the fix/rc-credential-github-identity branch July 24, 2026 18:27
imaustink added a commit that referenced this pull request Jul 25, 2026
Principals: Identity gains a `principal` -- a stable id for the HUMAN,
independent of entry point -- resolved ONCE in resolveIdentity and read
everywhere downstream. Entry-point subjects become aliases; durable
per-user state (the Claude credentials) keys on the principal, so one
authorization covers GitHub triage and Open WebUI chat. This retires
resolveCredentialSubject, whose per-provider re-derivation was the exact
shape of the PR #144 bug: many call sites each deciding a key.

Deliberately does NOT rewrite identity.subject itself. Doing so would
move session keys, RBAC scoping and credential keys simultaneously;
feeding the verified login into principal resolution instead lets
sessions and RBAC stay on the subject they have always used while only
credentials converge.

Signed sender assertion: the gateway authenticates to /invoke with its
own service token, which says the gateway is calling and nothing about
the human. The sender login travelled as an unsigned body field -- yet
it selects the caller's principal and therefore which stored credentials
a run receives, so anything holding that token could name an arbitrary
login and be handed that person's credentials. It is now an HMAC-SHA256
assertion the orchestrator verifies; with the secret configured the body
field is ignored outright.

Compact HMAC rather than JWT: both ends are in this repo, the only
claims are a login and an expiry, and node:crypto avoids adding a JWT
library to the gateway to agree with the orchestrator's. Without the
secret both processes warn loudly and fall back to the old behaviour, so
upgrading does not silently break a deployment nor silently stay weak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWBX7XTfAY66wFk73q8kX9
imaustink added a commit that referenced this pull request Jul 25, 2026
The property §1 asks for already held: authorization ran as graph control
flow on the only path to a launch, never as something the planner could
select. What it lacked was a boundary -- "where is authorization decided" was
answered by reading ~300 lines inside delegateToAgent. graph.ts loses 345
lines; the full pre-existing orchestrator suite passes unchanged.

Two things fell out that the "purely cosmetic refactor" framing missed.

The verdict is now a total discriminated union -- authorized |
link-required | misconfigured -- so a fourth outcome breaks compilation at
the branch rather than falling through to "launch anyway". That is the
failure direction that matters for a gate.

And the agent-backed-tool path had its OWN hand-copied provider loop with
the same principal-vs-subject keying rules. Two copies of credential keying
is precisely the shape of the #144 bug. It now calls the same owner through
a deliberately separate read-only entry point, resolveLinkedCredentials:
that path must never START a link, because a paused *tool* call has no
resume slot, unlike a paused agent delegation. A boolean flag on authorize()
was the alternative and rejected -- it would make "does calling this begin an
OAuth flow?" depend on an argument, which is the ambiguity §1 exists to
remove.

23 unit tests cover what the extraction made directly reachable, including
verdicts previously observable only through a launched AgentRun: the batch
prompt for two missing links, a failed start reported ALONGSIDE a successful
one, the write-back grant keyed to the same subject the credential was read
from, and the degradation to pending when the long-held wait throws.

Also drops the three [identity-gate-debug] console.logs, which were marked
"remove once root-caused" -- this PR root-causes the 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWBX7XTfAY66wFk73q8kX9
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