Skip to content

docs(cloud-agent-next): plan to commit as user via GitHub App user-to-server tokens#3147

Open
kilo-code-bot[bot] wants to merge 1 commit into
mainfrom
plan/cloud-agent-commit-as-user
Open

docs(cloud-agent-next): plan to commit as user via GitHub App user-to-server tokens#3147
kilo-code-bot[bot] wants to merge 1 commit into
mainfrom
plan/cloud-agent-commit-as-user

Conversation

@kilo-code-bot
Copy link
Copy Markdown
Contributor

@kilo-code-bot kilo-code-bot Bot commented May 9, 2026

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 the kiloconnect[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 a ghu_ 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 config changes per env (callback URL, expiring user tokens, github_app_authorization webhook).
  • New user_github_app_tokens table (encrypted access + refresh tokens, identity, revocation state) generated via pnpm drizzle generate.
  • apps/web connect flow: github.connectUserIdentity tRPC mutation, signed state, /api/integrations/github/user-connect/callback, settings UI states (Connect / Connected / Reconnect).
  • services/git-token-service new getUserTokenForRepo RPC: lookup, refresh near expiry, repo-access check, race-safe DB update, fallback semantics.
  • services/cloud-agent-next token-selection branch in session-prepare, identity-aware cloneGitHubRepo author config, mid-session refresh in CloudAgentSession that can degrade gracefully to the App token if the user revokes mid-stream.
  • Edge cases: SAML SSO, lost repo access mid-session, lite app, two parallel callbacks, refresh-token expiry.
  • Security & GDPR: dedicated encryption key, softDeleteUser extension + test, signed-state CSRF defense, webhook signature verification.
  • Phased rollout behind feature_flag_github_user_token_connect, dogfood criteria, instant flag-off rollback.
  • Effort estimate (~1.5 weeks for one engineer) and open questions for review.

No code is changed. This PR is plans-only — .plans/ is normally gitignored; the file was force-added for review.

Verification

  • Read through .plans/cloud-agent-commit-as-user.md end-to-end.
  • Cross-checked code references in the plan against the actual files (line numbers as of this branch's base).

Visual Changes

N/A

Reviewer Notes

  • The doc is opinionated about scope (e.g. v1 keeps PR/issue/comment ops on the App token; only git push and clone use the user token). Push back if you'd rather the user token cover more API surface in v1.
  • Open questions are listed in §14 of the plan; the SAML SSO polish question and the "auto-banner vs. settings-only CTA" question are the most user-visible.
  • The plan calls out one subtle behavioural choice: if a user revokes the App mid-session, we silently fall back to the installation token + bot author rather than killing the session. That trades attribution for reliability — flag if you'd prefer the opposite.
  • .plans/ is gitignored; if we'd rather this plan live in docs/ or another tracked path long-term, happy to move it before merge.

```ts
export async function GET(request: Request) {
const { code, state, error, error_description } = parseQuery(request);
if (error) return errorRedirect(error_description ?? error);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 */ },
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'}`.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kilo-code-bot
Copy link
Copy Markdown
Contributor Author

kilo-code-bot Bot commented May 9, 2026

Code Review Summary

Status: 8 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 5
SUGGESTION 3
Issue Details (click to expand)

WARNING

File Line Issue
.plans/cloud-agent-commit-as-user.md 227 prompt=select_account is not a valid GitHub OAuth parameter — silently ignored
.plans/cloud-agent-commit-as-user.md 241 error_description forwarded directly to redirect URL, contradicting the "map to friendly codes" note below
.plans/cloud-agent-commit-as-user.md 278 onConflictDoUpdate set clause leaves updated_at unspecified — Drizzle won't auto-update it
.plans/cloud-agent-commit-as-user.md 363 Webhook revocation WHERE clause may need explicit github_app_type scope; also verify sender.id format matches stored github_user_id
.plans/cloud-agent-commit-as-user.md 189 apps.deleteAuthorization requires the access token, not just client_id; plan needs to specify decryption + 404/422 error handling

SUGGESTION

File Line Issue
.plans/cloud-agent-commit-as-user.md 107 Call out explicitly that "Expire user authorization tokens" must be verified before Phase 0
.plans/cloud-agent-commit-as-user.md 335 no_user_token conflates "never connected" and "revoked" — consider splitting for better UX copy fidelity
.plans/cloud-agent-commit-as-user.md 351 Optimistic-concurrency re-read path should validate the freshly-read token is also not near-expiry
Other Observations (not in diff)

The following design-level observations apply to the plan as a whole:

  1. git-token-service has no DB write access today — the current CloudflareEnv for git-token-service only has TOKEN_CACHE (KV), HYPERDRIVE, and the App private keys. The plan adds token refresh writes to the DB from this service, which requires adding a Hyperdrive/DB binding and GITHUB_APP_CLIENT_ID/_CLIENT_SECRET env vars. The plan mentions the env vars (§4.2) but does not call out that a new Hyperdrive binding must also be added to wrangler.jsonc for git-token-service.

  2. github_app_authorization webhook goes to a new route, but the existing webhook handler is at /api/webhooks/github/route.ts — the plan says to check apps/web/src/app/api/integrations/github/webhook/route.ts (§7.4), but this path does not exist. The real handler is at /api/webhooks/github/. The new github_app_authorization event should be wired into the existing webhook handler (with handleGitHubWebhook) rather than a new route, to share signature verification code.

  3. return_to open-redirect risk — the plan references safeReturnTo(return_to) without specifying what validation it performs. Since return_to is embedded in the signed state (so cannot be forged), the main risk is an attacker who legitimately initiates a flow with a malicious return_to. The plan should specify that safeReturnTo only accepts relative paths (starts with /) to prevent redirecting to external domains.

  4. revocation_reason is an untyped text column — the plan documents three valid values ('user_revoked', 'refresh_failed', 'admin') in a comment, but uses a plain text column. A pgEnum would be safer and consistent with github_app_type already using githubAppTypeEnum. This is a minor schema nit but worth considering before the migration is generated.

  5. Mid-session fallback silently re-attributes commits — §8.3 says to fall back to the installation token mid-session if the user revokes, and surface "a one-time warning". However, §9 (edge case table) for "User loses repo access mid-session" says explicitly not to fall back because "that'd silently re-attribute the commit." These two sections are inconsistent: one falls back on revocation, the other doesn't fall back on lost access. The distinction (voluntary revocation vs. access removal) should be made explicit so implementers don't have to guess which rule takes precedence.

Files Reviewed (1 file)
  • .plans/cloud-agent-commit-as-user.md — 8 issues

Fix these issues in Kilo Cloud


Reviewed by claude-sonnet-4.6 · 1,485,945 tokens

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