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
276 changes: 276 additions & 0 deletions .plans/cloud-agent-profile-skills-and-mcps.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@chat-adapter/state-redis": "^4.27.0",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@kilocode/cloud-agent-profile": "workspace:*",
"@kilocode/db": "workspace:*",
"@kilocode/encryption": "workspace:*",
"@kilocode/event-service": "workspace:*",
Expand Down
110 changes: 21 additions & 89 deletions apps/web/src/app/api/cloud-agent/sessions/prepare/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,13 @@ import {
} from '@/lib/cloud-agent/github-integration-helpers';
import { createCloudAgentClient } from '@/lib/cloud-agent/cloud-agent-client';
import { signStreamTicket } from '@/lib/cloud-agent/stream-ticket';
import {
mergeProfileConfiguration,
ProfileNotFoundError,
type MergeProfileConfigurationResult,
} from '@/lib/agent/profile-session-config';
import type { User } from '@kilocode/db/schema';

jest.mock('@/lib/user.server');
jest.mock('@/routers/organizations/utils');
jest.mock('@/lib/cloud-agent/github-integration-helpers');
jest.mock('@/lib/cloud-agent/cloud-agent-client');
jest.mock('@/lib/cloud-agent/stream-ticket');
jest.mock('@/lib/agent/profile-session-config', () => ({
mergeProfileConfiguration: jest.fn(),
ProfileNotFoundError: class ProfileNotFoundError extends Error {},
}));

const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedEnsureOrganizationAccess = jest.mocked(ensureOrganizationAccess);
Expand All @@ -41,7 +32,6 @@ const mockedValidateGitHubRepoAccessForOrganization = jest.mocked(
);
const mockedCreateCloudAgentClient = jest.mocked(createCloudAgentClient);
const mockedSignStreamTicket = jest.mocked(signStreamTicket);
const mockedMergeProfileConfiguration = jest.mocked(mergeProfileConfiguration);

function makeRequest(body: unknown) {
return new Request('http://localhost:3000/api/cloud-agent/sessions/prepare', {
Expand Down Expand Up @@ -132,11 +122,6 @@ describe('POST /api/cloud-agent/sessions/prepare', () => {
jest.resetAllMocks();
mockedValidateGitHubRepoAccessForUser.mockResolvedValue(true);
mockedValidateGitHubRepoAccessForOrganization.mockResolvedValue(true);
mockedMergeProfileConfiguration.mockResolvedValue({
envVars: undefined,
setupCommands: undefined,
encryptedSecrets: undefined,
});
mockedSignStreamTicket.mockReturnValue({ ticket: 'test-ticket', expiresAt: 1234567890 });
});

Expand Down Expand Up @@ -183,13 +168,14 @@ describe('POST /api/cloud-agent/sessions/prepare', () => {
expect(body.details).toContainEqual(expect.objectContaining({ path: 'prompt' }));
});

test('returns 400 when mode is invalid', async () => {
test('returns 400 when mode is not a valid slug', async () => {
setUserAuth();

const response = await POST(
makeRequest({
...validInput,
mode: 'invalid-mode',
// Uppercase + space — not a valid slug shape.
mode: 'Invalid Mode!',
})
);

Expand Down Expand Up @@ -554,12 +540,6 @@ describe('POST /api/cloud-agent/sessions/prepare', () => {
autoCommit: true,
};

mockedMergeProfileConfiguration.mockResolvedValueOnce({
envVars: inputWithOptionals.envVars,
setupCommands: inputWithOptionals.setupCommands,
encryptedSecrets: undefined,
});

await POST(makeRequest(inputWithOptionals));

expect(mockPrepareSession).toHaveBeenCalledWith(
Expand All @@ -578,58 +558,49 @@ describe('POST /api/cloud-agent/sessions/prepare', () => {
});
});

describe('profileName integration', () => {
test('merges profile configuration before calling cloud-agent', async () => {
const user = setUserAuth();
describe('profile forwarding', () => {
const profileId = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa';

test('forwards profileId and inline overrides to cloud-agent-next unchanged', async () => {
setUserAuth();
mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345');
const mockPrepareSession = jest.fn().mockResolvedValue({
kiloSessionId: '123e4567-e89b-12d3-a456-426614174000',
cloudAgentSessionId: 'cloud-session-123',
});
mockedCreateCloudAgentClient.mockReturnValue(createMockCloudAgentClient(mockPrepareSession));

const mergedConfig: MergeProfileConfigurationResult = {
envVars: { FROM_PROFILE: 'value' },
setupCommands: ['pnpm install'],
encryptedSecrets: undefined,
};
mockedMergeProfileConfiguration.mockResolvedValueOnce(mergedConfig);

await POST(
makeRequest({
...validInput,
profileName: 'My Default',
profileId,
envVars: { INLINE: 'value' },
setupCommands: ['echo inline'],
})
);

expect(mockedMergeProfileConfiguration).toHaveBeenCalledWith({
profileName: 'My Default',
owner: { type: 'user', id: user.id },
userId: undefined,
repoFullName: 'owner/repo',
platform: 'github',
envVars: { INLINE: 'value' },
setupCommands: ['echo inline'],
});

expect(mockPrepareSession).toHaveBeenCalledWith(
expect.objectContaining({
envVars: mergedConfig.envVars,
setupCommands: mergedConfig.setupCommands,
profileId,
envVars: { INLINE: 'value' },
setupCommands: ['echo inline'],
})
);
});

test('returns 404 when the profile cannot be found', async () => {
test('returns 404 when cloud-agent reports the profile is not found', async () => {
setUserAuth();
mockedMergeProfileConfiguration.mockRejectedValueOnce(new ProfileNotFoundError('Missing'));
mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345');
mockedCreateCloudAgentClient.mockReturnValue(
createMockCloudAgentClient(
jest.fn().mockRejectedValue(new Error(`Profile '${profileId}' not found`))
)
);

const response = await POST(
makeRequest({
...validInput,
profileName: 'Missing',
profileId,
})
);

Expand All @@ -638,46 +609,7 @@ describe('POST /api/cloud-agent/sessions/prepare', () => {
expect(body.error).toBe('Profile not found');
expect(body.details).toContainEqual(
expect.objectContaining({
path: 'profileName',
})
);
});

test('passes encryptedSecrets from profile to cloud-agent worker', async () => {
setUserAuth();
mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345');
const mockPrepareSession = jest.fn().mockResolvedValue({
kiloSessionId: '123e4567-e89b-12d3-a456-426614174000',
cloudAgentSessionId: 'cloud-session-123',
});
mockedCreateCloudAgentClient.mockReturnValue(createMockCloudAgentClient(mockPrepareSession));

const encryptedEnvelope = {
encryptedData: 'base64-encrypted-data',
encryptedDEK: 'base64-encrypted-dek',
algorithm: 'rsa-aes-256-gcm' as const,
version: 1 as const,
};

const mergedConfig: MergeProfileConfigurationResult = {
envVars: { PUBLIC_VAR: 'value' },
setupCommands: ['npm install'],
encryptedSecrets: { SECRET_KEY: encryptedEnvelope },
};
mockedMergeProfileConfiguration.mockResolvedValueOnce(mergedConfig);

await POST(
makeRequest({
...validInput,
profileName: 'production',
})
);

expect(mockPrepareSession).toHaveBeenCalledWith(
expect.objectContaining({
envVars: { PUBLIC_VAR: 'value' },
encryptedSecrets: { SECRET_KEY: encryptedEnvelope },
setupCommands: ['npm install'],
path: 'profileId',
})
);
});
Expand Down
78 changes: 25 additions & 53 deletions apps/web/src/app/api/cloud-agent/sessions/prepare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,7 @@ import { generateApiToken } from '@/lib/tokens';
import { publicPrepareSessionSchema } from './schema';
import { captureException } from '@sentry/nextjs';
import { TRPCError } from '@trpc/server';
import type { ProfileOwner } from '@/lib/agent/types';
import type { EncryptedEnvelope } from '@/lib/encryption';
import { signStreamTicket } from '@/lib/cloud-agent/stream-ticket';
import {
mergeProfileConfiguration,
ProfileNotFoundError,
} from '@/lib/agent/profile-session-config';
import { PLATFORM } from '@/lib/integrations/core/constants';

function handleTRPCError(error: unknown): NextResponse {
Expand Down Expand Up @@ -177,50 +171,6 @@ export async function POST(request: Request) {
);
}

let mergedEnvVars: Record<string, string> | undefined;
let mergedSetupCommands: string[] | undefined;
let encryptedSecrets: Record<string, EncryptedEnvelope> | undefined;

try {
const owner: ProfileOwner = input.organizationId
? { type: 'organization', id: input.organizationId }
: { type: 'user', id: user.id };

const repoFullName = input.githubRepo ?? input.gitlabProject;
const platform = input.gitlabProject ? PLATFORM.GITLAB : PLATFORM.GITHUB;

const merged = await mergeProfileConfiguration({
profileName: input.profileName,
owner,
// In org context, pass userId to enable fallback to personal profiles
userId: input.organizationId ? user.id : undefined,
repoFullName,
platform,
envVars: input.envVars,
setupCommands: input.setupCommands,
});

mergedEnvVars = merged.envVars;
mergedSetupCommands = merged.setupCommands;
encryptedSecrets = merged.encryptedSecrets;
} catch (error) {
if (error instanceof ProfileNotFoundError) {
return NextResponse.json(
{
error: 'Profile not found',
details: [
{
path: 'profileName',
message: error.message,
},
],
},
{ status: 404 }
);
}
throw error;
}

try {
const authToken = generateApiToken(user);
const client = createCloudAgentClient(authToken);
Expand All @@ -239,9 +189,12 @@ export async function POST(request: Request) {
platform: input.gitlabProject ? PLATFORM.GITLAB : PLATFORM.GITHUB,
// Common params
kilocodeOrganizationId,
envVars: mergedEnvVars,
encryptedSecrets,
setupCommands: mergedSetupCommands,
// Profile resolution happens in cloud-agent-next — forward profileId
// and any inline overrides. CAN merges profile-derived values with
// the inline fields using the same precedence the web used to apply.
profileId: input.profileId,
envVars: input.envVars,
setupCommands: input.setupCommands,
mcpServers: input.mcpServers,
autoCommit: input.autoCommit,
upstreamBranch: input.upstreamBranch,
Expand All @@ -260,6 +213,25 @@ export async function POST(request: Request) {
...ticketResult,
});
} catch (error) {
// Profile resolution failures are surfaced by CAN as 404s. Forward them
// through without mapping to a generic "Failed to prepare session"
// response so the caller sees the same shape we used before this
// refactor.
if (error instanceof Error && /Profile '.+' not found/i.test(error.message)) {
return NextResponse.json(
{
error: 'Profile not found',
details: [
{
path: 'profileId',
message: error.message,
},
],
},
{ status: 404 }
);
}

captureException(error, {
tags: { source: 'cloud-agent-prepare-session', step: 'forward-to-cloud-agent' },
extra: {
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/app/api/cloud-agent/sessions/prepare/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ export const publicPrepareSessionSchema = z
organizationId: z.string().uuid('Invalid organization ID format').optional(),

// Optional environment profile
// If provided, envVars and setupCommands from the profile will be used.
// Any inline envVars/setupCommands will be merged (inline takes precedence).
// If organizationId is provided, looks up org profile; otherwise looks up user profile.
profileName: z.string().max(100, 'Profile name must be at most 100 characters').optional(),
// If provided, envVars/setupCommands/MCP servers/skills/secrets from the
// profile are merged with inline values (inline takes precedence).
// When omitted, the effective default profile for the caller is used
// (org default wins over personal default in an org context).
profileId: z.string().uuid('Invalid profile ID format').optional(),

// Optional configuration
envVars: z
Expand Down
Loading
Loading