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}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: GitHub install can start with an undefined user id

For personal onboarding, this builds user_${user?.id} even while useUser() is still loading. If the user clicks Install before the user query resolves, GitHub returns with state=user_undefined, and the callback rejects it because user.id !== 'undefined', sending the user back to / instead of completing installation. Gate the button/handler on user?.id for the personal path, or derive the owner id from already-authenticated server state.

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') {
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
37 changes: 21 additions & 16 deletions apps/web/src/app/api/integrations/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
IntegrationPermissions,
Owner,
} from '@/lib/integrations/core/types';
import { parseStateReturn } from '@/lib/integrations/validate-return-path';
import { captureException, captureMessage } from '@sentry/nextjs';

/**
Expand All @@ -40,23 +41,25 @@ 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');

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

// 3. Parse owner from state
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 @@ -172,7 +175,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 @@ -291,8 +294,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 @@ -302,7 +306,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 @@ -312,17 +316,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
17 changes: 16 additions & 1 deletion apps/web/src/components/gastown/MayorChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
trpc.gastown.getMayorStatus.queryKey({ townId }),
(old: { configured?: boolean; townId?: string; session?: { agentId?: string; sessionId?: string; status?: string; lastActivityAt?: string } } | undefined) => ({
...(old ?? {}),
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
17 changes: 16 additions & 1 deletion apps/web/src/components/gastown/TerminalBar.tsx
Original file line number Diff line number Diff line change
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(
trpc.gastown.getMayorStatus.queryKey({ townId }),
(old: { configured?: boolean; townId?: string; session?: { agentId?: string; sessionId?: string; status?: string; lastActivityAt?: string } } | undefined) => ({
...(old ?? {}),
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