Skip to content
Open
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
22 changes: 18 additions & 4 deletions apps/web/src/app/(app)/gastown/onboarding/OnboardingStepRepo.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client';

import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'next/navigation';
import { toast } from 'sonner';
import { useTRPC } from '@/lib/trpc/utils';
import { useUser } from '@/hooks/useUser';
import { RepositoryCombobox, type RepositoryOption } from '@/components/shared/RepositoryCombobox';
Expand Down Expand Up @@ -63,11 +65,23 @@ export function OnboardingStepRepo() {
const githubAppName = process.env.NEXT_PUBLIC_GITHUB_APP_NAME || 'KiloConnect';

const handleInstallGithub = useCallback(() => {
const installState = orgId ? `org_${orgId}` : `user_${user?.id}`;
const installUrl = `https://github.com/apps/${githubAppName}/installations/new?state=${installState}`;
window.open(installUrl, '_blank', 'noopener');
const owner = orgId ? `org_${orgId}` : `user_${user?.id}`;
Comment thread
jrf0110 marked this conversation as resolved.
const returnPath = `/gastown/onboarding?step=repo${orgId ? `&orgId=${orgId}` : ''}`;
const state = `${owner}|return=${encodeURIComponent(returnPath)}`;
const installUrl = `https://github.com/apps/${githubAppName}/installations/new?state=${encodeURIComponent(state)}`;
window.location.href = installUrl;
}, [orgId, user?.id, githubAppName]);

const githubInstallParam = useSearchParams().get('github_install');
const { refetch: refetchGithubRepos } = githubReposQuery;

useEffect(() => {
if (githubInstallParam === 'success') {
void refetchGithubRepos();
toast.success('GitHub app installed. Select a repo to continue.');
}
}, [githubInstallParam, refetchGithubRepos]);

const handleRepoSelect = useCallback(
(fullName: string) => {
setSelectedRepoFullName(fullName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const STEPS = [

type StepKey = (typeof STEPS)[number]['key'];

const VALID_STEP_KEYS = new Set<string>(STEPS.map(s => s.key));

function StepIndicator({ currentIndex }: { currentIndex: number }) {
return (
<div className="flex items-center justify-center gap-0">
Expand Down Expand Up @@ -146,7 +148,16 @@ function CancelButton() {
function WizardContent() {
const searchParams = useSearchParams();
const orgId = searchParams.get('orgId');
const [currentStepKey, setCurrentStepKey] = useState<StepKey>('name');

const initialStep: StepKey = (() => {
const stepParam = searchParams.get('step');
if (stepParam && VALID_STEP_KEYS.has(stepParam)) {
return stepParam as StepKey;
}
return 'name';
})();

const [currentStepKey, setCurrentStepKey] = useState<StepKey>(initialStep);

const currentIndex = STEPS.findIndex(s => s.key === currentStepKey);

Expand Down
43 changes: 25 additions & 18 deletions apps/web/src/app/api/integrations/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
IntegrationPermissions,
Owner,
} from '@/lib/integrations/core/types';
import { parseStateReturn } from '@/lib/integrations/validate-return-path';
import { captureException, captureMessage } from '@sentry/nextjs';
import { verifyGitHubBotLinkState } from '@/lib/bot/github-link-state';
import { linkKiloUser } from '@/lib/bot-identity';
Expand Down Expand Up @@ -125,30 +126,34 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const installationId = searchParams.get('installation_id') ?? '';
const setupAction = searchParams.get('setup_action');
const state = searchParams.get('state'); // Contains owner info (org_ID or user_ID)
const rawState = searchParams.get('state');

if (!state?.startsWith('org_') && !state?.startsWith('user_')) {
const botLinkState = verifyGitHubBotLinkState(state);
// 3. Bot-link callback hand-off — runs BEFORE owner parsing because
// bot-link state values do not start with `org_`/`user_` and have a
// different signature (verifyGitHubBotLinkState).
if (rawState && !rawState.startsWith('org_') && !rawState.startsWith('user_')) {
const botLinkState = verifyGitHubBotLinkState(rawState);
if (botLinkState) {
return await handleGitHubBotLinkCallback(request, user);
}
}

// 3. Parse owner from state
// 4. Parse owner from state (with optional |return=<path> suffix)
const { ownerToken, returnTo } = parseStateReturn(rawState);
let owner: Owner;
let ownerId: string;

if (state?.startsWith('org_')) {
ownerId = state.replace('org_', '');
if (ownerToken.startsWith('org_')) {
ownerId = ownerToken.slice(4);
owner = { type: 'org', id: ownerId };
} else if (state?.startsWith('user_')) {
ownerId = state.replace('user_', '');
} else if (ownerToken.startsWith('user_')) {
ownerId = ownerToken.slice(5);
owner = { type: 'user', id: ownerId };
} else {
captureMessage('GitHub callback missing or invalid owner in state', {
level: 'warning',
tags: { endpoint: 'github/callback', source: 'github_app_installation' },
extra: { installationId, state, allParams: Object.fromEntries(searchParams.entries()) },
extra: { installationId, rawState, allParams: Object.fromEntries(searchParams.entries()) },
});
return NextResponse.redirect(new URL('/', request.url));
}
Expand Down Expand Up @@ -264,7 +269,7 @@ export async function GET(request: NextRequest) {
captureMessage('GitHub callback missing installation_id', {
level: 'warning',
tags: { endpoint: 'github/callback', source: 'github_app_installation' },
extra: { setupAction, state, allParams: Object.fromEntries(searchParams.entries()) },
extra: { setupAction, rawState, allParams: Object.fromEntries(searchParams.entries()) },
});

const redirectPath =
Expand Down Expand Up @@ -383,8 +388,9 @@ export async function GET(request: NextRequest) {
}

// 9. Redirect to success page
const successPath =
owner.type === 'org'
const successPath = returnTo
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}github_install=success`
: owner.type === 'org'
? `/organizations/${owner.id}/integrations/github?success=installed`
: `/integrations/github?success=installed`;

Expand All @@ -394,7 +400,7 @@ export async function GET(request: NextRequest) {

// Capture error to Sentry with context for debugging
const searchParams = request.nextUrl.searchParams;
const state = searchParams.get('state');
const rawState = searchParams.get('state');

captureException(error, {
tags: {
Expand All @@ -404,17 +410,18 @@ export async function GET(request: NextRequest) {
extra: {
installationId: searchParams.get('installation_id'),
setupAction: searchParams.get('setup_action'),
state,
rawState,
},
});

// Determine redirect path based on state parameter
const { ownerToken: errorOwnerToken } = parseStateReturn(rawState);

let redirectPath = '/?error=installation_failed';

if (state?.startsWith('org_')) {
const orgId = state.replace('org_', '');
if (errorOwnerToken.startsWith('org_')) {
const orgId = errorOwnerToken.slice(4);
redirectPath = `/organizations/${orgId}/integrations/github?error=installation_failed`;
} else if (state?.startsWith('user_')) {
} else if (errorOwnerToken.startsWith('user_')) {
redirectPath = `/integrations/github?error=installation_failed`;
}

Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/components/gastown/MayorChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useGastownTRPC } from '@/lib/gastown/trpc';
import { useGastownTRPC, type GastownOutputs } from '@/lib/gastown/trpc';
import { useSidebar } from '@/components/ui/sidebar';
import { ChevronDown, ChevronUp, Terminal as TerminalIcon } from 'lucide-react';
import { useXtermPty } from './useXtermPty';
Expand All @@ -22,7 +22,22 @@ export function MayorChat({ townId }: MayorChatProps) {
// Eagerly ensure mayor agent + container on mount
const ensureMayor = useMutation(
trpc.gastown.ensureMayor.mutationOptions({
onSuccess: () => {
onSuccess: data => {
queryClient.setQueryData<GastownOutputs['gastown']['getMayorStatus']>(
trpc.gastown.getMayorStatus.queryKey({ townId }),
(old): GastownOutputs['gastown']['getMayorStatus'] => ({
...(old ?? { configured: true, townId: null, session: null }),
configured: true,
townId,
session: {
...(old?.session ?? {}),
agentId: data.agentId,
sessionId: data.agentId,
status: data.sessionStatus,
lastActivityAt: old?.session?.lastActivityAt ?? new Date().toISOString(),
},
})
);
void queryClient.invalidateQueries({
queryKey: trpc.gastown.getMayorStatus.queryKey(),
});
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/components/gastown/TerminalBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useGastownTRPC, gastownWsUrl } from '@/lib/gastown/trpc';
import { useGastownTRPC, gastownWsUrl, type GastownOutputs } from '@/lib/gastown/trpc';

import { useSidebar } from '@/components/ui/sidebar';
import {
Expand Down Expand Up @@ -1319,7 +1319,22 @@ function MayorTerminalPane({ townId, collapsed }: { townId: string; collapsed: b

const ensureMayor = useMutation(
trpc.gastown.ensureMayor.mutationOptions({
onSuccess: () => {
onSuccess: data => {
queryClient.setQueryData<GastownOutputs['gastown']['getMayorStatus']>(
trpc.gastown.getMayorStatus.queryKey({ townId }),
(old): GastownOutputs['gastown']['getMayorStatus'] => ({
...(old ?? { configured: true, townId: null, session: null }),
configured: true,
townId,
session: {
...(old?.session ?? {}),
agentId: data.agentId,
sessionId: data.agentId,
status: data.sessionStatus,
lastActivityAt: old?.session?.lastActivityAt ?? new Date().toISOString(),
},
})
);
void queryClient.invalidateQueries({
queryKey: trpc.gastown.getMayorStatus.queryKey(),
});
Expand Down
110 changes: 110 additions & 0 deletions apps/web/src/lib/integrations/validate-return-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { validateReturnPath, parseStateReturn } from './validate-return-path';

describe('validateReturnPath', () => {
it('accepts a simple internal path', () => {
expect(validateReturnPath('/gastown/onboarding')).toBe('/gastown/onboarding');
});

it('accepts a path with query params', () => {
expect(validateReturnPath('/gastown/onboarding?step=repo&orgId=123')).toBe(
'/gastown/onboarding?step=repo&orgId=123'
);
});

it('rejects protocol-relative URLs', () => {
expect(validateReturnPath('//evil.com')).toBeNull();
});

it('rejects absolute URLs', () => {
expect(validateReturnPath('https://evil.com')).toBeNull();
});

it('rejects backslash-prefixed paths', () => {
expect(validateReturnPath('/\\evil.com')).toBeNull();
});

it('rejects paths with carriage return', () => {
expect(validateReturnPath('/foo\rbar')).toBeNull();
});

it('rejects paths with newline', () => {
expect(validateReturnPath('/foo\nbar')).toBeNull();
});

it('rejects paths without leading slash', () => {
expect(validateReturnPath('foo/bar')).toBeNull();
});

it('rejects empty string', () => {
expect(validateReturnPath('')).toBeNull();
});

it('accepts root path', () => {
expect(validateReturnPath('/')).toBe('/');
});

it('rejects triple-slash paths', () => {
expect(validateReturnPath('///foo')).toBeNull();
});
});

describe('parseStateReturn', () => {
it('parses state with return suffix', () => {
const encoded = encodeURIComponent('/gastown/onboarding?step=repo');
const result = parseStateReturn(`user_abc|return=${encoded}`);
expect(result).toEqual({
ownerToken: 'user_abc',
returnTo: '/gastown/onboarding?step=repo',
});
});

it('parses org state with return suffix', () => {
const encoded = encodeURIComponent('/gastown/onboarding?step=repo&orgId=123');
const result = parseStateReturn(`org_123|return=${encoded}`);
expect(result).toEqual({
ownerToken: 'org_123',
returnTo: '/gastown/onboarding?step=repo&orgId=123',
});
});

it('parses state without return suffix (backwards compat)', () => {
const result = parseStateReturn('user_abc');
expect(result).toEqual({
ownerToken: 'user_abc',
returnTo: null,
});
});

it('parses org state without return suffix', () => {
const result = parseStateReturn('org_123');
expect(result).toEqual({
ownerToken: 'org_123',
returnTo: null,
});
});

it('returns null returnTo when return path is invalid', () => {
const encoded = encodeURIComponent('//evil.com');
const result = parseStateReturn(`user_abc|return=${encoded}`);
expect(result).toEqual({
ownerToken: 'user_abc',
returnTo: null,
});
});

it('handles null state', () => {
const result = parseStateReturn(null);
expect(result).toEqual({
ownerToken: '',
returnTo: null,
});
});

it('returns null returnTo when return suffix has malformed percent-encoding', () => {
const result = parseStateReturn('user_abc|return=%ZZ');
expect(result).toEqual({
ownerToken: 'user_abc',
returnTo: null,
});
});
});
31 changes: 31 additions & 0 deletions apps/web/src/lib/integrations/validate-return-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const RETURN_PATH_RE = /^\/(?![/\\])[^\r\n]*$/;

export function validateReturnPath(candidate: string): string | null {
if (!RETURN_PATH_RE.test(candidate) || candidate.startsWith('//')) {
return null;
}
return candidate;
}

export function parseStateReturn(rawState: string | null): {
ownerToken: string;
returnTo: string | null;
} {
let ownerToken = rawState ?? '';
let returnTo: string | null = null;

if (rawState) {
const sepIdx = rawState.indexOf('|return=');
if (sepIdx !== -1) {
ownerToken = rawState.slice(0, sepIdx);
try {
const candidate = decodeURIComponent(rawState.slice(sepIdx + '|return='.length));
returnTo = validateReturnPath(candidate);
} catch {
returnTo = null;
}
}
}

return { ownerToken, returnTo };
}
4 changes: 4 additions & 0 deletions services/gastown/container/src/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,10 @@ async function verifyGitCredentials(
* kilo serve requires a git repo in the working directory, so we init
* a bare local repo with an empty initial commit.
*/
export function mayorWorkdirForTown(townId: string): string {
return `/workspace/rigs/mayor-${townId}/mayor-workspace`;
}

async function createLightweightWorkspace(label: string, rigId: string): Promise<string> {
const { mkdir: mkdirAsync } = await import('node:fs/promises');
const { existsSync } = await import('node:fs');
Expand Down
Loading
Loading