Skip to content

Commit e9f66a2

Browse files
committed
feat(github): add user-attributed cloud agent actions
Allow eligible Cloud Agent work to use connected GitHub identities while retaining installation fallback and bot co-authoring. Keep persisted user credentials in Worker-owned keyed envelopes and route disconnect revocation through the established internal API boundary.
1 parent 7081bb2 commit e9f66a2

67 files changed

Lines changed: 41281 additions & 10434 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.local.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ STRIPE_KILOCLAW_2026_05_10_COMMIT_PRICE_ID=price_test_kiloclaw_2026_05_10_commit
9393
# ============================================================================
9494
# Internal API secret (generate: openssl rand -base64 32)
9595
INTERNAL_API_SECRET=changeme
96+
# Git token service persisted-authorization disconnect (uses INTERNAL_API_SECRET)
97+
GIT_TOKEN_SERVICE_API_URL=http://localhost:8802
9698
# Worker URLs (defaults shown, workers are optional)
9799
CLOUD_AGENT_API_URL=http://localhost:8788
98100
WEBHOOK_AGENT_URL=http://localhost:8793
@@ -131,6 +133,10 @@ NEXT_PUBLIC_SENTRY_DSN=
131133
# Encryption keys (generate if needed)
132134
BYOK_ENCRYPTION_KEY=
133135
CREDIT_CATEGORIES_ENCRYPTION_KEY=
136+
# Connected GitHub user token envelope encryption (dedicated RSA public key only in Web)
137+
USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID=
138+
# Base64-encoded PEM public key; keep the matching private key only in git-token-service
139+
USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY=
134140
# Agent environment vars encryption (RSA public key, base64 encoded)
135141
AGENT_ENV_VARS_PUBLIC_KEY=
136142
# User deployments

apps/web/src/app/(app)/organizations/[id]/integrations/components/PlatformCard.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import { Button } from '@/components/ui/button';
66
import { CheckCircle2, XCircle, Clock, ArrowRight, GitBranch } from 'lucide-react';
77
import type { Platform } from '@/lib/integrations/platform-definitions';
88

9-
interface PlatformCardProps {
9+
export type GitHubIdentityStatus = 'connected' | 'revoked';
10+
11+
type PlatformCardProps = {
1012
platform: Platform;
13+
githubIdentityStatus?: GitHubIdentityStatus;
1114
onNavigate?: (platformId: string) => void;
12-
}
15+
};
1316

1417
const PlatformIcon = () => {
1518
// Using GitBranch as placeholder for all, we can add specific icons later
@@ -42,13 +45,40 @@ const StatusBadge = ({ status }: { status: Platform['status'] }) => {
4245
}
4346
};
4447

45-
export function PlatformCard({ platform, onNavigate }: PlatformCardProps) {
48+
const GitHubIdentityBadge = ({ status }: { status: GitHubIdentityStatus }) => {
49+
if (status === 'connected') {
50+
return (
51+
<Badge variant="default" className="flex items-center gap-1">
52+
<CheckCircle2 className="h-3 w-3" />
53+
Identity connected
54+
</Badge>
55+
);
56+
}
57+
58+
return (
59+
<Badge variant="secondary" className="flex items-center gap-1">
60+
<XCircle className="h-3 w-3" />
61+
Reconnect identity
62+
</Badge>
63+
);
64+
};
65+
66+
export function PlatformCard({ platform, githubIdentityStatus, onNavigate }: PlatformCardProps) {
4667
const handleClick = () => {
4768
if (platform.enabled && onNavigate) {
4869
onNavigate(platform.id);
4970
}
5071
};
5172

73+
const description =
74+
githubIdentityStatus === 'connected'
75+
? platform.status === 'installed'
76+
? 'Your GitHub identity is connected and personal repository access is set up.'
77+
: 'Your GitHub identity is connected. Set up personal repository access here, or use access from an organization.'
78+
: githubIdentityStatus === 'revoked'
79+
? 'Reconnect your GitHub identity to let Cloud Agent act as you. Repository access is managed separately.'
80+
: platform.description;
81+
5282
return (
5383
<Card
5484
className={`transition-all ${
@@ -64,16 +94,22 @@ export function PlatformCard({ platform, onNavigate }: PlatformCardProps) {
6494
<div className="min-w-0 flex-1">
6595
<div className="flex flex-wrap items-center gap-2">
6696
<CardTitle>{platform.name}</CardTitle>
67-
<StatusBadge status={platform.status} />
97+
{githubIdentityStatus ? (
98+
<GitHubIdentityBadge status={githubIdentityStatus} />
99+
) : (
100+
<StatusBadge status={platform.status} />
101+
)}
68102
</div>
69-
<CardDescription className="mt-2">{platform.description}</CardDescription>
103+
<CardDescription className="mt-2">{description}</CardDescription>
70104
</div>
71105
</div>
72106
</CardHeader>
73107
<CardContent>
74108
{platform.enabled ? (
75109
<Button variant="outline" className="group w-full" onClick={handleClick}>
76-
{platform.status === 'installed' ? 'Manage Integration' : 'Configure'}
110+
{platform.status === 'installed' || githubIdentityStatus
111+
? 'Manage Integration'
112+
: 'Configure'}
77113
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
78114
</Button>
79115
) : (
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { NextRequest } from 'next/server';
2+
import { createHash } from 'node:crypto';
3+
import { NextResponse } from 'next/server';
4+
import { captureException, captureMessage } from '@sentry/nextjs';
5+
import { APP_URL } from '@/lib/constants';
6+
import { getUserFromAuth } from '@/lib/user/server';
7+
import { consumeGitHubUserAuthorizationState } from '@/lib/integrations/platforms/github/user-authorization-state';
8+
import { exchangeAndStoreGitHubUserAuthorization } from '@/lib/integrations/platforms/github/user-authorization';
9+
10+
function redirectWithStatus(key: 'success' | 'error', value: string): NextResponse {
11+
const target = new URL('/integrations/github', APP_URL);
12+
target.searchParams.set(key, value);
13+
return NextResponse.redirect(target);
14+
}
15+
16+
function safeCallbackContext(searchParams: URLSearchParams) {
17+
const state = searchParams.get('state');
18+
return {
19+
hasCode: Boolean(searchParams.get('code')),
20+
hasState: Boolean(state),
21+
stateHash: state ? createHash('sha256').update(state).digest('hex').slice(0, 8) : null,
22+
providerError: searchParams.get('error'),
23+
};
24+
}
25+
26+
function validOAuthCode(code: string | null): string | null {
27+
if (!code || code.length > 2048 || !/^[A-Za-z0-9._~+/-]+$/.test(code)) return null;
28+
return code;
29+
}
30+
31+
function logDevelopmentCallbackFailure(stage: string, searchParams: URLSearchParams): void {
32+
if (process.env.NODE_ENV !== 'development') return;
33+
const context = safeCallbackContext(searchParams);
34+
console.error('[GitHub user authorization callback debug]', {
35+
stage,
36+
hasCode: context.hasCode,
37+
hasState: context.hasState,
38+
stateHash: context.stateHash,
39+
hasProviderError: Boolean(context.providerError),
40+
});
41+
}
42+
43+
export async function GET(request: NextRequest) {
44+
let stage = 'authenticate_user';
45+
try {
46+
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
47+
if (authFailedResponse) {
48+
return NextResponse.redirect(new URL('/users/sign_in', APP_URL));
49+
}
50+
51+
const searchParams = request.nextUrl.searchParams;
52+
if (searchParams.get('error')) {
53+
return redirectWithStatus('error', 'authorization_cancelled');
54+
}
55+
56+
stage = 'consume_state';
57+
const state = await consumeGitHubUserAuthorizationState(searchParams.get('state'), user.id);
58+
if (!state) {
59+
captureMessage('GitHub user authorization callback invalid state', {
60+
level: 'warning',
61+
tags: { endpoint: 'github/user-connect/callback' },
62+
extra: safeCallbackContext(searchParams),
63+
});
64+
return redirectWithStatus('error', 'invalid_state');
65+
}
66+
67+
const code = validOAuthCode(searchParams.get('code'));
68+
if (!code) {
69+
return redirectWithStatus('error', 'missing_code');
70+
}
71+
72+
stage = 'exchange_and_store_authorization';
73+
const result = await exchangeAndStoreGitHubUserAuthorization({
74+
kiloUserId: user.id,
75+
code,
76+
codeVerifier: state.codeVerifier,
77+
});
78+
if (result.status !== 'connected') {
79+
return redirectWithStatus('error', result.status);
80+
}
81+
82+
return redirectWithStatus('success', 'user_connected');
83+
} catch (error) {
84+
logDevelopmentCallbackFailure(stage, request.nextUrl.searchParams);
85+
captureException(error, {
86+
tags: { endpoint: 'github/user-connect/callback' },
87+
extra: safeCallbackContext(request.nextUrl.searchParams),
88+
});
89+
return redirectWithStatus('error', 'connection_failed');
90+
}
91+
}

0 commit comments

Comments
 (0)