Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
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
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
86 changes: 70 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,6 +165,7 @@ describe('spawnCloudAgentSession delegation', () => {
})
);
expect(prepareInput).not.toHaveProperty('images');
expect(prepareInput).not.toHaveProperty('githubToken');
for (const field of profileDerivedInlineFields) {
expect(prepareInput).not.toHaveProperty(field);
}
Expand All @@ -164,6 +181,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 @@ -224,7 +275,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
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,7 @@ export async function evaluateGitHubSharingCompatibility(
.where(
and(
or(...slackOwnerConditions),
inArray(platform_integrations.platform, [
PLATFORM.SLACK,
PLATFORM.DISCORD,
PLATFORM.LINEAR,
]),
inArray(platform_integrations.platform, [PLATFORM.DISCORD, PLATFORM.LINEAR]),
eq(platform_integrations.integration_status, INTEGRATION_STATUS.ACTIVE),
isNull(platform_integrations.suspended_at),
isNull(platform_integrations.auth_invalid_at)
Expand Down
52 changes: 50 additions & 2 deletions apps/web/src/lib/integrations/provider-oauth-attempts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cleanupDbForTest, db } from '@/lib/drizzle';
import { organizations, provider_oauth_attempts } from '@kilocode/db/schema';
import { eq, sql } from 'drizzle-orm';
import { organizations, platform_integrations, provider_oauth_attempts } from '@kilocode/db/schema';
import { and, eq, sql } from 'drizzle-orm';
import { insertTestUser } from '@/tests/helpers/user.helper';
import { createTestOrganization } from '@/tests/helpers/organization.helper';
import {
Expand Down Expand Up @@ -113,18 +113,66 @@ describe('provider OAuth attempts', () => {
await expect(
connectVerifiedGitHubInstallation({ type: 'org', id: organizationA.id }, github)
).resolves.toMatchObject({ ok: true });
await db.insert(platform_integrations).values({
owned_by_organization_id: organizationB.id,
platform: 'slack',
integration_type: 'oauth',
platform_installation_id: 'T_EXISTING',
platform_account_id: 'T_EXISTING',
integration_status: 'active',
installed_at: new Date().toISOString(),
});
await beginProviderOAuthAttempt({
actorUserId: destinationUser.id,
owner: { type: 'org', id: organizationB.id },
provider: 'slack',
state: 'state-started',
purpose: 'provider_install',
});
await expect(
connectVerifiedGitHubInstallation(
{ type: 'org', id: organizationB.id },
{ ...github, kiloUserId: destinationUser.id }
)
).resolves.toEqual({ ok: false, reason: 'incompatible_workflow' });
await cancelProviderOAuthAttempt({
actorUserId: destinationUser.id,
owner: { type: 'org', id: organizationB.id },
provider: 'slack',
state: 'state-started',
purpose: 'provider_install',
});
for (const platform of ['linear', 'discord']) {
await db.insert(platform_integrations).values({
owned_by_organization_id: organizationB.id,
platform,
integration_type: 'oauth',
platform_installation_id: `${platform}-existing`,
platform_account_id: `${platform}-existing`,
integration_status: 'active',
installed_at: new Date().toISOString(),
});
await expect(
connectVerifiedGitHubInstallation(
{ type: 'org', id: organizationB.id },
{ ...github, kiloUserId: destinationUser.id }
)
).resolves.toEqual({ ok: false, reason: 'incompatible_workflow' });
await db
.delete(platform_integrations)
.where(
and(
eq(platform_integrations.platform, platform),
eq(platform_integrations.owned_by_organization_id, organizationB.id)
)
);
}
await expect(
connectVerifiedGitHubInstallation(
{ type: 'org', id: organizationB.id },
{ ...github, kiloUserId: destinationUser.id }
)
).resolves.toMatchObject({ ok: true });
});

it('blocks provider start after shared GitHub attach', async () => {
Expand Down
Loading