Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c2d45c0
feat(github): enable shared web agent installations
pandemicsyn Sep 8, 2026
bb189ad
fix(github): harden shared installation races
pandemicsyn Sep 8, 2026
9e78435
fix(github): serialize shared callback cleanup
pandemicsyn Sep 8, 2026
a3e2582
fix(integrations): fence provider state mutations
pandemicsyn Sep 8, 2026
9cde1c7
fix(integrations): serialize provider state sessions
pandemicsyn Sep 8, 2026
7c7f87c
fix(integrations): restore provider session consistency
pandemicsyn Sep 8, 2026
a7c3c79
Revert "fix(integrations): restore provider session consistency"
pandemicsyn Sep 8, 2026
66e2100
Revert "fix(integrations): serialize provider state sessions"
pandemicsyn Sep 8, 2026
4e43c9b
Revert "fix(integrations): fence provider state mutations"
pandemicsyn Sep 8, 2026
602cc9e
feat(integrations): reserve provider OAuth attempts
pandemicsyn Sep 8, 2026
6d96f55
fix(integrations): lock OAuth reservations by owner row
pandemicsyn Sep 8, 2026
5c2801a
fix(integrations): fence provider OAuth state purpose
pandemicsyn Sep 8, 2026
eec4f26
test(integrations): harden OAuth reservation races
pandemicsyn Sep 8, 2026
da98aa3
test(integrations): prove OAuth owner-row blocking
pandemicsyn Sep 8, 2026
b533c8c
fix(github): serialize verified owner cardinality
pandemicsyn Sep 9, 2026
1d4ad35
test(integrations): include OAuth state issue time
pandemicsyn Sep 9, 2026
d67f75b
chore(db): regenerate shared GitHub migration
pandemicsyn Sep 9, 2026
1d2f970
fix(github): close shared runtime isolation gaps
pandemicsyn Sep 9, 2026
b4b1dfa
fix(github): fence exclusive lifecycle retries
pandemicsyn Sep 9, 2026
50c41b1
fix(github): close legacy authorization edges
pandemicsyn Sep 9, 2026
3a4208f
fix(github): fence direct token legacy paths
pandemicsyn Sep 9, 2026
a518526
fix(github): retry in-progress lifecycle receipts
pandemicsyn Sep 9, 2026
a26013b
refactor(github): simplify lifecycle cleanup
pandemicsyn Sep 10, 2026
fe7651d
fix(github): align repository refresh lock order
pandemicsyn Sep 10, 2026
454bb92
test(github): prove lifecycle lock overlap
pandemicsyn Sep 10, 2026
3fe432d
test(github): match exact advisory lock query
pandemicsyn Sep 10, 2026
884f872
fix(github): bind bot links to tenant association
pandemicsyn Sep 10, 2026
af1734d
fix(github): constrain legacy bot-link fallback
pandemicsyn Sep 10, 2026
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
3 changes: 3 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `GITHUB_LITE_APP_PRIVATE_KEY` - Private key for the lite GitHub App. `[SECRET]`
- `GITHUB_LITE_APP_CLIENT_ID` - OAuth Client ID for the lite GitHub App install/login flow. [PUBLIC]
- `GITHUB_MULTIPLE_INSTALLATION_ORGANIZATION_IDS` - Comma-separated Kilo organization UUIDs allowed to connect multiple GitHub App installations. Unset or empty disables multiple installations for all organizations. [SERVER]
- `GITHUB_SHARED_INSTALLATION_ORGANIZATION_IDS` - Comma-separated destination Kilo organization UUIDs allowed to create an association to a GitHub App installation already associated elsewhere. Unset or empty disables new shared associations without revoking existing ones. [SERVER]
- `GITHUB_CONNECTION_MANAGEMENT_ENABLED` - Set to exact `true` to admit new existing-installation connection management and local disconnect. Unset or any other value keeps new management admission disabled without changing incumbent GitHub integration workflows. [SERVER]
- Keep disabled for at least one OAuth state TTL (10 minutes) after deploying reservation-aware callbacks so purpose-less states issued by the previous version can complete.
- During the migration-to-app promotion window, old pending-install callbacks may fail against the replaced pending indexes. Keep the window brief, monitor deploy health, and retry the GitHub connection after promotion completes.
- `PER_REPO_SETTINGS` - Set to exactly `true` to reveal the Repository Customizations UI (per-installation default AI model / PR review mode, plus per-repository overrides) on the GitHub integration settings pages, for both personal accounts and organizations. Defaults to disabled so the feature can ship dark. [SERVER]
- `GITHUB_ADMIN_STATS_TOKEN` - Token for admin GitHub API stats lookups; used in `apps/web/src/scripts/backfill-pr-author-github-ids.ts`. `[SECRET]`
- `GITHUB_CLI_PAT` - GitHub personal access token for `gh` CLI operations inside contractors; used in `services/gastown/container/src/process-manager.ts`. `[SECRET]`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe('GET /api/integrations/bitbucket/connect', () => {
owner: `org_${ORGANIZATION_ID}`,
userId: USER_ID,
returnTo: `/organizations/${ORGANIZATION_ID}/integrations/bitbucket`,
issuedAt: expect.any(Number),
});
expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith(
{ user: expect.objectContaining({ id: USER_ID }) },
Expand Down
134 changes: 106 additions & 28 deletions apps/web/src/app/api/integrations/github/callback/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { eq } from 'drizzle-orm';
import { randomUUID } from 'node:crypto';
import type { Owner } from '@/lib/integrations/core/types';
import {
findGitHubBotLinkIntegrations,
findIntegrationByInstallationId,
findIntegrationByInstallationIdForOwner,
upsertPlatformIntegrationForOwner,
Expand Down Expand Up @@ -85,6 +86,7 @@ jest.mock('@/routers/organizations/utils', () => ({
}));
jest.mock('@/lib/integrations/db/platform-integrations', () => ({
createPendingIntegration: jest.fn(),
findGitHubBotLinkIntegrations: jest.fn(),
findIntegrationByInstallationId: jest.fn(),
findIntegrationByInstallationIdForOwner: jest.fn(),
findPendingInstallationByRequesterId: jest.fn(),
Expand Down Expand Up @@ -113,6 +115,7 @@ const mockedExchangeGitHubOAuthCode = jest.mocked(exchangeGitHubOAuthCode);
const mockedLinkKiloUser = jest.mocked(linkKiloUser);
const mockedBot = jest.mocked(bot);
const mockedFindIntegrationByInstallationId = jest.mocked(findIntegrationByInstallationId);
const mockedFindGitHubBotLinkIntegrations = jest.mocked(findGitHubBotLinkIntegrations);
const mockedFindIntegrationByInstallationIdForOwner = jest.mocked(
findIntegrationByInstallationIdForOwner
);
Expand Down Expand Up @@ -160,6 +163,18 @@ beforeEach(() => {
github_app_type: 'standard',
}) as never
);
mockedFindGitHubBotLinkIntegrations.mockImplementation(
async () =>
[
{
id: '00000000-0000-4000-8000-000000000099',
owned_by_user_id: writtenOwner.type === 'user' ? writtenOwner.id : null,
owned_by_organization_id: writtenOwner.type === 'org' ? writtenOwner.id : null,
platform_installation_id: INSTALLATION_ID,
github_app_type: 'standard',
},
] as never
);
mockedFindIntegrationByInstallationIdForOwner.mockImplementation(
async () =>
({
Expand Down Expand Up @@ -197,6 +212,8 @@ function expectRedirectLocation(response: Response, expectedPathWithQuery: strin
describe('GET /api/integrations/github/callback bot link flow', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.GITHUB_CONNECTION_MANAGEMENT_ENABLED = 'false';
process.env.GITHUB_SHARED_INSTALLATION_ORGANIZATION_IDS = '';

mockedGetUserFromAuth.mockResolvedValue({
user: { id: USER_ID },
Expand All @@ -206,6 +223,7 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
platformIntegrationId: '00000000-0000-4000-8000-000000000099',
});
mockedExchangeGitHubOAuthCode.mockResolvedValue({
id: GITHUB_USER_ID,
Expand All @@ -218,6 +236,15 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
github_app_type: 'standard',
metadata: null,
} as never);
mockedFindGitHubBotLinkIntegrations.mockResolvedValue([
{
id: '00000000-0000-4000-8000-000000000099',
owned_by_organization_id: 'org_1',
owned_by_user_id: null,
platform_installation_id: INSTALLATION_ID,
github_app_type: 'standard',
} as never,
]);
mockedIsOrganizationMember.mockResolvedValue(true);
});

Expand Down Expand Up @@ -281,11 +308,11 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
await expect(response.text()).resolves.toContain(
'not a member of the organization that owns this GitHub integration'
);
expect(mockedFindIntegrationByInstallationId).toHaveBeenCalledWith(
'github',
INSTALLATION_ID,
'standard'
);
expect(mockedFindGitHubBotLinkIntegrations).toHaveBeenCalledWith({
installationId: INSTALLATION_ID,
appType: 'standard',
platformIntegrationId: '00000000-0000-4000-8000-000000000099',
});
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});
Expand All @@ -299,11 +326,11 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
expect(response.status).toBe(200);
await expect(response.text()).resolves.toContain('GitHub account octocat has been linked');
expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'standard');
expect(mockedFindIntegrationByInstallationId).toHaveBeenCalledWith(
'github',
INSTALLATION_ID,
'standard'
);
expect(mockedFindGitHubBotLinkIntegrations).toHaveBeenCalledWith({
installationId: INSTALLATION_ID,
appType: 'standard',
platformIntegrationId: '00000000-0000-4000-8000-000000000099',
});
expect(mockedIsOrganizationMember).toHaveBeenCalledWith('org_1', USER_ID);
expect(mockedBot.initialize).toHaveBeenCalled();
expect(mockedLinkKiloUser).toHaveBeenCalledWith(
Expand All @@ -318,27 +345,80 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
);
});

test('allows one healthy legacy no-ID state while management and sharing are disabled', async () => {
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
});
const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);
expect(response.status).toBe(200);
expect(mockedFindGitHubBotLinkIntegrations).toHaveBeenCalledWith({
installationId: INSTALLATION_ID,
appType: 'standard',
platformIntegrationId: undefined,
});
});

test('rejects a no-ID state after connection management is enabled', async () => {
process.env.GITHUB_CONNECTION_MANAGEMENT_ENABLED = 'true';
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
});
const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);
expect(response.status).toBe(404);
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
});

test('rejects a no-ID state after shared-installation admission is enabled', async () => {
process.env.GITHUB_SHARED_INSTALLATION_ORGANIZATION_IDS =
'00000000-0000-4000-8000-000000000001';
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
});
const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);
expect(response.status).toBe(404);
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
});

test('routes a Lite bot-link callback through the Lite app identity', async () => {
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
githubAppType: 'lite',
platformIntegrationId: '00000000-0000-4000-8000-000000000099',
});
mockedFindIntegrationByInstallationId.mockResolvedValue({
owned_by_organization_id: 'org_1',
github_app_type: 'lite',
} as never);
mockedFindGitHubBotLinkIntegrations.mockResolvedValue([
{
id: '00000000-0000-4000-8000-000000000099',
owned_by_organization_id: 'org_1',
github_app_type: 'lite',
} as never,
]);
const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);
expect(response.status).toBe(200);
expect(mockedFindIntegrationByInstallationId).toHaveBeenCalledWith(
'github',
INSTALLATION_ID,
'lite'
);
expect(mockedFindGitHubBotLinkIntegrations).toHaveBeenCalledWith({
installationId: INSTALLATION_ID,
appType: 'lite',
platformIntegrationId: '00000000-0000-4000-8000-000000000099',
});
expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'lite');
expect(mockedLinkKiloUser).toHaveBeenCalledWith(
mockState,
Expand All @@ -347,18 +427,16 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
);
});

test("exchanges the OAuth code against the integration's github_app_type", async () => {
mockedFindIntegrationByInstallationId.mockResolvedValue({
owned_by_organization_id: 'org_1',
owned_by_user_id: null,
github_app_type: 'lite',
metadata: null,
} as never);
test('rejects an integration whose app type does not match signed state', async () => {
mockedFindGitHubBotLinkIntegrations.mockResolvedValue([]);

const { GET } = await import('./route');
await GET(makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never);
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);

expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'lite');
expect(response.status).toBe(404);
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
});
});

Expand Down
28 changes: 21 additions & 7 deletions apps/web/src/app/api/integrations/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import { ensureOrganizationAccess } from '@/routers/organizations/utils';
import {
createPendingIntegration,
findIntegrationByInstallationId,
findGitHubBotLinkIntegrations,
findIntegrationByInstallationIdForOwner,
upsertPlatformIntegrationForOwner,
} from '@/lib/integrations/db/platform-integrations';
Expand All @@ -47,6 +47,7 @@ import {
type InstallStateRejectionReason,
} from '@/lib/integrations/github/install-state';
import { ORGANIZATION_MANAGE_ROLES } from '@kilocode/app-shared/organizations';
import { isGitHubSharedInstallationAdmissionEnabled } from '@/lib/integrations/github/multiple-installations';

const appendQueryParam = (path: string, queryParam: string): string =>
`${path}${path.includes('?') ? '&' : '?'}${queryParam}`;
Expand Down Expand Up @@ -102,11 +103,18 @@ async function handleGitHubBotLinkCallback(request: NextRequest, user: { id: str
}

const stateAppType = state.githubAppType ?? 'standard';
const integration = await findIntegrationByInstallationId(
PLATFORM.GITHUB,
state.installationId,
stateAppType
);
const usableCandidates = await findGitHubBotLinkIntegrations({
installationId: state.installationId,
appType: stateAppType,
platformIntegrationId: state.platformIntegrationId,
});
const legacyAllowed =
!isGitHubConnectionManagementEnabled() && !isGitHubSharedInstallationAdmissionEnabled();
const integration = state.platformIntegrationId
? usableCandidates[0]
: legacyAllowed && usableCandidates.length === 1
? usableCandidates[0]
: undefined;

if (!integration) {
return htmlPage('Link Failed', 'No matching GitHub integration was found.', 404);
Expand Down Expand Up @@ -677,7 +685,13 @@ async function handleCoreInstallFlow(params: {
const error =
upsertResult.reason === 'multiple_installations_disabled'
? 'multiple_installations_disabled'
: 'installation_already_claimed';
: upsertResult.reason === 'shared_installation_disabled'
? 'shared_installation_disabled'
: upsertResult.reason === 'incompatible_workflow'
? 'incompatible_workflow'
: upsertResult.reason === 'retryable_conflict'
? 'connection_temporarily_unavailable'
: 'installation_already_claimed';
if (isAppInitiated) {
return NextResponse.redirect(new URL(appFallbackPath(`error=${error}`), APP_URL));
}
Expand Down
Loading
Loading