Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
125 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
57f5e26
feat(slack): fence shared installation activation
pandemicsyn Sep 11, 2026
233a4d0
feat(slack): delegate with exact GitHub connection
pandemicsyn Sep 11, 2026
b9aeab1
fix(slack): make installation activation recoverable
pandemicsyn Sep 11, 2026
27d5944
fix(slack): resolve all-access GitHub repositories
pandemicsyn Sep 11, 2026
300d02a
fix(slack): retire expired installation generations
pandemicsyn Sep 11, 2026
296df67
test(slack): reject unhealthy all-access GitHub links
pandemicsyn Sep 11, 2026
1d9b194
fix(slack): preserve cleanup reservations until revoke
pandemicsyn Sep 11, 2026
0636b8f
fix(slack): clean verified legacy token metadata
pandemicsyn Sep 11, 2026
4357a03
fix(slack): retain cleanup identity barriers
pandemicsyn Sep 11, 2026
8db09b0
fix(slack): bridge Enterprise workspace identities
pandemicsyn Sep 11, 2026
439013f
fix(slack): order Enterprise identity aliases
pandemicsyn Sep 11, 2026
41538c3
fix(slack): fence alias and cleanup ordering
pandemicsyn Sep 11, 2026
89ac167
fix(slack): persist cleanup and alias progress
pandemicsyn Sep 11, 2026
4eab6e0
fix(slack): defer shared Enterprise Grid support
pandemicsyn Sep 11, 2026
31ac813
fix(slack): isolate shared workspace activation
pandemicsyn Sep 11, 2026
3bf72f0
fix(slack): fence workspace sharing conversion
pandemicsyn Sep 11, 2026
b567d09
fix(slack): validate workspace conversion state
pandemicsyn Sep 11, 2026
185270d
fix(slack): route shared and cleanup states exactly
pandemicsyn Sep 12, 2026
f386ae0
fix(slack): preserve unshared runtime boundaries
pandemicsyn Sep 12, 2026
fd6c560
fix(slack): enforce runtime health boundaries
pandemicsyn Sep 12, 2026
d8eecc6
fix(slack): reserve successful unshared workspaces
pandemicsyn Sep 12, 2026
31e4964
fix(slack): finalize SDK effects with generation CAS
pandemicsyn Sep 12, 2026
8ac1656
fix(slack): separate workspace and Enterprise cleanup
pandemicsyn Sep 12, 2026
bfe544e
fix(slack): terminalize legacy cleanup safely
pandemicsyn Sep 12, 2026
6d67a1e
fix(slack): release terminal cleanup tombstones
pandemicsyn Sep 12, 2026
9bfcd8d
fix(slack): isolate webhook maintenance failures
pandemicsyn Sep 12, 2026
bf4f256
fix(slack): exclude Enterprise installs from backfill
pandemicsyn Sep 12, 2026
06f0792
fix(slack): validate Enterprise IDs and reset health
pandemicsyn Sep 12, 2026
0440374
fix(slack): make Enterprise activation success-only
pandemicsyn Sep 12, 2026
0d98e23
fix(slack): reconstruct Enterprise runtime state
pandemicsyn Sep 12, 2026
365a247
Revert "fix(slack): reconstruct Enterprise runtime state"
pandemicsyn Sep 12, 2026
a9f3db9
Revert "fix(slack): make Enterprise activation success-only"
pandemicsyn Sep 12, 2026
53c6893
Revert "fix(slack): validate Enterprise IDs and reset health"
pandemicsyn Sep 12, 2026
7677da4
Revert "fix(slack): exclude Enterprise installs from backfill"
pandemicsyn Sep 12, 2026
12ff9d3
Revert "fix(slack): isolate webhook maintenance failures"
pandemicsyn Sep 12, 2026
f2213c8
Revert "fix(slack): release terminal cleanup tombstones"
pandemicsyn Sep 12, 2026
0bf885a
Revert "fix(slack): terminalize legacy cleanup safely"
pandemicsyn Sep 12, 2026
a4765e4
Revert "fix(slack): separate workspace and Enterprise cleanup"
pandemicsyn Sep 12, 2026
316a94b
Revert "fix(slack): finalize SDK effects with generation CAS"
pandemicsyn Sep 12, 2026
86e22b0
Revert "fix(slack): reserve successful unshared workspaces"
pandemicsyn Sep 12, 2026
96b12da
Revert "fix(slack): enforce runtime health boundaries"
pandemicsyn Sep 12, 2026
030bf07
Revert "fix(slack): preserve unshared runtime boundaries"
pandemicsyn Sep 12, 2026
16bf7d0
Revert "fix(slack): route shared and cleanup states exactly"
pandemicsyn Sep 12, 2026
e2b28a7
Revert "fix(slack): validate workspace conversion state"
pandemicsyn Sep 12, 2026
68449c5
Revert "fix(slack): fence workspace sharing conversion"
pandemicsyn Sep 12, 2026
c351966
Revert "fix(slack): isolate shared workspace activation"
pandemicsyn Sep 12, 2026
72d2f0f
Revert "fix(slack): defer shared Enterprise Grid support"
pandemicsyn Sep 12, 2026
8eca867
Revert "fix(slack): persist cleanup and alias progress"
pandemicsyn Sep 12, 2026
efd7202
Revert "fix(slack): fence alias and cleanup ordering"
pandemicsyn Sep 12, 2026
b6048b6
Revert "fix(slack): order Enterprise identity aliases"
pandemicsyn Sep 12, 2026
dc43dcc
Revert "fix(slack): bridge Enterprise workspace identities"
pandemicsyn Sep 12, 2026
e0b32fa
Revert "fix(slack): retain cleanup identity barriers"
pandemicsyn Sep 12, 2026
5993f0c
Revert "fix(slack): clean verified legacy token metadata"
pandemicsyn Sep 12, 2026
73856a5
Revert "fix(slack): preserve cleanup reservations until revoke"
pandemicsyn Sep 12, 2026
fb1b7ce
Revert "test(slack): reject unhealthy all-access GitHub links"
pandemicsyn Sep 12, 2026
4fd2f6d
Revert "fix(slack): retire expired installation generations"
pandemicsyn Sep 12, 2026
8c22ca7
Revert "fix(slack): resolve all-access GitHub repositories"
pandemicsyn Sep 12, 2026
d2bcb9e
Revert "fix(slack): make installation activation recoverable"
pandemicsyn Sep 12, 2026
6d824dc
Revert "feat(slack): fence shared installation activation"
pandemicsyn Sep 12, 2026
d617c5c
test(slack): verify minimal shared delegation
pandemicsyn Sep 12, 2026
3ea54c5
test(slack): preserve unsupported provider fences
pandemicsyn Sep 12, 2026
527ce8a
test(slack): tighten minimal sharing fixtures
pandemicsyn Sep 12, 2026
7a78158
fix(slack): validate cached GitHub repositories
pandemicsyn Sep 13, 2026
ec51eee
fix(db): reconcile orphans before validating github FK
pandemicsyn Sep 14, 2026
1158178
fix(github): only record webhook receipt on 2xx dispatch
pandemicsyn Sep 14, 2026
731e775
fix(integrations): supersede unexpired pending OAuth attempts
pandemicsyn Sep 14, 2026
242eb98
fix(cloud-agent): dedupe exact-duplicate repository entries
pandemicsyn Sep 14, 2026
296112d
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 14, 2026
0e1c8b1
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 14, 2026
50b7505
fix(slack): accept adapter-written repository cache
pandemicsyn Sep 14, 2026
09238be
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 14, 2026
015fd21
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 14, 2026
805e161
fix(github): harden shared delivery and repository validation
pandemicsyn Sep 14, 2026
12b0e56
fix(cloud-agent): repair personal GitHub session start and stale refresh
pandemicsyn Sep 14, 2026
866465c
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 14, 2026
85e48a8
fix(github): reclaim stale installation delivery claims
pandemicsyn Sep 14, 2026
4a7c23c
Merge remote-tracking branch 'origin/feat/github-connections-pr2' int…
pandemicsyn Sep 14, 2026
5e72145
fix(github): enforce webhook delivery-claim window
pandemicsyn Sep 14, 2026
9710652
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 14, 2026
5776bde
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 14, 2026
6b2e740
fix(db): make github FK migration crash-safe and guard orphan reconci…
pandemicsyn Sep 14, 2026
3dcd749
fix(db): make concurrent index builds in 0245 retry-safe
pandemicsyn Sep 14, 2026
79025d2
Merge PR2 into PR3 and renumber receipt migration to 0247
pandemicsyn Sep 14, 2026
1465240
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 14, 2026
a332616
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 14, 2026
169563f
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 15, 2026
616f690
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 15, 2026
81628ee
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 15, 2026
ea8d5ed
fix(github): restore exact-id shared runtime authorization lost in th…
pandemicsyn Sep 15, 2026
9e7a7ce
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 15, 2026
a2fb46e
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 15, 2026
3ae05d4
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 15, 2026
f352b0c
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 15, 2026
0f65542
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 15, 2026
d6b6b79
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 16, 2026
6a6e30a
Merge PR2 into PR3: adopt renumbered migrations 0245-0249
pandemicsyn Sep 16, 2026
45f15ad
Merge main: reconcile PR3 hardening with squash-merged PR2
pandemicsyn Sep 16, 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
7 changes: 7 additions & 0 deletions apps/web/src/app/api/webhooks/github-lite/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { NextRequest } from 'next/server';
import { handleGitHubWebhook } from '@/lib/integrations/platforms/github/webhook-handler';

/**
* Must stay strictly below GITHUB_INSTALLATION_DELIVERY_STALE_CLAIM_MS (10 minutes).
* The shared handler reclaims a `processing` delivery receipt once it is older than
* that window, so a dispatch allowed to run longer could be reclaimed and double-processed.
*/
export const maxDuration = 300;

/**
* GitHub Lite App Webhook Handler
*
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/app/api/webhooks/github/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ jest.mock('@/lib/integrations/github/runtime-authorization', () => ({
mockAssertRuntimeAuthorized(installationId, appType),
}));

import { POST } from './route';
import { maxDuration, POST } from './route';
import { maxDuration as liteMaxDuration } from '../github-lite/route';
import { GITHUB_INSTALLATION_DELIVERY_STALE_CLAIM_MS } from '@/lib/integrations/db/github-installations';
import { GitHubRuntimeAuthorizationError } from '@/lib/integrations/github/runtime-authorization';

function githubRequest(
Expand Down Expand Up @@ -143,3 +145,11 @@ describe('GitHub webhook route', () => {
expect(mockGithubWebhook).not.toHaveBeenCalled();
});
});

describe('GitHub webhook delivery-claim window', () => {
it('keeps every webhook route maxDuration below the stale-claim reclaim window', () => {
const staleWindowMs = GITHUB_INSTALLATION_DELIVERY_STALE_CLAIM_MS;
expect(maxDuration * 1000).toBeLessThan(staleWindowMs);
expect(liteMaxDuration * 1000).toBeLessThan(staleWindowMs);
});
});
7 changes: 7 additions & 0 deletions apps/web/src/app/api/webhooks/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ function getGitHubInstallationId(rawBody: string): string | null {
return null;
}

/**
* Must stay strictly below GITHUB_INSTALLATION_DELIVERY_STALE_CLAIM_MS (10 minutes).
* The shared handler reclaims a `processing` delivery receipt once it is older than
* that window, so a dispatch allowed to run longer could be reclaimed and double-processed.
*/
export const maxDuration = 300;

/**
* GitHub App Webhook Handler (Standard App)
*
Expand Down
94 changes: 78 additions & 16 deletions apps/web/src/lib/bot/tools/spawn-cloud-agent-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals
import type { PlatformIntegration } from '@kilocode/db';
import type { CloudAgentAttachments } from '@/lib/cloud-agent/constants';
import type { createCloudAgentNextClient as CreateCloudAgentNextClient } from '@/lib/cloud-agent-next/cloud-agent-client';
import type {
getGitHubTokenForOrganization as GetGitHubTokenForOrganization,
getGitHubTokenForUser as GetGitHubTokenForUser,
} from '@/lib/cloud-agent/github-integration-helpers';
import type {
buildGitLabCloneUrl as BuildGitLabCloneUrl,
getGitLabInstanceUrlForUser as GetGitLabInstanceUrlForUser,
getGitLabTokenForUser as GetGitLabTokenForUser,
} from '@/lib/cloud-agent/gitlab-integration-helpers';
import type { resolveModelForGitHubRepository as ResolveModelForGitHubRepository } from '@/lib/integrations/github-repository-settings';
import type { resolveGitHubRepositoryForOwner as ResolveGitHubRepositoryForOwner } from '@/lib/slack-bot/github-repository-context';
import type SpawnCloudAgentSession from './spawn-cloud-agent-session';

const mockGetGitHubIntegrationById =
jest.fn<(...args: unknown[]) => Promise<PlatformIntegration | null>>();

jest.mock('@/lib/config.server', () => ({
CALLBACK_TOKEN_SECRET: 'callback-secret',
}));
Expand All @@ -26,9 +26,12 @@ jest.mock('@/lib/cloud-agent-next/cloud-agent-client', () => ({
createCloudAgentNextClient: jest.fn(),
}));

jest.mock('@/lib/cloud-agent/github-integration-helpers', () => ({
getGitHubTokenForOrganization: jest.fn(),
getGitHubTokenForUser: jest.fn(),
jest.mock('@/lib/slack-bot/github-repository-context', () => ({
resolveGitHubRepositoryForOwner: jest.fn(),
}));

jest.mock('@/lib/integrations/db/platform-integrations', () => ({
getGitHubIntegrationById: (...args: unknown[]) => mockGetGitHubIntegrationById(...args),
}));

jest.mock('@/lib/cloud-agent/gitlab-integration-helpers', () => ({
Expand Down Expand Up @@ -73,8 +76,9 @@ const mockPrepareSession =
const mockInitiateFromPreparedSession = jest.fn<(input: unknown) => Promise<unknown>>();
let spawnCloudAgentSession: typeof SpawnCloudAgentSession;
let mockCreateCloudAgentNextClient: jest.MockedFunction<typeof CreateCloudAgentNextClient>;
let mockGetGitHubTokenForOrganization: jest.MockedFunction<typeof GetGitHubTokenForOrganization>;
let mockGetGitHubTokenForUser: jest.MockedFunction<typeof GetGitHubTokenForUser>;
let mockResolveGitHubRepositoryForOwner: jest.MockedFunction<
typeof ResolveGitHubRepositoryForOwner
>;
let mockGetGitLabTokenForUser: jest.MockedFunction<typeof GetGitLabTokenForUser>;
let mockGetGitLabInstanceUrlForUser: jest.MockedFunction<typeof GetGitLabInstanceUrlForUser>;
let mockBuildGitLabCloneUrl: jest.MockedFunction<typeof BuildGitLabCloneUrl>;
Expand All @@ -85,14 +89,15 @@ let mockResolveModelForGitHubRepository: jest.MockedFunction<
describe('spawnCloudAgentSession delegation', () => {
beforeAll(async () => {
const client = await import('@/lib/cloud-agent-next/cloud-agent-client');
const github = await import('@/lib/cloud-agent/github-integration-helpers');
const githubRepositoryContext = await import('@/lib/slack-bot/github-repository-context');
const gitlab = await import('@/lib/cloud-agent/gitlab-integration-helpers');
const repositorySettings = await import('@/lib/integrations/github-repository-settings');
const spawn = await import('./spawn-cloud-agent-session');

mockCreateCloudAgentNextClient = jest.mocked(client.createCloudAgentNextClient);
mockGetGitHubTokenForOrganization = jest.mocked(github.getGitHubTokenForOrganization);
mockGetGitHubTokenForUser = jest.mocked(github.getGitHubTokenForUser);
mockResolveGitHubRepositoryForOwner = jest.mocked(
githubRepositoryContext.resolveGitHubRepositoryForOwner
);
mockGetGitLabTokenForUser = jest.mocked(gitlab.getGitLabTokenForUser);
mockGetGitLabInstanceUrlForUser = jest.mocked(gitlab.getGitLabInstanceUrlForUser);
mockBuildGitLabCloneUrl = jest.mocked(gitlab.buildGitLabCloneUrl);
Expand All @@ -113,8 +118,19 @@ describe('spawnCloudAgentSession delegation', () => {
kiloSessionId: 'kilo-session-1',
});
mockInitiateFromPreparedSession.mockResolvedValue({});
mockGetGitHubTokenForOrganization.mockResolvedValue('organization-github-token');
mockGetGitHubTokenForUser.mockResolvedValue('github-token');
mockResolveGitHubRepositoryForOwner.mockResolvedValue({
id: 1,
name: 'repo',
full_name: 'owner/repo',
private: true,
githubIntegrationId: 'github-association-1',
githubAppType: 'standard',
});
mockGetGitHubIntegrationById.mockResolvedValue({
...userIntegration,
id: 'github-association-1',
repositories: [{ id: 1, name: 'repo', full_name: 'owner/repo', private: true }],
});
mockGetGitLabTokenForUser.mockResolvedValue('gitlab-token');
mockGetGitLabInstanceUrlForUser.mockResolvedValue('https://gitlab.com');
mockBuildGitLabCloneUrl.mockReturnValue('https://gitlab.com/group/repo.git');
Expand All @@ -138,7 +154,7 @@ describe('spawnCloudAgentSession delegation', () => {
expect(prepareInput).toEqual(
expect.objectContaining({
githubRepo: 'owner/repo',
githubToken: 'organization-github-token',
githubIntegrationId: 'github-association-1',
kilocodeOrganizationId: 'organization-1',
createdOnPlatform: 'slack',
attachments,
Expand All @@ -149,12 +165,17 @@ describe('spawnCloudAgentSession delegation', () => {
})
);
expect(prepareInput).not.toHaveProperty('images');
expect(prepareInput).not.toHaveProperty('githubToken');
for (const field of profileDerivedInlineFields) {
expect(prepareInput).not.toHaveProperty(field);
}
expect(mockCreateCloudAgentNextClient).toHaveBeenCalledWith('auth-token', {
skipBalanceCheck: true,
});
expect(mockGetGitHubIntegrationById).toHaveBeenCalledWith(
{ type: 'org', id: 'organization-1' },
'github-association-1'
);
expect(mockInitiateFromPreparedSession).toHaveBeenCalledWith({
cloudAgentSessionId: 'cloud-session-1',
});
Expand All @@ -164,6 +185,40 @@ describe('spawnCloudAgentSession delegation', () => {
});
});

it('rejects GitHub repositories outside the owner inventory', async () => {
mockResolveGitHubRepositoryForOwner.mockResolvedValue(null);

await expect(
spawnCloudAgentSession(
{ githubRepo: 'other/repo', prompt: 'Use the files', mode: 'code' },
'model',
organizationIntegration,
'auth-token',
'request-unknown'
)
).resolves.toEqual(
expect.objectContaining({ response: expect.stringContaining('not uniquely available') })
);
expect(mockPrepareSession).not.toHaveBeenCalled();
});

it('rejects a repository when its selected association is foreign to the owner', async () => {
mockGetGitHubIntegrationById.mockResolvedValue(null);

await expect(
spawnCloudAgentSession(
{ githubRepo: 'owner/repo', prompt: 'Inspect it', mode: 'code' },
'model',
organizationIntegration,
'auth-token',
'request-foreign'
)
).resolves.toEqual(
expect.objectContaining({ response: expect.stringContaining('no longer available') })
);
expect(mockPrepareSession).not.toHaveBeenCalled();
});

it('delegates GitLab profile resolution while preserving canonical repository context', async () => {
await spawnCloudAgentSession(
{ gitlabProject: 'group/repo', prompt: 'Use the files', mode: 'ask' },
Expand Down Expand Up @@ -208,6 +263,10 @@ describe('spawnCloudAgentSession delegation', () => {
expect(mockPrepareSession).toHaveBeenCalledWith(
expect.objectContaining({ createdOnPlatform: origin })
);
expect(mockGetGitHubIntegrationById).toHaveBeenCalledWith(
{ type: 'user', id: 'owner-1' },
'github-association-1'
);
}
);

Expand All @@ -224,7 +283,10 @@ describe('spawnCloudAgentSession delegation', () => {
{ chatPlatform: 'slack' }
);

expect(mockResolveModelForGitHubRepository).toHaveBeenCalledWith(userIntegration, 'owner/repo');
expect(mockResolveModelForGitHubRepository).toHaveBeenCalledWith(
expect.objectContaining({ id: 'github-association-1' }),
'owner/repo'
);
expect(mockPrepareSession).toHaveBeenCalledWith(
expect.objectContaining({ model: 'repo-override-model' })
);
Expand Down
61 changes: 27 additions & 34 deletions apps/web/src/lib/bot/tools/spawn-cloud-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ import {
type PrepareSessionInput,
} from '@/lib/cloud-agent-next/cloud-agent-client';
import type { RunSessionInput } from '@/lib/cloud-agent-next/run-session';
import {
getGitHubTokenForOrganization,
getGitHubTokenForUser,
} from '@/lib/cloud-agent/github-integration-helpers';
import {
getGitLabTokenForOrganization,
getGitLabTokenForUser,
Expand All @@ -27,6 +23,8 @@ import { captureException } from '@sentry/nextjs';
import type { PlatformIntegration } from '@kilocode/db';
import z from 'zod';
import { getBotUserId } from '@/lib/bot-users/bot-user-service';
import { resolveGitHubRepositoryForOwner } from '@/lib/slack-bot/github-repository-context';
import { getGitHubIntegrationById } from '@/lib/integrations/db/platform-integrations';

/**
* Derive a per-request callback token so the dedicated callback HMAC secret
Expand Down Expand Up @@ -184,52 +182,47 @@ export default async function spawnCloudAgentSession(
attachments: options?.attachments,
};
} else {
// GitHub path: get token, use githubRepo/githubToken
if (!args.githubRepo) {
// Unreachable given the guard above (one of githubRepo/gitlabProject
// is always set here), but keeps the repo-model lookup below type-safe.
return { response: 'Error: You must specify either a githubRepo or a gitlabProject.' };
}

// The token fetch and the per-repository model override lookup are
// independent of each other, so resolve them concurrently rather than
// paying for two sequential round trips.
const [githubToken, effectiveModel] = await Promise.all([
owner.type === 'org'
? getGitHubTokenForOrganization(owner.id)
: getGitHubTokenForUser(owner.id),
// A per-repository model override (`repository_customizations`) takes
// precedence over the installation-default `model` resolved earlier for
// this whole bot conversation — the repo is only known now that the LLM
// has picked one via this tool call. Guard this lookup independently so
// a customization-query failure falls back to the incoming `model`
// instead of aborting session creation entirely.
resolveModelForGitHubRepository(platformIntegration, args.githubRepo).catch(error => {
console.error(
'[KiloBot] Failed to resolve per-repository model override, falling back to installation model:',
error
);
captureException(error, {
tags: { component: 'kilo-bot', op: 'resolve-model-for-github-repository' },
extra: { botRequestId, githubRepo: args.githubRepo },
});
return model;
}),
]);

if (!githubToken) {
const repository = await resolveGitHubRepositoryForOwner(owner, args.githubRepo);
if (!repository) {
return {
response:
'Error: No GitHub token available. Please ensure a GitHub integration is connected in your Kilo Code settings.',
"Error: That GitHub repository is not uniquely available through this organization's approved GitHub connections.",
};
}

const githubIntegration = await getGitHubIntegrationById(owner, repository.githubIntegrationId);
if (!githubIntegration) {
return {
response: 'Error: That GitHub connection is no longer available to this Kilo organization.',
};
}
const effectiveModel = await resolveModelForGitHubRepository(
githubIntegration,
args.githubRepo
).catch(error => {
console.error(
'[KiloBot] Failed to resolve per-repository model override, falling back to installation model:',
error
);
captureException(error, {
tags: { component: 'kilo-bot', op: 'resolve-model-for-github-repository' },
extra: { botRequestId, githubRepo: args.githubRepo },
});
return model;
});

prepareInput = {
githubRepo: args.githubRepo,
prompt,
mode,
model: effectiveModel,
githubToken,
githubIntegrationId: repository.githubIntegrationId,
kilocodeOrganizationId,
createdOnPlatform: chatPlatform,
callbackTarget,
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/lib/integrations/core/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,30 @@
* Zod schemas for runtime validation of integration metadata
*/
import * as z from 'zod';
import type { PlatformRepository } from '@kilocode/db/schema-types';
import { PENDING_APPROVAL_STATUS } from './constants';

/**
* Persisted GitHub/GitLab repository inventory cached on platform integration rows.
* Unknown keys are stripped so forward-compatible cache fields do not invalidate the row.
*/
export const PlatformRepositoryCacheSchema = z
.array(
z.object({
id: z.number().int(),
name: z.string(),
full_name: z.string(),
private: z.boolean(),
default_branch: z.string().optional(),
})
)
.nullable();

export function parsePlatformRepositoryCache(value: unknown): PlatformRepository[] {
const parsed = PlatformRepositoryCacheSchema.safeParse(value ?? null);
return parsed.success ? (parsed.data ?? []) : [];
}

/**
* GitHub requester schema
*/
Expand Down
Loading