Skip to content

Commit 6b28b9d

Browse files
authored
feat(cloud-agent): commit as yourself instead of the Kilo bot (#3638)
feat(github): add user-attributed cloud agent actions
1 parent 776ab50 commit 6b28b9d

74 files changed

Lines changed: 42554 additions & 10454 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
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

DEVELOPMENT.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,19 @@ The `@url` annotation accepts multiple comma-separated services (e.g., `# @url s
394394

395395
Run `pnpm dev:env` again after pulling changes that add new env vars to any `.dev.vars.example`.
396396

397+
### RSA environment keypair generation
398+
399+
Generate a dedicated RSA keypair when one runtime encrypts environment-backed secrets and another runtime decrypts them:
400+
401+
```bash
402+
pnpm exec tsx dev/generate-rsa-env-keypair.ts -- \
403+
--out-dir <secure-output-dir> \
404+
--public-env <PUBLIC_KEY_ENV> \
405+
--private-env <PRIVATE_KEY_ENV>
406+
```
407+
408+
The command requires a new output directory outside the repository, then writes restricted PKCS#8 private-key, SPKI public-key, and base64 env-assignment files without overwriting existing output. Store `private.pem` and `private.env` in an approved secrets manager and never commit them. Generate a separate keypair for each encryption domain; do not reuse deployment, agent-profile, or GitHub user-token keypairs.
409+
397410
### Local Grafana (reads prod Analytics Engine)
398411

399412
KiloClaw emits events to Cloudflare Analytics Engine (datasets `kiloclaw_events`, `kiloclaw_controller_telemetry`). A local-only Grafana is available for querying those datasets against the real production CF account — there is no local ClickHouse, and `wrangler dev` cannot simulate AE writes, but Grafana can always read what prod has already written.

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+
}

apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
44
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
5+
import Link from 'next/link';
56
import { useRouter } from 'next/navigation';
67
import { useAtom, useSetAtom } from 'jotai';
78
import { toast } from 'sonner';
@@ -89,6 +90,12 @@ import {
8990
setLastUsedRepo,
9091
setLastUsedVariant,
9192
} from '@/components/cloud-agent-next/model-preferences';
93+
import {
94+
GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY,
95+
getGitHubIdentityHint,
96+
getGitHubIdentityHintDismissed,
97+
markGitHubIdentityHintDismissed,
98+
} from '@/components/cloud-agent-next/github-identity-hint';
9299

93100
type Repository = {
94101
id: number;
@@ -102,6 +109,13 @@ type NewSessionPanelProps = {
102109
isDevcontainerAvailable: boolean;
103110
};
104111

112+
type ContextualTipProps = {
113+
body: string;
114+
linkLabel: string;
115+
href: string;
116+
onDismiss: () => void;
117+
};
118+
105119
export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: NewSessionPanelProps) {
106120
const router = useRouter();
107121
const trpc = useTRPC();
@@ -111,6 +125,9 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
111125
const fileInputRef = useRef<HTMLInputElement>(null);
112126
const commandListRef = useRef<HTMLDivElement>(null);
113127
const [devcontainer, setDevcontainer] = useState(false);
128+
const [isGitHubIdentityHintDismissed, setIsGitHubIdentityHintDismissed] = useState<
129+
boolean | null
130+
>(null);
114131
const { mutateAsync: personalUploadUrl } = useMutation(
115132
trpc.cloudAgentNext.getAttachmentUploadUrl.mutationOptions()
116133
);
@@ -173,6 +190,21 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
173190
const [isPreparing, setIsPreparing] = useState(false);
174191
const [attachmentMessageUuid, setAttachmentMessageUuid] = useState(() => crypto.randomUUID());
175192

193+
// ---------------------------------------------------------------------------
194+
// GitHub identity awareness
195+
// ---------------------------------------------------------------------------
196+
const {
197+
data: githubUserAuthorization,
198+
isLoading: isGitHubUserAuthorizationLoading,
199+
isError: isGitHubUserAuthorizationError,
200+
} = useQuery({
201+
...trpc.githubApps.getUserAuthorization.queryOptions(),
202+
enabled:
203+
isGitHubIdentityHintDismissed === false &&
204+
selectedRepo.length > 0 &&
205+
selectedPlatform === 'github',
206+
});
207+
176208
const attachmentUpload = useCloudAgentAttachmentUpload({
177209
messageUuid: attachmentMessageUuid,
178210
organizationId,
@@ -197,6 +229,15 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
197229

198230
useEffect(() => {
199231
setDevcontainer(getDevcontainerEnabled());
232+
setIsGitHubIdentityHintDismissed(getGitHubIdentityHintDismissed());
233+
234+
const handleGitHubIdentityHintStorage = (event: StorageEvent) => {
235+
if (event.key === GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY && event.newValue === 'true') {
236+
setIsGitHubIdentityHintDismissed(true);
237+
}
238+
};
239+
window.addEventListener('storage', handleGitHubIdentityHintStorage);
240+
return () => window.removeEventListener('storage', handleGitHubIdentityHintStorage);
200241
}, []);
201242

202243
const handleDevcontainerChange = useCallback((enabled: boolean) => {
@@ -865,6 +906,20 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
865906
[handleAutocompleteKeyDown, isFormValid, handleStartSession]
866907
);
867908

909+
const githubIdentityHint = getGitHubIdentityHint({
910+
selectedRepo,
911+
selectedPlatform,
912+
authorization: githubUserAuthorization,
913+
isLoading: isGitHubUserAuthorizationLoading,
914+
isError: isGitHubUserAuthorizationError,
915+
isDismissed: isGitHubIdentityHintDismissed !== false,
916+
});
917+
918+
const handleDismissGitHubIdentityHint = useCallback(() => {
919+
markGitHubIdentityHintDismissed();
920+
setIsGitHubIdentityHintDismissed(true);
921+
}, []);
922+
868923
// ---------------------------------------------------------------------------
869924
// Integration missing view
870925
// ---------------------------------------------------------------------------
@@ -1326,6 +1381,42 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
13261381
/>
13271382
</div>
13281383
</div>
1384+
1385+
{githubIdentityHint && (
1386+
<ContextualTip {...githubIdentityHint} onDismiss={handleDismissGitHubIdentityHint} />
1387+
)}
1388+
</div>
1389+
</div>
1390+
);
1391+
}
1392+
1393+
function ContextualTip({ body, linkLabel, href, onDismiss }: ContextualTipProps) {
1394+
return (
1395+
<div className="group/tip flex max-w-full justify-center text-center" role="status">
1396+
<div className="text-muted-foreground inline-flex max-w-full items-start justify-center gap-1 text-xs">
1397+
<span aria-hidden="true" className="invisible mr-1 shrink-0 px-1">
1398+
Dismiss
1399+
</span>
1400+
<span className="text-foreground font-medium">Tip:</span>
1401+
<span aria-hidden="true" className="text-border">
1402+
&middot;
1403+
</span>
1404+
<span className="min-w-0">
1405+
{body}{' '}
1406+
<Link
1407+
href={href}
1408+
className="text-blue-400 hover:text-blue-300 hover:underline focus-visible:underline"
1409+
>
1410+
{linkLabel}
1411+
</Link>
1412+
</span>
1413+
<button
1414+
type="button"
1415+
className="text-muted-foreground/70 hover:text-foreground focus-visible:text-foreground focus-visible:ring-ring pointer-events-none -my-4 ml-1 shrink-0 cursor-pointer rounded-sm px-1 py-4 underline decoration-border underline-offset-4 opacity-0 transition-opacity group-focus-within/tip:pointer-events-auto group-focus-within/tip:opacity-100 group-hover/tip:pointer-events-auto group-hover/tip:opacity-100 focus-visible:ring-1 focus-visible:outline-none [@media(any-pointer:coarse)]:pointer-events-auto [@media(any-pointer:coarse)]:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"
1416+
onClick={onDismiss}
1417+
>
1418+
Dismiss
1419+
</button>
13291420
</div>
13301421
</div>
13311422
);
@@ -1334,7 +1425,6 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
13341425
// ---------------------------------------------------------------------------
13351426
// Internal sub-component for repo items in the Command list
13361427
// ---------------------------------------------------------------------------
1337-
13381428
function RepoCommandItem({
13391429
repo,
13401430
isSelected,

0 commit comments

Comments
 (0)