Skip to content

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

Closed
kilo-code-bot[bot] wants to merge 2 commits into
mainfrom
plan/cloud-agent-commit-as-user
Closed

docs(cloud-agent-next): plan to commit as user via GitHub App user-to-server tokens#3147
kilo-code-bot[bot] wants to merge 2 commits 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.

Comment thread .plans/cloud-agent-commit-as-user.md Outdated
```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.

Comment thread .plans/cloud-agent-commit-as-user.md Outdated
`${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.

Comment thread .plans/cloud-agent-commit-as-user.md Outdated
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.

Comment thread .plans/cloud-agent-commit-as-user.md Outdated
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.

Comment thread .plans/cloud-agent-commit-as-user.md Outdated

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.

Comment thread .plans/cloud-agent-commit-as-user.md Outdated

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).

Comment thread .plans/cloud-agent-commit-as-user.md Outdated

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.

Comment thread .plans/cloud-agent-commit-as-user.md Outdated
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: 11 Issues Found | Recommendation: Address before merge

Executive Summary

The new commit adds container-bound capability enforcement (v2 prefix, container_mismatch check, getOutboundContainerId) with no new issues. The three previously-flagged index.ts warnings are unchanged and still open.

Overview

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

WARNING — Unchanged from previous revision (still open)

File Line Issue
services/git-token-service/src/index.ts 290 repository_mismatch classification returns wrong reason for unrelated .git-bearing paths on the same origin
services/git-token-service/src/index.ts 668 findGitLabIntegration called twice on non-code-review paths — resolveGitLabRuntimeToken already called it internally
services/git-token-service/src/index.ts 680 Missing integration identity returns no_token — incorrect error code; should distinguish credential absence from identity absence

WARNING — Carried forward from plan review (unchanged)

File Issue
.plans/cloud-agent-commit-as-user.md prompt=select_account is not a valid GitHub OAuth parameter — silently ignored
.plans/cloud-agent-commit-as-user.md error_description forwarded directly to redirect URL, contradicting the "map to friendly codes" note
.plans/cloud-agent-commit-as-user.md onConflictDoUpdate set clause leaves updated_at unspecified — Drizzle won't auto-update it
.plans/cloud-agent-commit-as-user.md Webhook revocation WHERE clause may need explicit github_app_type scope
.plans/cloud-agent-commit-as-user.md apps.deleteAuthorization requires the access token, not just client_id

SUGGESTION — Carried forward from plan review (unchanged)

File Issue
.plans/cloud-agent-commit-as-user.md Call out that "Expire user authorization tokens" must be verified before Phase 0
.plans/cloud-agent-commit-as-user.md no_user_token conflates "never connected" and "revoked"
.plans/cloud-agent-commit-as-user.md Optimistic-concurrency re-read path should validate the freshly-read token is also not near-expiry
Incremental Review (new commit 410282b)

The new commit introduces container-bound capabilities (kgh2./kgl2. prefix) and container_mismatch enforcement:

  • getOutboundContainerId — correctly derives the DO hex ID from getSandboxNamespace(env, sandboxId).idFromName(sandboxId).toString(). Routing logic (dind-* → SandboxDIND, ses-* → SandboxSmall, others → Sandbox) is consistent with existing getSandboxNamespace.
  • Version discrimination in codecsdecode() now verifies parsed.data.version !== format.version, preventing a v1-prefix token from being accepted when the outer format says v2 and vice versa. Correct.
  • container_mismatch checkclaims.version === 2 && claims.outboundContainerId !== params.outboundContainerId correctly gates v2 capabilities; v1 (legacy/unbound) tokens skip the check as intended for the staged rollout. The RPC caller (cloud-agent-next) always supplies outboundContainerId for v2 tokens, so the undefined comparison is only reachable for legacy redemption paths.
  • SandboxSmall / SandboxDIND added to PersistenceEnv — consistent with the namespace routing already present in getSandboxNamespace.
  • No new issues found.
Other Observations (not in diff)

Design-level observations from the plan review that remain relevant to the implementation:

  1. DB writes in git-token-service — Now confirmed: Hyperdrive binding is present in wrangler.jsonc and per-use getWorkerDb is used correctly (GitLabLookupService.getDb() and GitLabTokenService.getDb() both create per-request clients). The module-scope caching anti-pattern from the previous code was removed — this is correct.

  2. return_to open-redirect risk — The plan referenced safeReturnTo(return_to) without specifying relative-path-only validation. This concern applies to the OAuth callback implementation (not yet in this diff).

  3. Mid-session fallback inconsistency — The plan still contains the inconsistency between §8.3 (fall back to installation token on revocation) and §9 (don't fall back on lost access). The current implementation in sandbox-outbound.ts correctly fails closed on redemption failure without fallback, but the plan should be updated to reflect the implemented behavior.

  4. SCM_SESSION_CAPABILITY_ENCRYPTION_KEY not provisioned to cloud-agent-next — The capability encryption key is bound in services/git-token-service/wrangler.jsonc but cloud-agent-next doesn't have it (and doesn't need it, since it only calls the RPC). This is correct by design but worth confirming the secret is provisioned to the Secrets Store before deployment.

Files Reviewed (34 files)
  • .plans/cloud-agent-commit-as-user.md — deleted (plan issues remain open against implementation)
  • services/cloud-agent-next/src/persistence/types.tsSandboxSmall / SandboxDIND added to PersistenceEnv
  • services/cloud-agent-next/src/sandbox-id.ts — new getOutboundContainerId helper
  • services/cloud-agent-next/src/sandbox-id.test.ts — new tests for getOutboundContainerId
  • services/cloud-agent-next/src/sandbox-outbound.ts — multi-prefix support (kgh2./kgl2.), outboundContainerId threading
  • services/cloud-agent-next/src/sandbox-outbound.test.ts — updated for new signature and legacy rollout test
  • services/cloud-agent-next/src/services/git-token-service-client.tsoutboundContainerId added to issue params
  • services/cloud-agent-next/src/services/git-token-service-client.test.ts
  • services/cloud-agent-next/src/session-service.tsresolveWorkspaceTokens now accepts sandboxId
  • services/cloud-agent-next/src/session-service.test.ts
  • services/cloud-agent-next/src/types.tscontainer_mismatch added to redeem failure union
  • services/git-token-service/src/github-session-capability.ts — v1/v2 discriminated union codec
  • services/git-token-service/src/github-session-capability.test.ts
  • services/git-token-service/src/gitlab-session-capability.ts — v1/v2 discriminated union codec
  • services/git-token-service/src/gitlab-session-capability.test.ts
  • services/git-token-service/src/index.tsoutboundContainerId param threading + container_mismatch check (3 prior issues open)
  • services/git-token-service/src/index.test.ts — new container binding/legacy compat tests

Fix these issues in Kilo Cloud


Reviewed by claude-4.6-sonnet-20260217 · 1,717,683 tokens

Review guidance: REVIEW.md from base branch main

@eshurakov eshurakov force-pushed the plan/cloud-agent-commit-as-user branch from ab1d756 to 8bcff5c Compare June 2, 2026 13:25
const repositoryPrefix = `/${session.projectPath}`;
return {
failure:
url.pathname.startsWith(repositoryPrefix) || !url.pathname.includes('.git/')
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: Fragile repository_mismatch vs invalid_upstream_request classification

The condition !url.pathname.includes('.git/') returns repository_mismatch for any URL whose path contains .git/ but doesn't start with the repo's project path — e.g. https://gitlab.com/attacker/evil.git/files/something. The intent is to distinguish "right origin, wrong repo" from "right origin, not a repo URL at all", but a URL targeting a completely different .git-bearing path on the same host will be labelled repository_mismatch rather than invalid_upstream_request. Both outcomes fail closed, but the mismatch label is semantically wrong and can confuse incident investigations. Consider checking more strictly: url.pathname === repositoryPath || url.pathname.startsWith(repositoryPath + '/') for the mismatch case.

);
if (!runtimeToken.success) return runtimeToken;

const integration = await this.gitlabLookupService.findGitLabIntegration(
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: Redundant findGitLabIntegration DB lookup on non-code-review paths

resolveGitLabRuntimeToken already calls findGitLabIntegration internally for non-code-review sessions (see gitlab-runtime-token-resolver.ts:135). Immediately after, issueGitLabSessionCapability issues another findGitLabIntegration call for the same integration ID. The second call is needed to get integrationType/accountId/accountLogin fields that are not part of GetGitLabTokenSuccess, but on the common (non-code-review) path this doubles the DB queries per issuance. Consider enriching GetGitLabTokenSuccess with the identity fields so the second lookup is only needed for the code-review path where findAuthorizedGitLabIntegrations was used instead.

const repository = parseGitLabCloneUrl(params.gitUrl, instanceOrigin);
if (!repository.success) return repository;
const identity = this.getGitLabSessionIdentity(integration);
if (!identity) return { success: false, reason: 'no_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.

WARNING: Incorrect error reason when integration identity is missing

When getGitLabSessionIdentity returns null (both accountId and accountLogin are null on the integration row), this returns { success: false, reason: 'no_token' }. But no_token semantically means "no credential/access-token found", not "integration row is missing required identity fields". This maps to the same client-facing error message as a legitimately absent token, making the two failure modes indistinguishable in logs and user-facing flows. A dedicated reason like 'no_integration_identity' (or reusing 'no_integration_found') would be more accurate.

@eshurakov
Copy link
Copy Markdown
Contributor

Superseded by #3665 after the parent attribution work merged and this implementation branch was rebased into a clean child-only diff.

@eshurakov eshurakov closed this Jun 2, 2026
@eshurakov eshurakov deleted the plan/cloud-agent-commit-as-user branch June 2, 2026 13:40
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