From ab1d75698825ab18f8148fd523be2ad42ac7cd99 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 12:08:18 +0000 Subject: [PATCH] docs(cloud-agent-next): plan to commit as user via GitHub App user-to-server tokens --- .plans/cloud-agent-commit-as-user.md | 606 +++++++++++++++++++++++++++ 1 file changed, 606 insertions(+) create mode 100644 .plans/cloud-agent-commit-as-user.md diff --git a/.plans/cloud-agent-commit-as-user.md b/.plans/cloud-agent-commit-as-user.md new file mode 100644 index 0000000000..f51bf43f11 --- /dev/null +++ b/.plans/cloud-agent-commit-as-user.md @@ -0,0 +1,606 @@ +# Cloud-Agent: commit as the user via GitHub App user-to-server tokens + +Detailed implementation plan for "Option C" from the original research note. Goal: when `cloud-agent-next` pushes commits, GitHub attributes the commit and the push to the **Kilo user** who triggered the session, not to the `kiloconnect[bot]` GitHub App identity. + +This plan is implementation-ready: it names the files to touch, the schema columns to add, the env vars to wire, and the test coverage required. It is **not** an order to start coding — it's a scoping doc for review. + +--- + +## 1. Goal & non-goals + +### Goal + +When the Kilo user `alice@kilocode.ai` runs a cloud agent session against an org repo `Kilo-Org/foo`: + +- The commits show **Alice's** GitHub avatar + login as primary author and committer. +- The push is recorded in `Kilo-Org/foo`'s audit log as **Alice** (with a "via GitHub App" badge), not as the App. +- Branch-protection rules that gate on user identity (CODEOWNERS, "must be approved by a member of @org", etc.) work correctly. +- Commits count toward Alice's GitHub contribution graph. + +### Non-goals + +- **Replacing the GitHub App installation token entirely.** The App token still owns server-to-server actions: PR comments, status checks, webhooks, label/review-comment writes. Only `git push` and (optionally) `git clone` switch to the user token. +- **Verified/signed commits.** GPG/SSH commit signing on behalf of the user is out of scope for this iteration. (We can revisit later via the Git Data API + GPG signing key in the App, but it's a separate feature.) +- **Acting as the user in API calls beyond git operations.** No PR creation, no issue comments, no repo metadata reads via the user token in v1. Those keep using the installation token. (We may revisit creating PRs with the user token after v1 ships and we have telemetry.) +- **Backfilling per-user identity for existing Kilo users.** v1 ships the connect flow; existing users see a "Connect your GitHub identity" CTA on their next session prepare, but no batch migration. +- **GitHub Enterprise Server (self-hosted) support.** Same protocol works, but we don't currently have a deploy of GHES, so this is GitHub.com only in v1. + +--- + +## 2. Background — what's already built + +| Concern | Where | Notes | +|---|---|---| +| GitHub App token minting | `services/git-token-service/src/github-token-service.ts:97-163` | `createAppAuth` → installation tokens, KV-cached 30 min | +| Installation lookup | `services/git-token-service/src/installation-lookup-service.ts:68-153` | Joins `organization_memberships`, prefers org installations | +| OAuth code exchange | `apps/web/src/lib/integrations/platforms/github/adapter.ts:409-445` | `exchangeWebFlowCode` already wired; **today the resulting user token is discarded** | +| Install callback | `apps/web/src/app/api/integrations/github/callback/route.ts:114-260` | Persists `kilo_requester_user_id` + `platform_requester_account_id` for the install requester only | +| `platform_integrations` table | `packages/db/src/schema.ts:2163-2262` | XOR org/user-owned, `github_app_type: 'standard' \| 'lite'` | +| `user_auth_provider` table | `packages/db/src/schema.ts:1258-1276` | NextAuth identity rows (only set if user signed in via GitHub provider) | +| `githubToken` override on the wire | `services/cloud-agent-next/src/router/schemas.ts:121-178` | Already accepted on `prepareSession` and `sendMessageV2`, applied at `CloudAgentSession.ts:2650-2696`. **Today no code path sends one.** | +| Token in clone URL + refresh | `services/cloud-agent-next/src/workspace.ts:557-562, 693-725` | `https://x-access-token:@github.com/...` | +| Author/committer config | `services/cloud-agent-next/src/workspace.ts:534-541, 599-611` | Hardcoded to `${slug}[bot]` from worker env | +| Encryption helpers | `apps/web/src/lib/encryption.ts` → `@kilocode/encryption` | Symmetric AES-256-GCM (`encryptWithSymmetricKey`/`decryptWithSymmetricKey`); reused for promo codes, env vars, and `auth-token-encryption.ts` | +| Header redaction | `@kilocode/worker-utils/redact-headers` | Required for any logs that touch tokens | +| GDPR soft delete | `apps/web/src/lib/user.ts` `softDeleteUser` + `apps/web/src/lib/user.test.ts` | Must clear new PII; required by `.kilo/rules/gdpr-pii.md` | + +Reusing all of this means the new code is mostly: (a) one new table, (b) the connect/refresh flow, (c) a token-selection branch in two places. Everything else is plumbing. + +--- + +## 3. End-to-end architecture + +``` + ┌──────────────────────────────────────┐ + │ GitHub App "kiloconnect" │ + │ - installation tokens (existing) │ + │ - user-to-server tokens (new use) │ + └──────────────────────────────────────┘ + ▲ ▲ + (1) install │ │ (2) user authorize + │ │ +┌───────────────────────────────┴──────────┐ ┌──────┴───────────────────────┐ +│ apps/web │ │ apps/web │ +│ /api/integrations/github/callback │ │ /api/integrations/github/ │ +│ (existing — install requester only) │ │ user-connect/callback │ +│ → platform_integrations │ │ (NEW — per kilo user) │ +└──────────────────────────────────────────┘ │ → user_github_app_tokens │ + └──────────────────────────────┘ + ▲ + │ exchange + refresh + │ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ services/git-token-service │ +│ getTokenForRepo({ githubRepo, userId, orgId }) ← existing (App install) │ +│ getUserTokenForRepo({ kiloUserId, githubRepo, orgId }) ← NEW │ +│ - reads user_github_app_tokens │ +│ - refreshes via grant_type=refresh_token if near expiry │ +│ - returns null when user hasn't connected → caller falls back │ +└──────────────────────────────────────────────────────────────────────────────┘ + ▲ + │ RPC (service binding) + │ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ services/cloud-agent-next │ +│ session-prepare: try user token → fall back to installation token │ +│ workspace.cloneGitHubRepo: pass user identity OR bot identity │ +│ CloudAgentSession.updateGitToken: refresh either kind │ +│ │ +│ wrapper/auto-commit.ts: unchanged — uses repo-level git config │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +Two independent "fallbacks": + +- **No user token at all** (user never connected) → use installation token, set bot author. Today's behaviour, unchanged. +- **User token exists but can't see this repo** (lost access, SAML SSO not active, repo not in installation) → log a warning, fall back to installation token + bot author. **Do not silently fail the session.** + +--- + +## 4. GitHub App configuration + +### 4.1 App settings to change (per environment) + +In each existing App (`kiloconnect`, `kiloconnect-lite`, `kiloconnect-development`): + +1. **Add a new callback URL**: `https:///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. +3. **Do not enable** "Request user authorization (OAuth) during installation" — we want the connect flow to be a deliberate, separate step so install and connect can be initiated by different users at different times. +4. **Verify Account permissions** the App requests for users include nothing surprising. We only need the implicit "act on user's behalf" capability; no extra scopes. +5. Confirm `client_id` and `client_secret` are present for each App. They're already needed for `exchangeGitHubOAuthCode`; no action if it works today. +6. **Webhook**: subscribe to the `github_app_authorization` event so we get notified on user revocation. Already needed for any user-OAuth-using App. + +### 4.2 Env vars to wire + +Per worker (`services/git-token-service`, `apps/web`, `services/cloud-agent-next`) — already have `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` / `GITHUB_APP_CLIENT_ID` / `GITHUB_APP_CLIENT_SECRET`. No new vars unless: + +- We don't currently expose `GITHUB_APP_CLIENT_ID` / `_CLIENT_SECRET` to `git-token-service`. Right now the secret is used only by `apps/web` for `exchangeGitHubOAuthCode`. If `git-token-service` will own refresh, it needs both. **Action**: add `GITHUB_APP_CLIENT_ID` + `GITHUB_APP_CLIENT_SECRET` (plus Lite equivalents) to `services/git-token-service/wrangler.jsonc` env + `.dev.vars.example`. + +### 4.3 SAML SSO callout + +GitHub orgs with SAML SSO require the user to have an active SAML session at the **time of authorization** to see SSO'd-org repos. If a user authorizes without that session, repos in those orgs will silently disappear from `GET /user/installations/{installation_id}/repositories`. We need: + +- A "Re-authorize for this org" link in settings that re-runs the OAuth flow (see §6.4). +- Handling for the empty-repo-list case (treat as "user hasn't connected for this org" → fall back). + +--- + +## 5. Database schema + +### 5.1 New table: `user_github_app_tokens` + +Add to `packages/db/src/schema.ts`. **Generate the migration with `pnpm drizzle generate`** — never hand-write the SQL. + +```ts +export const userGitHubAppTokens = pgTable( + 'user_github_app_tokens', + { + id: uuid('id').primaryKey().defaultRandom(), + + kilo_user_id: uuid('kilo_user_id') + .notNull() + .references(() => kilocodeUsers.id, { onDelete: 'cascade' }), + + github_app_type: githubAppTypeEnum('github_app_type').notNull().default('standard'), + + // GitHub identity + github_user_id: text('github_user_id').notNull(), // numeric user id, stored as string (matches platform_requester_account_id convention) + github_login: text('github_login').notNull(), + github_email: text('github_email'), // primary email; nullable if user keeps email private + + // Encrypted tokens (AES-256-GCM via @kilocode/encryption) + access_token_encrypted: text('access_token_encrypted').notNull(), + access_token_expires_at: timestamp('access_token_expires_at', { withTimezone: true }).notNull(), + refresh_token_encrypted: text('refresh_token_encrypted').notNull(), + refresh_token_expires_at: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(), + + // State + revoked_at: timestamp('revoked_at', { withTimezone: true }), + revocation_reason: text('revocation_reason'), // 'user_revoked' | 'refresh_failed' | 'admin' + + created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updated_at: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + last_used_at: timestamp('last_used_at', { withTimezone: true }), + }, + (table) => [ + uniqueIndex('user_github_app_tokens_user_app_uidx').on(table.kilo_user_id, table.github_app_type), + index('user_github_app_tokens_github_user_id_idx').on(table.github_user_id), + ] +); +``` + +Notes: + +- **One row per (kilo user, App type).** A Kilo user can connect to standard and lite separately if we ever need to. +- `github_user_id` is `text` to match `platform_requester_account_id` (see schema.ts:2195). +- We deliberately do **not** store the installation IDs the token can access — that set is dynamic (user gains/loses repo access over time). We resolve it at use time via `GET /user/installations` and cache for ~5 min in `git-token-service`. +- We deliberately do **not** add a foreign key to `platform_integrations`. A user token is not bound to one installation; it can see *every* installation the user is a member of and the App is installed on. +- `last_used_at` is a soft signal for stale-row cleanup later; not authoritative. + +### 5.2 Migration scope + +- One migration file generated by `pnpm drizzle generate`. +- No backfill. Existing users start with no row and behaviour is identical to today (App-token push) until they connect. + +### 5.3 GDPR + +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. +- `apps/web/src/lib/user.test.ts`: new test asserting `user_github_app_tokens` rows are gone after soft-delete (and ideally a fake-octokit assertion that the revocation API was called). + +### 5.4 Logging / redaction + +- Tokens (`ghu_…`, `ghr_…`) must never appear in logs. Add patterns to whatever redactor we use for other secrets. Existing token sanitization in `services/cloud-agent-next/src/workspace.ts:39-51` strips `https://x-access-token:…@` URLs — that already covers the most common leak path. +- Sentry: do not enable `sendDefaultPii`. Make sure error messages from token exchange/refresh don't include the request body. +- Worker request logs from `git-token-service` should mask the token in any response logging (already does for installation tokens — extend to user tokens). + +--- + +## 6. `apps/web` — connect, callback, refresh, settings UI + +### 6.1 Initiation: `github.connectUserIdentity` tRPC mutation + +New procedure in `apps/web/src/routers/github-apps-router.ts`: + +```ts +connectUserIdentity: protectedProcedure + .input(z.object({ + appType: z.enum(['standard', 'lite']).default('standard'), + returnTo: z.string().optional(), // post-auth redirect inside the Kilo app + })) + .mutation(async ({ ctx, input }) => { + const credentials = getGitHubAppCredentials(input.appType); + const state = await createOAuthState({ + kilo_user_id: ctx.user.id, + app_type: input.appType, + return_to: input.returnTo ?? '/account/integrations', + }); + const authorizeUrl = new URL('https://github.com/login/oauth/authorize'); + authorizeUrl.searchParams.set('client_id', credentials.clientId); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set( + 'redirect_uri', + `${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'); + return { authorizeUrl: authorizeUrl.toString() }; + }); +``` + +`createOAuthState`: short-lived signed token (HMAC over `{kilo_user_id, app_type, return_to, nonce, expires_at}`), 10 min TTL. Reuse whatever signed-state helper exists for the existing GitHub install flow if any, or add a thin one. **Don't** store state server-side keyed by random nonce only — the kilo_user_id has to be bound to the state so we can authoritatively know who started the flow when the callback fires (the GitHub session may not match the Kilo session). + +### 6.2 Callback route: `/api/integrations/github/user-connect/callback` + +New file `apps/web/src/app/api/integrations/github/user-connect/callback/route.ts`. Pseudocode: + +```ts +export async function GET(request: Request) { + const { code, state, error, error_description } = parseQuery(request); + if (error) return errorRedirect(error_description ?? error); + + const decoded = await verifyOAuthState(state); + if (!decoded || decoded.expires_at < Date.now()) return errorRedirect('expired_state'); + + const { kilo_user_id, app_type, return_to } = decoded; + const credentials = getGitHubAppCredentials(app_type); + + // Exchange code → tokens (use @octokit/auth-app's exchangeWebFlowCode like adapter.ts:422) + const result = await exchangeWebFlowCode({ + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + clientType: 'github-app', + code, + }); + if (!result.authentication?.token) return errorRedirect('exchange_failed'); + + const auth = result.authentication; // { token, refreshToken, expiresAt, refreshTokenExpiresAt, ... } + + // Resolve user identity using the fresh user token + const octokit = new Octokit({ auth: auth.token }); + const { data: ghUser } = await octokit.rest.users.getAuthenticated(); + const { data: emails } = await octokit.rest.users.listEmailsForAuthenticatedUser().catch(() => ({ data: [] })); + const primaryEmail = emails.find(e => e.primary)?.email ?? null; + + await db.insert(userGitHubAppTokens).values({ + kilo_user_id, + github_app_type: app_type, + github_user_id: String(ghUser.id), + github_login: ghUser.login, + github_email: primaryEmail, + access_token_encrypted: encryptWithSymmetricKey(auth.token, USER_GH_APP_TOKEN_KEY), + access_token_expires_at: new Date(auth.expiresAt), + refresh_token_encrypted: encryptWithSymmetricKey(auth.refreshToken, USER_GH_APP_TOKEN_KEY), + 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 */ }, + }); + + return Response.redirect(safeReturnTo(return_to)); +} +``` + +Notes: + +- The `user:email` scope is implicit for user tokens on GitHub Apps that have any access; if `listEmailsForAuthenticatedUser` 404s, we just skip storing email. Push attribution doesn't need it (GitHub uses `+@users.noreply.github.com`). +- `errorRedirect` redirects to `/account/integrations?gh_error=` so the UI can render a banner with the failure reason. Don't leak GitHub's raw error string into the URL — map known errors to friendly codes. +- Idempotent: re-authorizing overwrites the row. This is also how a user "fixes SAML SSO" — they hit Connect again. + +### 6.3 Settings UI: `/account/integrations` (or wherever the existing GitHub App install lives) + +Three states, each surfaced in the existing GitHub integration card: + +1. **Not connected** → "Connect your GitHub identity to push commits as you" + button → calls `connectUserIdentity` and `window.location` to the returned URL. +2. **Connected** → "Pushing as **@alice** (you)" with the resolved login + avatar; secondary "Disconnect" button (calls a new `disconnectUserIdentity` mutation that DELETEs the row + revokes via `apps.deleteAuthorization`). +3. **Connection broken** (e.g. revoked outside Kilo, or refresh failed) → orange banner: "Reconnect GitHub to push as you" with the same Connect button. We render this state when `revoked_at IS NOT NULL` or last refresh failed. + +UI must follow `design.md` and `.agents/skills/kilo-design/SKILL.md` (Kilo tokens, shadcn/Radix patterns). Reuse the existing integration card layout — no new visual primitives. + +A second affordance lives in `cloud-agent-next` first-run/empty-state flows (see §7.4): a banner the first time a session starts and no user token is present, linking back to `/account/integrations`. + +### 6.4 SAML SSO recovery affordance + +For each org installation visible in the integration list, after the user has connected: ping `GET /user/installations/{installation_id}/repositories?per_page=1` with the user token; if it returns 0 and the org is known to use SAML SSO (`metadata.requires_sso` on `platform_integrations` if we have it, otherwise infer from the absence of repos despite the App having access), render an inline "This org uses SAML SSO. [Re-authorize to access SSO repos]". The link re-runs Connect. + +This check is best-effort and run client-side via tRPC, not on every page load. + +--- + +## 7. `services/git-token-service` — user token RPC + +### 7.1 New RPC method: `getUserTokenForRepo` + +In `services/git-token-service/src/index.ts` and `github-token-service.ts`: + +```ts +export type GetUserTokenInput = { + kiloUserId: string; + githubRepo: string; // "owner/repo" + appType?: GitHubAppType; // default 'standard' +}; + +export type GetUserTokenResult = + | { ok: true; token: string; expiresAt: number; githubLogin: string; githubUserId: string; githubEmail: string | null } + | { ok: false; reason: 'no_user_token' | 'no_repo_access' | 'revoked' | 'refresh_failed' }; + +export class GitTokenService extends WorkerEntrypoint { + async getUserTokenForRepo(input: GetUserTokenInput): Promise { ... } +} +``` + +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'}`. +2. If `access_token_expires_at` ≤ now + 5 min → refresh: + - POST `https://github.com/login/oauth/access_token` with `client_id`, `client_secret`, `grant_type=refresh_token`, `refresh_token`. + - On success: update DB row (encrypted tokens + expiries), continue. + - On `bad_refresh_token` or HTTP 4xx: mark row `revoked_at = now()`, `revocation_reason = 'refresh_failed'`, return `{ok: false, reason: 'revoked'}`. + - On 5xx/transient: don't mark revoked; return `{ok: false, reason: 'refresh_failed'}` so caller falls back this time. Retry on next call. +3. Verify the user token can see the target repo: + - Cheapest path: `GET /repos/{owner}/{repo}` with the user token. 200 → can see; 404 → cannot. Cache `(kiloUserId, owner, repo) → bool` in KV for 5 min. + - Alternative: `GET /user/installations/{installationId}/repositories` and check membership. Cleaner mental model but two API calls. Pick the first. + - On 404 → `{ok: false, reason: 'no_repo_access'}`. +4. Return token + identity. Update `last_used_at` (best-effort, fire-and-forget). + +### 7.2 Refresh concurrency + +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. +- This is the same pattern already used implicitly by the installation token cache — extend it. + +### 7.3 Existing `getTokenForRepo` is unchanged + +The existing installation-token RPC keeps working exactly as today. The cloud-agent-next caller decides which to ask for first (see §8). + +### 7.4 Webhook handler for revocation + +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`. +- Verify webhook signature (`X-Hub-Signature-256` + App webhook secret) — required. + +--- + +## 8. `services/cloud-agent-next` — token selection + author identity + +### 8.1 Decision point: `session-prepare` + +In `services/cloud-agent-next/src/router/handlers/session-prepare.ts:263-285`, replace the single `getTokenForRepo` call with: + +```ts +// Try user token first +const userTokenResult = await env.GIT_TOKEN_SERVICE.getUserTokenForRepo({ + kiloUserId: ctx.userId, + githubRepo, + appType, +}); + +if (userTokenResult.ok) { + return { + token: userTokenResult.token, + identity: 'user' as const, + githubLogin: userTokenResult.githubLogin, + githubUserId: userTokenResult.githubUserId, + githubEmail: userTokenResult.githubEmail, + expiresAt: userTokenResult.expiresAt, + }; +} + +// Fall back: log why, then use installation token +logger.info('Falling back to installation token', { + kiloUserId: ctx.userId, + githubRepo, + reason: userTokenResult.reason, +}); +const appTokenResult = await env.GIT_TOKEN_SERVICE.getTokenForRepo({ githubRepo, userId: ctx.userId, orgId }); +return { token: appTokenResult.token, identity: 'app' as const, ... }; +``` + +The `reason` is propagated to the response so the UI can render the "Connect your GitHub identity" banner with appropriate copy: + +- `no_user_token` → "Connect to push as you." +- `no_repo_access` → "Your GitHub account can't push to this repo. We're pushing as the bot." +- `revoked` / `refresh_failed` → "Reconnect GitHub to push as you." + +### 8.2 Author/committer identity + +Update `services/cloud-agent-next/src/workspace.ts:534-541` (`cloneGitHubRepo`) so the author config branches on identity kind: + +```ts +type GitIdentity = + | { kind: 'app'; slug: string; botUserId: string } + | { kind: 'user'; login: string; userId: string; email: string | null }; + +function buildGitAuthor(identity: GitIdentity): GitAuthorConfig { + if (identity.kind === 'app') { + return { + name: `${identity.slug}[bot]`, + email: `${identity.botUserId}+${identity.slug}[bot]@users.noreply.github.com`, + }; + } + return { + name: identity.login, + email: identity.email ?? `${identity.userId}+${identity.login}@users.noreply.github.com`, + }; +} +``` + +When `identity.kind === 'user'` and we have a verified primary email, prefer it (gives the user a green "Verified" badge on commits). Otherwise the no-reply form, which still attributes correctly. + +The existing `cloneGitHubRepo` signature already takes a flexible `env` arg — extend it to take `identity: GitIdentity` instead of the bot-only env vars. + +### 8.3 Token refresh during a long-running session + +In `CloudAgentSession.ts:2575-2696`: + +- Today's `updateGithubToken` calls `getToken(installationId, appType)` and writes a fresh installation token into the remote URL. +- New behaviour: track which kind of token the session was prepared with. On each interaction: + - If `identity.kind === 'user'` → call `getUserTokenForRepo` (which handles refresh internally) and write the result back. If it now returns `{ok:false}` (e.g. user revoked mid-session), **fall back to the installation token** mid-session and surface a one-time warning to the user. Don't kill the session. + - If `identity.kind === 'app'` → today's path, unchanged. + +Persist `identity_kind` and (for user tokens) `kilo_user_id` + `github_app_type` in the session DO state so the renewal call has what it needs after a worker restart. + +### 8.4 Wrapper changes + +**Goal: zero changes to `wrapper/src/auto-commit.ts`.** The wrapper inherits repo-level git config; if we set the right `user.name`/`user.email` at clone time and refresh the token in the remote URL between interactions, the wrapper's existing `git add -A && git commit && git push` is correct. + +The only wrapper-side change considered: log line tweak so the operator-facing log says "pushed as @alice" vs "pushed as kiloconnect[bot]". Cosmetic, optional. + +--- + +## 9. Edge cases & failure modes + +| Case | Handling | +|---|---| +| User has never connected | Fall back silently; surface "connect" CTA in UI on next session. | +| User token expired but refresh works | Transparent refresh in `git-token-service`; one extra HTTP call, no user-visible impact. | +| Refresh token expired (>6 months idle) | Mark `revoked_at`, fall back, surface "reconnect" CTA. | +| User revoked the App in their GitHub settings | `github_app_authorization` webhook fires → mark `revoked_at`. Next session falls back. | +| App is uninstalled from the org | Installation token also goes away. Existing handling applies (already an error today). User token is unaffected per-user but `getUserTokenForRepo` will return `no_repo_access`. Same fallback path. | +| User loses repo access mid-session (push 403s) | Wrapper's git push fails → surface as a session error; the user's permission change is the user's problem to fix. We don't auto-fall-back to App token mid-push because that'd silently re-attribute the commit. | +| Two Kilo users, one shared repo, one connected, one not | Each user gets their own attribution. Behaviour diverges per session, which is the desired outcome. | +| User signed in to Kilo with a different GitHub account than the one they connect | Allowed. The user token identity is canonical for commits; the NextAuth identity is unchanged. Make this explicit in copy: "Connect the GitHub account you want commits to be attributed to." | +| Lite App | Lite is read-only — there's nothing to push. **Do not** offer the Connect flow on Lite installations in v1. Add an `if (appType === 'lite') reject()` in `connectUserIdentity`. | +| GitHub.com outage during refresh | `getUserTokenForRepo` returns `refresh_failed`; we fall back to installation token and don't mark revoked. Retried on next call. | +| Race: user authorizes from two browser tabs simultaneously | Both callbacks complete; the second `onConflictDoUpdate` overwrites the first. Both refresh tokens are valid against GitHub but only the second one is stored — the first is orphaned but expires harmlessly. Acceptable. | +| Audit log expectation mismatch (org admin expects "App pushed everything") | Document in the connect UI: "Commits will be attributed to your GitHub account in audit logs." Org admins can disable user-OAuth on the App at the org level if they don't want this — not our problem. | + +--- + +## 10. Security & compliance + +- **Encryption at rest**: `access_token_encrypted` and `refresh_token_encrypted` use `encryptWithSymmetricKey` with a dedicated key `USER_GH_APP_TOKEN_ENCRYPTION_KEY` (separate from existing keys, rotated independently). Pattern matches `services/cloud-agent-next/src/utils/encryption.ts` and `apps/web/src/lib/user-deployments/auth-token-encryption.ts`. +- **Encryption key storage**: Vercel secret + Cloudflare Worker secret per env (already how we manage similar keys). Rotation is a documented runbook step but not a v1 deliverable. +- **Logging**: never log raw tokens; redaction patterns for `ghu_[A-Za-z0-9_-]+` and `ghr_[A-Za-z0-9_-]+` added to whatever shared redaction we use. Sentry: confirm `sendDefaultPii: false`, `attachRpcInput: false`. +- **State / CSRF**: signed `state` parameter required on the OAuth flow, bound to the Kilo user id and a 10-min expiry. Reject mismatches. +- **Webhook verification**: `github_app_authorization` webhook handler verifies `X-Hub-Signature-256` against the App webhook secret. Same code as any existing GitHub webhook. +- **GDPR**: `softDeleteUser` deletes the row + revokes via `apps.deleteAuthorization`. Test required. (See §5.3.) +- **Audit trail (internal)**: log `(kilo_user_id, github_login, action, timestamp)` for connect/disconnect/refresh-failed events to our standard event sink — no token values, just identity transitions. +- **PII inventory**: this table holds `github_login` and `github_email` on top of tokens. Update whatever PII inventory doc the org maintains, if any. + +--- + +## 11. Testing strategy + +### 11.1 Unit tests (Vitest, no GitHub network) + +- `git-token-service`: + - `getUserTokenForRepo` returns `no_user_token` when row missing. + - Returns `revoked` when `revoked_at` set. + - Refreshes when within 5-min expiry window; calls GitHub mock once. + - Refresh-token failure → marks row `revoked_at`. + - Concurrent refresh: only one row update wins (test with a controlled GitHub mock that returns the same token twice). + - Repo-access check: 404 → `no_repo_access`. +- `apps/web`: + - State signing/verification roundtrip. + - Callback persists row + sets revoked_at to null on reconnect. + - GDPR: `softDeleteUser` removes rows; existing test file must extend. +- `cloud-agent-next`: + - `session-prepare` returns `identity: 'user'` when token available, `'app'` otherwise. + - `cloneGitHubRepo` builds correct `git config` lines for both identities. + - Mid-session refresh falls back to installation token when user token revokes mid-stream. + +### 11.2 Integration tests + +- `services/cloud-agent-next/test/`: full session prepare → clone → commit → push using a fake GitHub upstream (existing test infra has one). Two scenarios: user-token path and fallback path. Assert author email + remote URL form. +- `apps/web/src/lib/user.test.ts`: verify GDPR delete scrubs the new table. + +### 11.3 Manual verification (PR-checklist material) + +- Connect flow happy path on staging. +- Reconnect flow (revoke in GitHub UI, observe webhook → row marked revoked → next session falls back → reconnect heals). +- SAML SSO org: connect without active SSO session, observe empty repo list, re-authorize within SSO session, observe access works. +- Two Kilo users on same org repo: each pushes; commits attributed correctly. +- 8h expiry: artificially set `access_token_expires_at` in the past, observe transparent refresh on next session. + +--- + +## 12. Rollout & feature flag + +### 12.1 Phasing + +| Phase | Deliverable | Behind flag? | +|---|---|---| +| 0 | App config + env vars in all environments | n/a | +| 1 | Schema migration + connect/callback + settings UI (read-only "view connection") | Behind `feature_flag_github_user_token_connect`, dev/staging only | +| 2 | `git-token-service` user-token RPC + cloud-agent-next branch + author identity | Same flag, dev/staging | +| 3 | Internal dogfood (Kilo team only — flag on for `kilo-org` org) | flag | +| 4 | GA | flag removed | + +### 12.2 Flag behaviour + +`feature_flag_github_user_token_connect`: + +- Off → `connectUserIdentity` mutation throws `forbidden`; UI hides the Connect button entirely; `session-prepare` skips the user-token branch and behaves exactly as today. +- On → full path enabled. + +This means rollout is a per-org or per-user flag flip, and rollback is instant (flag off → next session uses App token, no migrations to revert). + +### 12.3 What "GA" requires + +- Two weeks of dogfood with no `refresh_failed` rate above 1% (excluding deliberate user revokes). +- Settings UI design review against `design.md`. +- Runbook page for "GitHub user token issues" (refresh fail, SSO, disconnect/reconnect). +- Tracking: Posthog events for `github_user_token.connected`, `…disconnected`, `…fallback_used`, `…refresh_failed`. + +### 12.4 Failure rollback + +- Flag off → no further user tokens used; existing rows are dormant but valid. We can flip it back on later. +- If we discover a security issue in storage: drop the table (rows are dead weight without code reading them); revoke all authorizations server-side via `DELETE /applications/{client_id}/grant` per row. + +--- + +## 13. Effort estimate + +| Area | Effort | +|---|---| +| GitHub App config + env vars in 3 envs | 0.5 d | +| Schema + migration + GDPR | 0.5 d | +| OAuth state helper + connect mutation + callback route | 1 d | +| `git-token-service` `getUserTokenForRepo` + refresh + tests | 1.5 d | +| `cloud-agent-next` token selection + author identity + session DO threading | 1 d | +| Settings UI (Connect/Disconnect/Reconnect states, design review) | 1 d | +| Webhook handler for `github_app_authorization` | 0.5 d | +| Integration tests + manual QA + runbook | 1 d | +| Posthog events + dogfood polish | 0.5 d | +| **Total** | **~7.5 dev-days** (≈1.5 weeks for one engineer with reviews) | + +Adjust ±50% depending on encryption-key plumbing snags and how much existing OAuth-state code we can reuse. + +--- + +## 14. Open questions for the reviewer + +1. **Is GA gated on the SAML SSO flow being polished, or do we ship without it and document the workaround?** SAML is a tail of orgs; not blocking dogfood. +2. **Do we want to also use the user token for opening PRs** (i.e. let the agent open a PR "as me" via `POST /repos/{owner}/{repo}/pulls`)? Same token works for that endpoint. v1 scope says no — we keep PR ops on the App — but easy add later. +3. **Should the Connect CTA appear automatically on every session prepare, or only after the user opens settings?** Auto-banner is more discoverable but noisier. Recommendation: silent fallback in v1, banner only on the cloud-agent landing page; revisit after dogfood. +4. **Encryption key**: new dedicated key vs. reuse `USER_DEPLOYMENTS_GIT_TOKEN_ENCRYPTION_KEY`? Recommendation: new key — different threat model, different rotation cadence. +5. **Do we offer Disconnect that revokes globally vs. just-Kilo?** Calling `apps.deleteAuthorization` revokes the user's authorization of the whole App; the user can re-authorize without uninstalling. This is the right behaviour. +6. **Lite App connect flow**: confirmed N/A in v1? + +--- + +## 15. Code reference index (read these before starting) + +- `services/cloud-agent-next/src/workspace.ts:520-725` — clone, token-in-URL, refresh, author config +- `services/cloud-agent-next/src/persistence/CloudAgentSession.ts:2575-2696` — token refresh during session +- `services/cloud-agent-next/src/router/handlers/session-prepare.ts:263-285` — token resolution call site +- `services/cloud-agent-next/src/router/schemas.ts:121-178` — existing `githubToken` override +- `services/git-token-service/src/github-token-service.ts:97-163` — App auth pattern, KV cache pattern +- `services/git-token-service/src/installation-lookup-service.ts:68-153` — auth model for cross-checks +- `apps/web/src/lib/integrations/platforms/github/adapter.ts:409-445` — existing OAuth code exchange +- `apps/web/src/app/api/integrations/github/callback/route.ts:114-260` — existing install callback (reference) +- `apps/web/src/lib/user-deployments/auth-token-encryption.ts` — encryption pattern to mirror +- `packages/db/src/schema.ts:2163-2262` — `platform_integrations` (don't touch, but read) +- `packages/db/src/schema.ts:1258-1276` — `user_auth_provider` (don't touch, but read) +- `apps/web/src/lib/user.ts` `softDeleteUser` + `apps/web/src/lib/user.test.ts` — GDPR site of update +- `services/gastown/container/src/agent-runner.ts:88-119` — Co-authored-by trailer pattern (reference, not used here) +- GitHub docs: + - https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app + - https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens + - https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/identifying-and-authorizing-users-for-github-apps + - https://docs.github.com/en/rest/authentication/endpoints-available-for-github-app-user-access-tokens