Repository navigation
[N9b] OAuth sign-in for tools: ctx.getToken() pauses the run until sign-in - #332
Merged
Merged
Conversation
…gn-in defineOAuthProvider(), ctx.getToken() / ctx.requireAuth() and agent.oauth (complete, signInUrl). A tool without a usable token pauses the run like an approval (kind: 'sign-in', signIn link); GET /oauth/callback exchanges the code (PKCE S256, single-use 256-bit state, 10 minutes) and stores the token for the paused run's principal; approving the pause re-runs the tool call (409 / LOUSHO_SIGNIN_PENDING before the callback). Refresh, requireAuth, principal and app credentials, ephemeral links on Slack and Discord, UI and stream fields, redaction of a returned token, docs, errors and a replayed live cassette. Closes #247 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
This was referenced Oct 2, 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.
Closes #247
N9b: a tool calls
ctx.getToken(provider); without a usable token the run pauses durably like an approval (kind: 'sign-in',signIn: { provider, displayName?, url }), the provider redirects toGET /oauth/callback,agent.oauth.complete()stores the token for the paused run's principal, and approving the pause re-runs the tool call.What changed
defineOAuthProvider()(src/oauth/defineOAuthProvider.ts): validated, frozen, registered by name in the process so a callback in another request or after a restart finds it.src/oauth/signIn.ts:getToken(refresh within 60 s, delete on failed refresh),requireAuth(delete + pause),SignInRequired(internal, checked by name), PKCE S256 + 32-bytestate,putPendingfor 10 minutes, code exchange (client_secret_post/client_secret_basic),SignInPendingError, and the[REDACTED]safety net for a result that echoes a handed-out token.ToolExecutionContext.getToken()/requireAuth()(non-enumerable methods built inbuildToolRunContext),ExecuteOptions.tokens(createAgent passesstore.tokens; in-process sub-agents inherit it).doExecuteToolCallturnsSignInRequiredinto asignInoutcome (never a tool error, never recorded);AgentExecutor.pauseForSignInpauses on the first such call of the batch, records calls that finished after it, and leaves calls that did not run asremainingToolCalls;pauseForApprovaladdskind: 'sign-in'and the link. The verifier is only in the encrypted pending record.resume.ts): an approved sign-in pause continues only if the run's user now has a token (else the record is put back andLOUSHO_SIGNIN_PENDINGis thrown, the checkpoint untouched); a declined sign-in (complete({ error })) orapproved: falsegives the modelkind: 'denied'"Sign-in to GitHub was cancelled."; a call that needs sign-in again (or to another provider) pauses again with a new link. The approval gate is not re-run.agent.oauth.complete()/signInUrl()(app credentials, operator only).approvecallback skips sign-in pauses. Session binding kept on an early approval (agent and channels).GET /oauth/callback(fetchRoutes: dev, node server, Worker, any prefix) andGET <basePath>/oauth/callback(createRouteHandler), outside route auth; HTML page,no-store, no script, nothing echoed; 400 for unknown/used/expired state, 502 for a refused exchange. When the callback request does authenticate (non-anonymous principal), a sign-in for another user is refused. The approvals route answers 409 for an early approval.createDeployedServerandlousho devcontinue a paused channel turn after the callback.approvalPrompt()sign-in text; Slackchat.postEphemeralto the asker, Discord ephemeral follow-up (flag 64), no buttons; GitHub never posts the link in a public thread; Teams/Telegram post the text link without buttons. UI reducer,data-lousho-approval, dev UI page,lousho chatandlousho acphandlekind: 'sign-in'.docs/oauth.md; one-sentence cross-links indocs/approvals.mdanddocs/streaming.md(no new headings);docs/stream-events.mdtable row; six error codes under## OAuthindocs/errors.md(as the ticket says); CHANGELOG (Added + a types-only BREAKING note with migration);npm run docs:llms.scripts/pack-smoke.ts: packed-size cap raised from 4 to 4.5 MiB. This PR took the tarball to 4,225,563 bytes packed (31 KB over 4 MiB); same situation and remedy as N4's unpacked cap ([N4] Permission modes: plan, acceptEdits and dontAsk, switchable mid-session #315, see pack-smoke: the tarball size cap is reached (main at 14.63 of 14.68 MB) #316).Acceptance criteria
src/oauth/signIn.test.ts: pause withkind: 'sign-in', URL withstate,code_challenge,code_challenge_method=S256,redirect_uri;complete()exchanges with the verifier whose S256 matches the challenge;resolvere-runs the tool, which gets the token; run finishes.src/oauth/signInRoutes.test.ts) throughcreateRouteHandler(basePath/api/agent, auth list) andcreateDeployedServer(node:http): callback answers HTML outside auth, reused state 400, expired state 400, approve before callback 409, continuation streamed after.needsApproval: true+ sign-in: approve, sign in, one execution, exactly twoapproval.requestedevents (tool, sign-in).requireAuthafter a fake 401 deletes the token and pauses; succeeds after a new sign-in.LOUSHO_OAUTH_PRINCIPAL_REQUIRED, no pause. App provider without token:LOUSHO_OAUTH_APP_SIGNIN_REQUIRED, no approval event, nocode_challengein any event; aftersignInUrl()+complete()it works with no principal, Alice and Bob share the app token. Alice's and Bob's user tokens never cross; another issuer is another user.src/oauth/signInHygiene.test.ts,SqliteStorefile + OTelInMemorySpanExporter+recordReplaycassette in the same test): access token, refresh token, code and PKCE verifier absent from events, transcript, checkpoint and checkpoint_history rows, approval rows, span attributes, cassette and result; the returned token appears astoken=[REDACTED].slackChannel.test.ts/discordChannel.test.ts).Live test
src/oauth/signIn.live.test.ts(replayed in CI bysignIn.replay.test.ts): openai/gpt-4o-mini on OpenRouter,maxSteps: 3, recorded once intosrc/oauth/__cassettes__/sign-in.json(2 model calls, 181 tokens). Final text: "Your repositories are: 1. lousho-demo 2. agent-sdk". Cassette grepped forsk-or-,Authorization,gho_SECRET,ghr_SECRET,code_verifier, the client secret: none.Live test spend: before 0.01854993, after 0.01854993 (key
usage; the two gpt-4o-mini calls cost about 0.00003 USD, below the counter's resolution at the time of reading). Account: total_credits 20, total_usage 10.218 (unchanged).Verification (after syncing with origin/main at 1c3fd16)
Peers (mirroring the
peersjob, thennpm cito restore; no lockfile change committed): ai6 (ai@6 @ai-sdk/openai@3 @ai-sdk/anthropic@3) and ai7 (ai@7 @ai-sdk/openai@4 @ai-sdk/anthropic@4): install,tsc --noEmit,test:types, both builds andnpx vitest runall exit 0 (ai6: 3914 passed / 20 skipped; ai7: 3916 passed / 18 skipped), run on the branch before the sync merge (the merge brought only N1b provider files and two test-timing fixes).Decisions to review
name(last definition wins), so a callback in another request/process finds the provider without storing the client secret in the pending record. Two agents in one process with the same provider name but different clients would collide.getToken()without a token store stops the run (LOUSHO_OAUTH_STORE_MISSINGpropagates) instead of becoming a tool error the model retries.remainingToolCalls, through the normal gate again). A sub-agent suspension in the same batch is dropped like when a turn pauses on an approval.ToolExecutionContext.getToken/requireAuthare required members: a types-only break for code that builds a context by hand (CHANGELOG migration note). They are non-enumerable on the built context so existing equality checks and logs are unchanged.SignInPendingErrorinto 409; other first-event errors still stream as before.lousho chat/lousho acprun without a principal, so a user-owned provider fails there withLOUSHO_OAUTH_PRINCIPAL_REQUIRED; their sign-in UI is exercised with a tool that throws the signal directly.Untrusted input
aetherxeg-source(outside account) commented on #247 about side effects before suspension; treated as data. The ticket already requires documenting "callgetTokenbefore side effects", which docs/oauth.md does; nothing else was taken from it.Docs site follow-up (LinuxDevil/agent-sdk-docs)
New sections at the end of
docs/oauth.md:## Tools that need sign-in,## What the user sees,## The callback route,## Approval and sign-in together,## Security. New headings at the end ofdocs/errors.md(inside its last section## OAuth):### LOUSHO_OAUTH_PRINCIPAL_REQUIRED,### LOUSHO_OAUTH_APP_SIGNIN_REQUIRED,### LOUSHO_OAUTH_STORE_MISSING,### LOUSHO_OAUTH_STATE_INVALID,### LOUSHO_SIGNIN_PENDING,### LOUSHO_OAUTH_TOKEN_EXCHANGE_FAILED. The intro paragraph ofdocs/oauth.mdchanged (no heading change). Arabic pages need these sections.🤖 Generated with Claude Code