Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ STRIPE_KILOCLAW_2026_05_10_COMMIT_PRICE_ID=price_test_kiloclaw_2026_05_10_commit
# ============================================================================
# Internal API secret (generate: openssl rand -base64 32)
INTERNAL_API_SECRET=changeme
# Git token service persisted-authorization disconnect
GIT_TOKEN_SERVICE_API_URL=http://localhost:8802
# Worker URLs (defaults shown, workers are optional)
CLOUD_AGENT_API_URL=http://localhost:8788
WEBHOOK_AGENT_URL=http://localhost:8793
Expand Down Expand Up @@ -131,6 +133,10 @@ NEXT_PUBLIC_SENTRY_DSN=
# Encryption keys (generate if needed)
BYOK_ENCRYPTION_KEY=
CREDIT_CATEGORIES_ENCRYPTION_KEY=
# Connected GitHub user token envelope encryption (dedicated RSA public key only in Web)
USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID=
# Base64-encoded PEM public key; keep the matching private key only in git-token-service
USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY=
# Agent environment vars encryption (RSA public key, base64 encoded)
AGENT_ENV_VARS_PUBLIC_KEY=
# User deployments
Expand Down
13 changes: 13 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,19 @@ The `@url` annotation accepts multiple comma-separated services (e.g., `# @url s

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

### RSA environment keypair generation

Generate a dedicated RSA keypair when one runtime encrypts environment-backed secrets and another runtime decrypts them:

```bash
pnpm exec tsx dev/generate-rsa-env-keypair.ts -- \
--out-dir <secure-output-dir> \
--public-env <PUBLIC_KEY_ENV> \
--private-env <PRIVATE_KEY_ENV>
```

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.

### Local Grafana (reads prod Analytics Engine)

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import { Button } from '@/components/ui/button';
import { CheckCircle2, XCircle, Clock, ArrowRight, GitBranch } from 'lucide-react';
import type { Platform } from '@/lib/integrations/platform-definitions';

interface PlatformCardProps {
export type GitHubIdentityStatus = 'connected' | 'revoked';

type PlatformCardProps = {
platform: Platform;
githubIdentityStatus?: GitHubIdentityStatus;
onNavigate?: (platformId: string) => void;
}
};

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

export function PlatformCard({ platform, onNavigate }: PlatformCardProps) {
const GitHubIdentityBadge = ({ status }: { status: GitHubIdentityStatus }) => {
if (status === 'connected') {
return (
<Badge variant="default" className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Identity connected
</Badge>
);
}

return (
<Badge variant="secondary" className="flex items-center gap-1">
<XCircle className="h-3 w-3" />
Reconnect identity
</Badge>
);
};

export function PlatformCard({ platform, githubIdentityStatus, onNavigate }: PlatformCardProps) {
const handleClick = () => {
if (platform.enabled && onNavigate) {
onNavigate(platform.id);
}
};

const description =
githubIdentityStatus === 'connected'
? platform.status === 'installed'
? 'Your GitHub identity is connected and personal repository access is set up.'
: 'Your GitHub identity is connected. Set up personal repository access here, or use access from an organization.'
: githubIdentityStatus === 'revoked'
? 'Reconnect your GitHub identity to let Cloud Agent act as you. Repository access is managed separately.'
: platform.description;

return (
<Card
className={`transition-all ${
Expand All @@ -64,16 +94,22 @@ export function PlatformCard({ platform, onNavigate }: PlatformCardProps) {
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<CardTitle>{platform.name}</CardTitle>
<StatusBadge status={platform.status} />
{githubIdentityStatus ? (
<GitHubIdentityBadge status={githubIdentityStatus} />
) : (
<StatusBadge status={platform.status} />
)}
</div>
<CardDescription className="mt-2">{platform.description}</CardDescription>
<CardDescription className="mt-2">{description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{platform.enabled ? (
<Button variant="outline" className="group w-full" onClick={handleClick}>
{platform.status === 'installed' ? 'Manage Integration' : 'Configure'}
{platform.status === 'installed' || githubIdentityStatus
? 'Manage Integration'
: 'Configure'}
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button>
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { NextRequest } from 'next/server';
import { createHash } from 'node:crypto';
import { NextResponse } from 'next/server';
import { captureException, captureMessage } from '@sentry/nextjs';
import { APP_URL } from '@/lib/constants';
import { getUserFromAuth } from '@/lib/user/server';
import { consumeGitHubUserAuthorizationState } from '@/lib/integrations/platforms/github/user-authorization-state';
import { exchangeAndStoreGitHubUserAuthorization } from '@/lib/integrations/platforms/github/user-authorization';

function redirectWithStatus(key: 'success' | 'error', value: string): NextResponse {
const target = new URL('/integrations/github', APP_URL);
target.searchParams.set(key, value);
return NextResponse.redirect(target);
}

function safeCallbackContext(searchParams: URLSearchParams) {
const state = searchParams.get('state');
return {
hasCode: Boolean(searchParams.get('code')),
hasState: Boolean(state),
stateHash: state ? createHash('sha256').update(state).digest('hex').slice(0, 8) : null,
providerError: searchParams.get('error'),
};
}

function validOAuthCode(code: string | null): string | null {
if (!code || code.length > 2048 || !/^[A-Za-z0-9._~+/-]+$/.test(code)) return null;
return code;
}

function logDevelopmentCallbackFailure(stage: string, searchParams: URLSearchParams): void {
if (process.env.NODE_ENV !== 'development') return;
const context = safeCallbackContext(searchParams);
console.error('[GitHub user authorization callback debug]', {
stage,
hasCode: context.hasCode,
hasState: context.hasState,
stateHash: context.stateHash,
hasProviderError: Boolean(context.providerError),
});
}

export async function GET(request: NextRequest) {
let stage = 'authenticate_user';
try {
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) {
return NextResponse.redirect(new URL('/users/sign_in', APP_URL));
}

const searchParams = request.nextUrl.searchParams;
if (searchParams.get('error')) {
return redirectWithStatus('error', 'authorization_cancelled');
}

stage = 'consume_state';
const state = await consumeGitHubUserAuthorizationState(searchParams.get('state'), user.id);
if (!state) {
captureMessage('GitHub user authorization callback invalid state', {
level: 'warning',
tags: { endpoint: 'github/user-connect/callback' },
extra: safeCallbackContext(searchParams),
});
return redirectWithStatus('error', 'invalid_state');
}

const code = validOAuthCode(searchParams.get('code'));
if (!code) {
return redirectWithStatus('error', 'missing_code');
}

stage = 'exchange_and_store_authorization';
const result = await exchangeAndStoreGitHubUserAuthorization({
kiloUserId: user.id,
code,
codeVerifier: state.codeVerifier,
});
if (result.status !== 'connected') {
return redirectWithStatus('error', result.status);
}

return redirectWithStatus('success', 'user_connected');
} catch (error) {
logDevelopmentCallbackFailure(stage, request.nextUrl.searchParams);
captureException(error, {
tags: { endpoint: 'github/user-connect/callback' },
extra: safeCallbackContext(request.nextUrl.searchParams),
});
return redirectWithStatus('error', 'connection_failed');
}
}
92 changes: 91 additions & 1 deletion apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAtom, useSetAtom } from 'jotai';
import { toast } from 'sonner';
Expand Down Expand Up @@ -89,6 +90,12 @@ import {
setLastUsedRepo,
setLastUsedVariant,
} from '@/components/cloud-agent-next/model-preferences';
import {
GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY,
getGitHubIdentityHint,
getGitHubIdentityHintDismissed,
markGitHubIdentityHintDismissed,
} from '@/components/cloud-agent-next/github-identity-hint';

type Repository = {
id: number;
Expand All @@ -102,6 +109,13 @@ type NewSessionPanelProps = {
isDevcontainerAvailable: boolean;
};

type ContextualTipProps = {
body: string;
linkLabel: string;
href: string;
onDismiss: () => void;
};

export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: NewSessionPanelProps) {
const router = useRouter();
const trpc = useTRPC();
Expand All @@ -111,6 +125,9 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
const fileInputRef = useRef<HTMLInputElement>(null);
const commandListRef = useRef<HTMLDivElement>(null);
const [devcontainer, setDevcontainer] = useState(false);
const [isGitHubIdentityHintDismissed, setIsGitHubIdentityHintDismissed] = useState<
boolean | null
>(null);
const { mutateAsync: personalUploadUrl } = useMutation(
trpc.cloudAgentNext.getAttachmentUploadUrl.mutationOptions()
);
Expand Down Expand Up @@ -173,6 +190,21 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
const [isPreparing, setIsPreparing] = useState(false);
const [attachmentMessageUuid, setAttachmentMessageUuid] = useState(() => crypto.randomUUID());

// ---------------------------------------------------------------------------
// GitHub identity awareness
// ---------------------------------------------------------------------------
const {
data: githubUserAuthorization,
isLoading: isGitHubUserAuthorizationLoading,
isError: isGitHubUserAuthorizationError,
} = useQuery({
...trpc.githubApps.getUserAuthorization.queryOptions(),
enabled:
isGitHubIdentityHintDismissed === false &&
selectedRepo.length > 0 &&
selectedPlatform === 'github',
});

const attachmentUpload = useCloudAgentAttachmentUpload({
messageUuid: attachmentMessageUuid,
organizationId,
Expand All @@ -197,6 +229,15 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New

useEffect(() => {
setDevcontainer(getDevcontainerEnabled());
setIsGitHubIdentityHintDismissed(getGitHubIdentityHintDismissed());

const handleGitHubIdentityHintStorage = (event: StorageEvent) => {
if (event.key === GITHUB_IDENTITY_HINT_DISMISSED_STORAGE_KEY && event.newValue === 'true') {
setIsGitHubIdentityHintDismissed(true);
}
};
window.addEventListener('storage', handleGitHubIdentityHintStorage);
return () => window.removeEventListener('storage', handleGitHubIdentityHintStorage);
}, []);

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

const githubIdentityHint = getGitHubIdentityHint({
selectedRepo,
selectedPlatform,
authorization: githubUserAuthorization,
isLoading: isGitHubUserAuthorizationLoading,
isError: isGitHubUserAuthorizationError,
isDismissed: isGitHubIdentityHintDismissed !== false,
});

const handleDismissGitHubIdentityHint = useCallback(() => {
markGitHubIdentityHintDismissed();
setIsGitHubIdentityHintDismissed(true);
}, []);

// ---------------------------------------------------------------------------
// Integration missing view
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1326,6 +1381,42 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
/>
</div>
</div>

{githubIdentityHint && (
<ContextualTip {...githubIdentityHint} onDismiss={handleDismissGitHubIdentityHint} />
)}
</div>
</div>
);
}

function ContextualTip({ body, linkLabel, href, onDismiss }: ContextualTipProps) {
return (
<div className="group/tip flex max-w-full justify-center text-center" role="status">
<div className="text-muted-foreground inline-flex max-w-full items-start justify-center gap-1 text-xs">
<span aria-hidden="true" className="invisible mr-1 shrink-0 px-1">
Dismiss
</span>
<span className="text-foreground font-medium">Tip:</span>
<span aria-hidden="true" className="text-border">
&middot;
</span>
<span className="min-w-0">
{body}{' '}
<Link
href={href}
className="text-blue-400 hover:text-blue-300 hover:underline focus-visible:underline"
>
{linkLabel}
</Link>
</span>
<button
type="button"
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"
onClick={onDismiss}
>
Dismiss
</button>
</div>
</div>
);
Expand All @@ -1334,7 +1425,6 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
// ---------------------------------------------------------------------------
// Internal sub-component for repo items in the Command list
// ---------------------------------------------------------------------------

function RepoCommandItem({
repo,
isSelected,
Expand Down
Loading
Loading