docs(cloud-agent-next): plan to commit as user via GitHub App user-to-server tokens#3147
docs(cloud-agent-next): plan to commit as user via GitHub App user-to-server tokens#3147kilo-code-bot[bot] wants to merge 1 commit into
Conversation
| ```ts | ||
| export async function GET(request: Request) { | ||
| const { code, state, error, error_description } = parseQuery(request); | ||
| if (error) return errorRedirect(error_description ?? error); |
There was a problem hiding this comment.
WARNING: error_description is passed directly through to errorRedirect here, but §6.2 Notes say "don't leak GitHub's raw error string into the URL — map known errors to friendly codes." These two instructions are contradictory within the same pseudocode block.
Line 241 uses error_description ?? error as the redirect value, but the note below says to map to friendly codes. The implementation should only use error_description for internal logging and map to a controlled enum (exchange_failed, access_denied, etc.) before putting anything in the redirect URL.
| `${env.PUBLIC_BASE_URL}/api/integrations/github/user-connect/callback` | ||
| ); | ||
| // Optional: prompt=select_account so users with multiple GH accounts pick explicitly. | ||
| authorizeUrl.searchParams.set('prompt', 'select_account'); |
There was a problem hiding this comment.
WARNING: prompt=select_account is not a supported parameter for GitHub's OAuth authorize endpoint. GitHub's OAuth documentation does not recognise prompt; this parameter will be silently ignored. If multi-account selection is desired, this is not achievable via a query parameter on GitHub's standard OAuth flow — it would require the user to be logged out of GitHub first or to use a separate account-switcher flow. Remove this line or note that it has no effect.
| New route in `apps/web` (or a Worker, depending on where existing GitHub webhooks land — check `apps/web/src/app/api/integrations/github/webhook/route.ts` if it exists): | ||
|
|
||
| - Subscribe to `github_app_authorization` (action: `revoked`). | ||
| - Payload includes `sender.id` (GitHub user id). On `revoked`: `UPDATE user_github_app_tokens SET revoked_at = now(), revocation_reason = 'user_revoked' WHERE github_user_id = $sender_id`. |
There was a problem hiding this comment.
WARNING: The webhook revocation update uses WHERE github_user_id = $sender_id, but a single GitHub user ID can map to multiple rows if we ever support both standard and lite app types per user. This is correct in the current schema (the unique index is on (kilo_user_id, github_app_type)), but the WHERE clause is incomplete — it should also filter on the relevant github_app_type values, or explicitly accept that it updates all rows for that GitHub user (which is likely the correct intent). Also, note that the payload field to use is sender.node_id or sender.id (numeric); make sure the stored github_user_id format (numeric string from String(ghUser.id) in §6.2) matches the sender.id payload field format to avoid silent mismatches.
| refresh_token_expires_at: new Date(auth.refreshTokenExpiresAt), | ||
| }).onConflictDoUpdate({ | ||
| target: [userGitHubAppTokens.kilo_user_id, userGitHubAppTokens.github_app_type], | ||
| set: { /* all token + identity columns; reset revoked_at */ }, |
There was a problem hiding this comment.
WARNING: The set: { /* all token + identity columns; reset revoked_at */ } comment leaves a critical implementation detail unspecified. The onConflictDoUpdate set clause must explicitly include updated_at: new Date() — updated_at has .defaultNow() which only applies on INSERT, not on UPDATE. Drizzle does not auto-update it on conflict-update. If this is not spelled out, implementers may miss it, leaving updated_at stale after every reconnect.
|
|
||
| Behaviour: | ||
|
|
||
| 1. Look up `user_github_app_tokens` for `(kiloUserId, appType)`. If missing or `revoked_at IS NOT NULL` → `{ok: false, reason: 'no_user_token'}`. |
There was a problem hiding this comment.
SUGGESTION: The plan conflates two distinct {ok: false} states under a single reason: 'no_user_token': "row does not exist" and "row exists but is revoked". The UI copy in §8.1 maps these identically, but they have different UX implications — a user who was never connected should see "Connect", while a revoked user should see "Reconnect". Consider splitting into reason: 'not_connected' vs reason: 'revoked', especially since the reason: 'revoked' variant is already used separately at line 339/407.
|
|
||
| Two parallel `getUserTokenForRepo` calls for the same user can race on refresh and both spend the refresh token. **Handle this:** | ||
|
|
||
| - Use a Durable Object lock per `kiloUserId` (overkill) **or** a KV-based mutex with `cas` + a 5-minute backoff. Simplest: when refreshing, do a conditional update on the DB row using `WHERE access_token_expires_at = $oldExpiry`; if 0 rows updated, re-read the row (someone else refreshed) and use that token. |
There was a problem hiding this comment.
SUGGESTION: The optimistic-concurrency approach using WHERE access_token_expires_at = $oldExpiry is correct in principle, but relies on the DB driver returning affected-row counts accurately through Drizzle + Hyperdrive. Confirm that Drizzle's update().returning() or affected-rows result works as expected through the Hyperdrive connection pool, and note this in the implementation ticket. The plan says "if 0 rows updated, re-read the row" — make sure the re-read path also checks the new token is not itself expired (another race could have produced a token that is already near-expiry).
|
|
||
| This table holds PII (`github_login`, `github_email`, plus tokens that resolve to a person). **Required updates per `.kilo/rules/gdpr-pii.md`:** | ||
|
|
||
| - `softDeleteUser` (`apps/web/src/lib/user.ts`): hard-delete all `user_github_app_tokens` rows for the user, **and** call `DELETE /applications/{client_id}/grant` (Octokit `apps.deleteAuthorization`) to revoke the user's authorization on the GitHub side. If the GitHub call fails, log + continue — the local rows are gone, which is the GDPR-mandatory part. |
There was a problem hiding this comment.
WARNING: apps.deleteAuthorization requires the access token (not just the client_id) to identify which authorization to delete — specifically it uses Basic Auth with client_id:client_secret and the token in the request body. If the token has already been revoked (e.g., GitHub webhook arrived first and set revoked_at), the access token may be invalid. The plan should specify: (a) decrypt and pass the stored access token to deleteAuthorization; (b) handle 404/422 gracefully since the grant may already be gone; (c) ensure the decryption key is available in the apps/web context where softDeleteUser runs.
| In each existing App (`kiloconnect`, `kiloconnect-lite`, `kiloconnect-development`): | ||
|
|
||
| 1. **Add a new callback URL**: `https://<env>/api/integrations/github/user-connect/callback`. Keep the existing install callback URL. | ||
| 2. **Enable "Expire user authorization tokens"** (already the default for new apps; needs verification on the existing apps). Without this we lose refresh tokens, lose 8h rotation, and must store a non-expiring user token — not acceptable. |
There was a problem hiding this comment.
SUGGESTION: The plan says to enable "Expire user authorization tokens" but notes it "needs verification on the existing apps". If this setting is currently disabled on the live Apps, enabling it will immediately invalidate all existing user tokens that were issued without expiry (though since the feature is not yet built, there are no such tokens yet). However, this is worth calling out explicitly as a verification step before Phase 0, so it does not become a surprise during the App config change.
Code Review SummaryStatus: 8 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Other Observations (not in diff)The following design-level observations apply to the plan as a whole:
Files Reviewed (1 file)
Fix these issues in Kilo Cloud Reviewed by claude-sonnet-4.6 · 1,485,945 tokens |
Summary
Adds
.plans/cloud-agent-commit-as-user.md— a detailed implementation plan for making cloud-agent-next attribute commits and pushes to the Kilo user who triggered the session, instead of to thekiloconnect[bot]GitHub App identity.The plan picks "Option C" from the prior research: GitHub App user access tokens (user-to-server,
ghu_…). Same App we already have, an additional per-user OAuth step, no PATs, no new App registration. With aghu_token in the git remote URL plus the user's GitHub no-reply email as committer, GitHub attributes the commit and the push to the user (audit log:programmatic_access_type = "GitHub App user-to-server token"). Permissions are intersected with the user's repo access, so the user can never push to repos they don't already have write access to.The plan covers:
github_app_authorizationwebhook).user_github_app_tokenstable (encrypted access + refresh tokens, identity, revocation state) generated viapnpm drizzle generate.apps/webconnect flow:github.connectUserIdentitytRPC mutation, signedstate,/api/integrations/github/user-connect/callback, settings UI states (Connect / Connected / Reconnect).services/git-token-servicenewgetUserTokenForRepoRPC: lookup, refresh near expiry, repo-access check, race-safe DB update, fallback semantics.services/cloud-agent-nexttoken-selection branch insession-prepare, identity-awarecloneGitHubRepoauthor config, mid-session refresh inCloudAgentSessionthat can degrade gracefully to the App token if the user revokes mid-stream.softDeleteUserextension + test, signed-state CSRF defense, webhook signature verification.feature_flag_github_user_token_connect, dogfood criteria, instant flag-off rollback.No code is changed. This PR is plans-only —
.plans/is normally gitignored; the file was force-added for review.Verification
.plans/cloud-agent-commit-as-user.mdend-to-end.Visual Changes
N/A
Reviewer Notes
git pushand clone use the user token). Push back if you'd rather the user token cover more API surface in v1..plans/is gitignored; if we'd rather this plan live indocs/or another tracked path long-term, happy to move it before merge.