Skip to content

[N9b] OAuth sign-in for tools: ctx.getToken() pauses the run until sign-in - #332

Merged
LinuxDevil merged 2 commits into
mainfrom
lou-n9b-oauth-sign-in
Oct 2, 2026
Merged

LinuxDevil merged 2 commits into
mainfrom
lou-n9b-oauth-sign-in

Conversation

@LinuxDevil

Copy link
Copy Markdown
Owner

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 to GET /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-byte state, putPending for 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 in buildToolRunContext), ExecuteOptions.tokens (createAgent passes store.tokens; in-process sub-agents inherit it).
  • Pause: doExecuteToolCall turns SignInRequired into a signIn outcome (never a tool error, never recorded); AgentExecutor.pauseForSignIn pauses on the first such call of the batch, records calls that finished after it, and leaves calls that did not run as remainingToolCalls; pauseForApproval adds kind: 'sign-in' and the link. The verifier is only in the encrypted pending record.
  • Resume (resume.ts): an approved sign-in pause continues only if the run's user now has a token (else the record is put back and LOUSHO_SIGNIN_PENDING is thrown, the checkpoint untouched); a declined sign-in (complete({ error })) or approved: false gives the model kind: '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). approve callback skips sign-in pauses. Session binding kept on an early approval (agent and channels).
  • Routes: GET /oauth/callback (fetchRoutes: dev, node server, Worker, any prefix) and GET <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. createDeployedServer and lousho dev continue a paused channel turn after the callback.
  • Channels: approvalPrompt() sign-in text; Slack chat.postEphemeral to 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 chat and lousho acp handle kind: 'sign-in'.
  • Docs: new sections at the end of docs/oauth.md; one-sentence cross-links in docs/approvals.md and docs/streaming.md (no new headings); docs/stream-events.md table row; six error codes under ## OAuth in docs/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 with kind: 'sign-in', URL with state, code_challenge, code_challenge_method=S256, redirect_uri; complete() exchanges with the verifier whose S256 matches the challenge; resolve re-runs the tool, which gets the token; run finishes.
  • Over HTTP (src/oauth/signInRoutes.test.ts) through createRouteHandler (basePath /api/agent, auth list) and createDeployedServer (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 two approval.requested events (tool, sign-in).
  • Refresh without pause; failing refresh deletes and pauses.
  • requireAuth after a fake 401 deletes the token and pauses; succeeds after a new sign-in.
  • User provider without principal: LOUSHO_OAUTH_PRINCIPAL_REQUIRED, no pause. App provider without token: LOUSHO_OAUTH_APP_SIGNIN_REQUIRED, no approval event, no code_challenge in any event; after signInUrl() + 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.
  • Hygiene (src/oauth/signInHygiene.test.ts, SqliteStore file + OTel InMemorySpanExporter + recordReplay cassette 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 as token=[REDACTED].
  • Slack and Discord: ephemeral link, no buttons, continuation after the callback (in slackChannel.test.ts / discordChannel.test.ts).
  • Docs, error codes, CHANGELOG, snippets, llms, full verification list (below).

Live test

src/oauth/signIn.live.test.ts (replayed in CI by signIn.replay.test.ts): openai/gpt-4o-mini on OpenRouter, maxSteps: 3, recorded once into src/oauth/__cassettes__/sign-in.json (2 model calls, 181 tokens). Final text: "Your repositories are: 1. lousho-demo 2. agent-sdk". Cassette grepped for sk-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)

npx tsc --noEmit                                     exit 0
npm run lint                                         exit 0 (zero warnings)
npm run build                                        exit 0
npm run build --workspace=packages/create-lousho-agent  exit 0
npm run test:types                                   exit 0
npm run docs:verify-snippets -- --skip-build         exit 0: all 245 snippet(s) type-check against src/; 8 also run cleanly
npm run docs:llms:check                              exit 0
npm run test:coverage                                exit 0: Test Files 274 (1 skipped), Tests 3951 passed | 6 skipped (3957)
npm run fallow                                       exit 0: dead code: no issues; health: 0 above threshold, maintainability 89.5
npm run typecheck --workspace apps/agent-forge       exit 0
npm run typecheck:server --workspace apps/agent-forge  exit 0
(cd apps/agent-forge && npx vitest run)              exit 0: 14 files, 119 passed
npm run test:server --workspace apps/agent-forge     exit 0: 16 files, 133 passed
npm run pack-smoke                                   exit 0: 871 entries, 14.9 MB unpacked, 4.1 MB packed; ESM/CJS 17/17 entries, mock turn, bin, tsc bundler+node16

Peers (mirroring the peers job, then npm ci to 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 and npx vitest run all 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

  1. Provider lookup at the callback: a process-wide registry by 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.
  2. The callback is open, but when its request happens to pass the route's auth with a non-anonymous principal, a sign-in started for another user is refused (put back, 400). Without credentials, the protection is the single-use state plus showing the link only to the asker.
  3. getToken() without a token store stops the run (LOUSHO_OAUTH_STORE_MISSING propagates) instead of becoming a tool error the model retries.
  4. Several calls of one turn needing sign-in: pause on the first; the others run after the decision (from remainingToolCalls, through the normal gate again). A sub-agent suspension in the same batch is dropped like when a turn pauses on an approval.
  5. Teams and Telegram post the link as plain text (no message type visible to one user only); GitHub never posts it (public threads) and the pause must be completed from the app. Documented in docs/oauth.md.
  6. ToolExecutionContext.getToken/requireAuth are 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.
  7. The approvals route now starts the decided run before answering, to turn an immediate SignInPendingError into 409; other first-event errors still stream as before.
  8. pack-smoke packed cap 4 to 4.5 MiB (see pack-smoke: the tarball size cap is reached (main at 14.63 of 14.68 MB) #316).
  9. lousho chat / lousho acp run without a principal, so a user-owned provider fails there with LOUSHO_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 "call getToken before 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 of docs/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 of docs/oauth.md changed (no heading change). Arabic pages need these sections.

🤖 Generated with Claude Code

LinuxDevil and others added 2 commits October 3, 2026 01:28
…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>
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.

[N9b] OAuth sign-in for tools: ctx.getToken() pauses the run until sign-in

1 participant