diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index e56eeab910..74c10df09d 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -11,9 +11,15 @@ import { getSandboxProvider, parseSessionMetadata, serializeSessionMetadata, + preserveResolvedRepositoryIdentity, + withResolvedRepositoryIdentity, type SessionMetadata, } from './session-metadata.js'; import { readProfileBundle, type SessionProfileBundle } from '../session-profile.js'; +import type { + ResolvedRepositoryIdentity, + SessionRepositoryRequest, +} from '../session/session-requests.js'; import { fitCallbackJobToQueueLimit } from '../callbacks/queue-payload.js'; import type { CallbackJob, CallbackTarget } from '../callbacks/index.js'; import { projectTerminalClientError } from '../session/terminal-error-projector.js'; @@ -253,32 +259,7 @@ type GroupedRegisterSessionInput = { agent: AgentSelection & { appendSystemPrompt?: string; }; - repository?: - | { - type: 'github'; - repo: string; - githubIntegrationId?: string; - branch?: string; - } - | { - type: 'gitlab'; - url: string; - branch?: string; - } - | { - type: 'bitbucket'; - url: string; - workspaceUuid: string; - repositoryUuid: string; - bitbucketIntegrationId?: string; - branch?: string; - } - | { - type: 'git'; - url: string; - token?: string; - branch?: string; - }; + repository?: SessionRepositoryRequest; profile?: SessionProfileBundle; finalization?: SessionFinalization; callback?: SessionMetadata['callback']; @@ -314,13 +295,18 @@ function repositoryMetadataFromRegistrationInput( ? { githubIntegrationId: repository.githubIntegrationId } : {}), upstreamBranch: repository.branch, + ...(repository.resolvedIdentity ? { resolvedIdentity: repository.resolvedIdentity } : {}), }; case 'gitlab': return { type: 'gitlab', url: repository.url, + ...(repository.gitlabIntegrationId + ? { gitlabIntegrationId: repository.gitlabIntegrationId } + : {}), platform: 'gitlab', upstreamBranch: repository.branch, + ...(repository.resolvedIdentity ? { resolvedIdentity: repository.resolvedIdentity } : {}), }; case 'bitbucket': return { @@ -331,6 +317,7 @@ function repositoryMetadataFromRegistrationInput( repositoryUuid: repository.repositoryUuid, bitbucketIntegrationId: repository.bitbucketIntegrationId, upstreamBranch: repository.branch, + ...(repository.resolvedIdentity ? { resolvedIdentity: repository.resolvedIdentity } : {}), }; case 'git': return { @@ -338,6 +325,7 @@ function repositoryMetadataFromRegistrationInput( url: repository.url, token: repository.token, upstreamBranch: repository.branch, + ...(repository.resolvedIdentity ? { resolvedIdentity: repository.resolvedIdentity } : {}), }; } @@ -390,6 +378,13 @@ function isSameRegistrationRepository( const submitted = input.repository; if (!stored || !submitted) return stored === undefined && submitted === undefined; if (stored.type !== submitted.type) return false; + if (submitted.resolvedIdentity) { + try { + withResolvedRepositoryIdentity(metadata, submitted.resolvedIdentity); + } catch { + return false; + } + } switch (submitted.type) { case 'github': @@ -403,6 +398,7 @@ function isSameRegistrationRepository( return ( stored.type === 'gitlab' && stored.url === submitted.url && + stored.gitlabIntegrationId === submitted.gitlabIntegrationId && stored.upstreamBranch === submitted.branch ); case 'git': @@ -1723,9 +1719,10 @@ export class CloudAgentSession extends DurableObject { if (await this.hasDeletionIntent()) { throw new Error('Cannot update deleted session metadata'); } - const newMetadata = serializeSessionMetadata(parseSessionMetadata(data)); + let newMetadata = serializeSessionMetadata(parseSessionMetadata(data)); const existingMetadata = await this.getMetadata(); if (existingMetadata) { + newMetadata = preserveResolvedRepositoryIdentity(existingMetadata, newMetadata); if (getSandboxProvider(existingMetadata) !== getSandboxProvider(newMetadata)) { throw new Error('Registered sandbox provider cannot be changed'); } @@ -1749,6 +1746,39 @@ export class CloudAgentSession extends DurableObject { await this.updateLastActivity(); } + async updateResolvedRepositoryIdentity( + expected: Pick, + identity: ResolvedRepositoryIdentity + ): Promise> { + // Keep the lookup snapshot as a guard, never as the metadata to write. + // Only storage I/O belongs in this transaction; credentials are resolved by the caller. + return this.ctx.storage.transaction(async transaction => { + if ( + (await transaction.get(DELETION_INTENT_KEY)) !== undefined || + (await transaction.get(VERCEL_DELETION_TOMBSTONE_KEY)) !== undefined + ) { + throw new Error('Cannot update deleted session metadata'); + } + const raw = await transaction.get('metadata'); + if (!raw) throw new Error('Cannot update repository identity: session metadata not found'); + const current = parseSessionMetadata(raw); + if (current.identity.sessionId !== expected.identity.sessionId) { + throw new Error('Repository identity cannot change'); + } + const authorized = withResolvedRepositoryIdentity( + { ...current, identity: expected.identity, repository: expected.repository }, + identity + ); + const updated = preserveResolvedRepositoryIdentity(authorized, current); + await transaction.put('metadata', serializeSessionMetadata(updated)); + return { + repository: updated.repository, + workspace: updated.workspace, + lifecycle: updated.lifecycle, + }; + }); + } + /** * Mark this session as interrupted. * Used to signal streaming generators to stop when interruptSession is called. diff --git a/services/cloud-agent-next/src/persistence/session-metadata.test.ts b/services/cloud-agent-next/src/persistence/session-metadata.test.ts index 58eceab7f3..6e04e1b2bd 100644 --- a/services/cloud-agent-next/src/persistence/session-metadata.test.ts +++ b/services/cloud-agent-next/src/persistence/session-metadata.test.ts @@ -1,4 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { CloudAgentSession } from './CloudAgentSession.js'; +import { prepareInputToSessionCreateRequest } from '../router/handlers/session-prepare.js'; +import { startInputToSessionCreateRequest } from '../router/handlers/session-start.js'; +import { PrepareSessionInput, StartSessionInput } from '../router/schemas.js'; +import { normalizeRepositoryIdentity } from '../session/session-requests.js'; + +vi.mock('cloudflare:workers', () => ({ DurableObject: class DurableObject {} })); +vi.mock('@cloudflare/sandbox', () => ({ + Sandbox: class Sandbox {}, + getSandbox: vi.fn(), + ContainerProxy: class ContainerProxy {}, +})); +vi.mock('@cloudflare/containers', () => ({})); +vi.mock('../../drizzle/migrations', () => ({ default: { journal: {}, migrations: {} } })); import { CurrentSessionMetadataSchema, @@ -8,8 +22,203 @@ import { requiresContainmentSandbox, serializeSessionMetadata, updateProviderRuntime, + preserveResolvedRepositoryIdentity, + withResolvedRepositoryIdentity, } from './session-metadata.js'; +function createMetadataSession(beforeTransaction?: () => Promise) { + const storage = new Map(); + const session = Object.create(CloudAgentSession.prototype) as CloudAgentSession; + Object.assign(session, { + ctx: { + storage: { + get: async (key: string) => structuredClone(storage.get(key)), + put: async (key: string, value: unknown) => { + storage.set(key, structuredClone(value)); + }, + transaction: async ( + run: (transaction: { + get: (key: string) => Promise; + put: (key: string, value: unknown) => Promise; + }) => Promise + ) => { + await beforeTransaction?.(); + const pending = structuredClone(storage); + const result = await run({ + get: async key => structuredClone(pending.get(key)), + put: async (key, value) => { + pending.set(key, structuredClone(value)); + }, + }); + for (const [key, value] of pending) storage.set(key, value); + return result; + }, + }, + }, + requireSessionId: async () => 'agent_identity', + hasDeletionIntent: async () => false, + updateLastActivity: async () => {}, + ensureAlarmScheduled: async () => {}, + getMetadata: async () => { + const value = storage.get('metadata'); + return value ? parseSessionMetadata(value) : null; + }, + getSessionMessageQueue: () => ({ + admitAcceptedMessage: async ({ turn }: { turn: { messageId: string } }) => ({ + success: true, + outcome: 'queued', + compatibilityDelivery: 'queued', + messageId: turn.messageId, + }), + }), + }); + return session; +} + +describe('adapters to Durable Object persistence', () => { + const pin = '123e4567-e89b-12d3-a456-426614174022'; + const providers = [ + { type: 'github' as const, repo: 'group/repo', githubIntegrationId: pin }, + { + type: 'gitlab' as const, + url: 'https://gitlab.example.com/gitlab/group/sub/repo.git', + gitlabIntegrationId: pin, + }, + { + type: 'bitbucket' as const, + url: 'https://bitbucket.org/group/repo.git', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + bitbucketIntegrationId: pin, + }, + ]; + it.each( + providers.flatMap(repository => ['prepare', 'start'].map(adapter => ({ adapter, repository }))) + )( + 'persists $adapter $repository.type pins and rejects changed admission', + async ({ adapter, repository }) => { + const branch = 'release/selected'; + const request = + adapter === 'start' + ? startInputToSessionCreateRequest( + StartSessionInput.parse({ + message: { prompt: 'Test' }, + agent: { mode: 'code', model: 'claude-3' }, + repository: { ...repository, branch }, + }) + ) + : prepareInputToSessionCreateRequest( + PrepareSessionInput.parse({ + prompt: 'Test', + mode: 'code', + model: 'claude-3', + upstreamBranch: branch, + githubToken: 'must-not-persist', + gitToken: 'must-not-persist', + ...(repository.type === 'github' + ? { + githubRepo: repository.repo, + githubIntegrationId: repository.githubIntegrationId, + } + : repository.type === 'gitlab' + ? { + platform: 'gitlab', + gitUrl: repository.url, + gitlabIntegrationId: repository.gitlabIntegrationId, + } + : { + platform: 'bitbucket', + gitUrl: repository.url, + bitbucketIntegrationId: repository.bitbucketIntegrationId, + bitbucketWorkspaceUuid: repository.workspaceUuid, + bitbucketRepositoryUuid: repository.repositoryUuid, + }), + }) + ); + const resolvedIdentity = { + kind: 'resolved' as const, + integrationId: pin, + integrationOwner: { type: 'org' as const, id: 'org-1' }, + instanceUrl: + repository.type === 'github' + ? 'https://github.com' + : repository.type === 'gitlab' + ? 'https://gitlab.example.com/gitlab' + : 'https://bitbucket.org', + }; + const session = createMetadataSession(); + const command = { + identity: { sessionId: 'agent_identity', userId: 'oauth/user', orgId: 'org-1' }, + auth: {}, + agent: request.agent, + repository: { ...request.repository, resolvedIdentity }, + message: { + initialTurn: { + type: 'prompt' as const, + messageId: 'msg_018f1e2d3c4bAbCdEfGhIjKlMn', + prompt: 'Test', + }, + }, + }; + expect(await session.createSessionWithInitialAdmission(command)).toMatchObject({ + success: true, + }); + const metadata = await session.getMetadata(); + expect(metadata?.repository).toMatchObject({ + ...repository, + upstreamBranch: branch, + resolvedIdentity, + }); + expect(JSON.stringify(metadata)).not.toContain('must-not-persist'); + expect(await session.createSessionWithInitialAdmission(command)).toMatchObject({ + success: true, + }); + expect( + await session.createSessionWithInitialAdmission({ + ...command, + repository: { ...command.repository, branch: 'release/different' }, + }) + ).toMatchObject({ success: false, code: 'BAD_REQUEST' }); + const changedPin = + repository.type === 'github' + ? { githubIntegrationId: '123e4567-e89b-12d3-a456-426614174099' } + : repository.type === 'gitlab' + ? { gitlabIntegrationId: '123e4567-e89b-12d3-a456-426614174099' } + : { bitbucketIntegrationId: '123e4567-e89b-12d3-a456-426614174099' }; + expect( + await session.createSessionWithInitialAdmission({ + ...command, + repository: { ...command.repository, ...changedPin }, + }) + ).toMatchObject({ success: false, code: 'BAD_REQUEST' }); + expect( + await session.createSessionWithInitialAdmission({ + ...command, + repository: { + ...command.repository, + resolvedIdentity: { ...resolvedIdentity, integrationId: 'another-integration' }, + }, + }) + ).toMatchObject({ success: false, code: 'BAD_REQUEST' }); + if (!metadata?.repository) throw new Error('Expected persisted repository'); + const { resolvedIdentity: _resolved, ...legacyRepository } = metadata.repository; + await session.updateMetadata({ ...metadata, repository: legacyRepository }); + expect(normalizeRepositoryIdentity((await session.getMetadata())?.repository ?? {})).toEqual( + resolvedIdentity + ); + const cloneSession = createMetadataSession(); + const clone = { cloneFromKiloSessionId: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa' }; + const { message: _initialMessage, ...registration } = command; + expect(await cloneSession.registerSession({ ...registration, clone })).toEqual({ + success: true, + }); + const clonedMetadata = await cloneSession.getMetadata(); + expect(clonedMetadata).toMatchObject({ clone, repository: metadata.repository }); + expect(clonedMetadata).not.toHaveProperty('initialMessage'); + } + ); +}); + const callbackTarget = { url: 'https://example.com/callback', headers: { 'X-Test': '1' }, @@ -27,6 +236,291 @@ const profile = { ], }; +describe('late resolution on legacy admission', () => { + it('replays the original request after resolution without changing its caller pin', async () => { + const session = createMetadataSession(); + const command = { + identity: { sessionId: 'agent_identity', userId: 'oauth/user' }, + auth: {}, + agent: { mode: 'code', model: 'claude-3' }, + repository: { type: 'github' as const, repo: 'group/repo', branch: 'release/selected' }, + message: { + initialTurn: { + type: 'prompt' as const, + messageId: 'msg_018f1e2d3c4bAbCdEfGhIjKlMn', + prompt: 'Test', + }, + }, + }; + await session.createSessionWithInitialAdmission(command); + const metadata = await session.getMetadata(); + if (!metadata) throw new Error('Expected registration'); + const resolvedIdentity = { + kind: 'resolved' as const, + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user' as const, id: 'oauth/user' }, + instanceUrl: 'https://github.com', + }; + await session.updateMetadata(withResolvedRepositoryIdentity(metadata, resolvedIdentity)); + expect(await session.createSessionWithInitialAdmission(command)).toMatchObject({ + success: true, + }); + expect((await session.getMetadata())?.repository).toMatchObject({ + repo: 'group/repo', + upstreamBranch: 'release/selected', + resolvedIdentity, + }); + expect((await session.getMetadata())?.repository).not.toHaveProperty('githubIntegrationId'); + }); +}); + +describe('durable repository resolution', () => { + const resolvedIdentity = { + kind: 'resolved' as const, + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user' as const, id: 'oauth/user' }, + instanceUrl: 'https://gitlab.example.com/gitlab', + }; + const base = { + metadataSchemaVersion: 2 as const, + identity: { sessionId: 'agent_identity', userId: 'oauth/user', orgId: 'billing-org' }, + auth: {}, + lifecycle: { version: 1, timestamp: 1 }, + }; + + it.each([ + { + type: 'github' as const, + repo: 'group/repo', + githubIntegrationId: resolvedIdentity.integrationId, + }, + { + type: 'gitlab' as const, + url: 'https://gitlab.example.com/gitlab/group/sub/repo.git', + gitlabIntegrationId: resolvedIdentity.integrationId, + }, + { + type: 'bitbucket' as const, + url: 'https://bitbucket.org/group/repo.git', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + bitbucketIntegrationId: resolvedIdentity.integrationId, + }, + ])('round-trips the $type resolution independently of session ownership', repository => { + const metadata = parseSessionMetadata({ + ...base, + repository: { ...repository, upstreamBranch: 'release/selected' }, + }); + const pinned = withResolvedRepositoryIdentity(metadata, resolvedIdentity); + expect(parseSessionMetadata(serializeSessionMetadata(pinned)).repository).toEqual({ + ...repository, + upstreamBranch: 'release/selected', + resolvedIdentity, + }); + expect(pinned.identity.orgId).toBe('billing-org'); + expect( + preserveResolvedRepositoryIdentity(pinned, metadata).repository?.resolvedIdentity + ).toEqual(resolvedIdentity); + }); + + it.each([ + { integrationId: '123e4567-e89b-12d3-a456-426614174099' }, + { integrationOwner: { type: 'org' as const, id: 'another-owner' } }, + { instanceUrl: 'https://other.example.com/gitlab' }, + ])('rejects resolved identity replacement %j', change => { + const metadata = withResolvedRepositoryIdentity( + parseSessionMetadata({ + ...base, + repository: { type: 'gitlab', url: 'https://gitlab.example.com/gitlab/group/sub/repo.git' }, + }), + resolvedIdentity + ); + expect(() => + withResolvedRepositoryIdentity(metadata, { ...resolvedIdentity, ...change }) + ).toThrow('Repository identity cannot change'); + }); + + it('rejects a resolved integration that differs from the caller pin', () => { + const metadata = parseSessionMetadata({ + ...base, + repository: { + type: 'gitlab', + url: 'https://gitlab.example.com/gitlab/group/sub/repo.git', + gitlabIntegrationId: '123e4567-e89b-12d3-a456-426614174099', + }, + }); + expect(() => withResolvedRepositoryIdentity(metadata, resolvedIdentity)).toThrow( + 'Repository identity cannot change' + ); + }); +}); + +describe('Durable Object repository identity merge', () => { + const resolvedIdentity = { + kind: 'resolved' as const, + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user' as const, id: 'oauth/user' }, + instanceUrl: 'https://github.com', + }; + const snapshot = parseSessionMetadata({ + metadataSchemaVersion: 2, + identity: { sessionId: 'agent_identity', userId: 'oauth/user', orgId: 'billing-org' }, + auth: { kilocodeToken: 'old-auth' }, + repository: { type: 'github', repo: 'group/repo', upstreamBranch: 'release/selected' }, + callback: { target: { url: 'https://example.com/old' } }, + lifecycle: { version: 1, timestamp: 1 }, + }); + + it('merges into concurrent metadata and preserves it on a stale same-identity retry', async () => { + const newer = parseSessionMetadata({ + ...snapshot, + auth: { kilocodeToken: 'current-auth', kiloSessionId: 'current-session' }, + repository: { ...snapshot.repository, upstreamBranch: 'release/current' }, + callback: { target: { url: 'https://example.com/current' } }, + workspace: { branchName: 'workspace/current', workspacePath: '/workspace/current' }, + lifecycle: { version: 2, timestamp: 1, preparedAt: 2, initiatedAt: 3 }, + }); + let concurrent = true; + const session = createMetadataSession(async () => { + if (concurrent) { + concurrent = false; + await session.updateMetadata(newer); + } + }); + await session.updateMetadata(snapshot); + await session.updateResolvedRepositoryIdentity(snapshot, resolvedIdentity); + expect(await session.getMetadata()).toEqual({ + ...newer, + repository: { ...newer.repository, resolvedIdentity }, + }); + + await session.updateUpstreamBranch('release/after-prepare'); + await session.recordKiloServerActivity(); + const current = await session.getMetadata(); + await session.updateResolvedRepositoryIdentity(snapshot, resolvedIdentity); + expect(await session.getMetadata()).toEqual(current); + expect(current?.repository?.upstreamBranch).toBe('release/after-prepare'); + expect(current?.lifecycle.preparedAt).toBe(2); + expect(current?.workspace?.branchName).toBe('workspace/current'); + }); + + it.each([ + { label: 'repository', change: { repository: { type: 'github', repo: 'group/other' } } }, + { + label: 'provider', + change: { repository: { type: 'gitlab', url: 'https://gitlab.com/group/repo.git' } }, + }, + { label: 'missing repository', change: { repository: undefined } }, + { label: 'user', change: { identity: { ...snapshot.identity, userId: 'other-user' } } }, + { label: 'organization', change: { identity: { ...snapshot.identity, orgId: 'other-org' } } }, + { + label: 'pin', + change: { + repository: { ...snapshot.repository, githubIntegrationId: resolvedIdentity.integrationId }, + }, + }, + { + label: 'integration', + change: { + repository: { + ...snapshot.repository, + resolvedIdentity: { ...resolvedIdentity, integrationId: 'another-integration' }, + }, + }, + }, + { + label: 'resolved owner', + change: { + repository: { + ...snapshot.repository, + resolvedIdentity: { + ...resolvedIdentity, + integrationOwner: { type: 'org', id: 'billing-org' }, + }, + }, + }, + }, + { + label: 'instance', + change: { + repository: { + ...snapshot.repository, + resolvedIdentity: { ...resolvedIdentity, instanceUrl: 'https://other.example.com' }, + }, + }, + }, + ])( + 'rejects a changed $label between lookup and merge without altering current metadata', + async ({ change }) => { + const session = createMetadataSession(); + const current = parseSessionMetadata({ ...snapshot, ...change }); + await session.updateMetadata(current); + await expect( + session.updateResolvedRepositoryIdentity(snapshot, resolvedIdentity) + ).rejects.toThrow('Repository identity cannot change'); + expect(await session.getMetadata()).toEqual(current); + } + ); + + it.each([ + { + repository: { type: 'gitlab', url: 'https://gitlab.example.com/gitlab/group/repo.git' }, + change: { url: 'https://gitlab.example.com/gitlab/group/other.git' }, + }, + { + repository: { type: 'gitlab', url: 'https://gitlab.example.com/gitlab/group/repo.git' }, + change: { gitlabIntegrationId: resolvedIdentity.integrationId }, + }, + ...[ + { url: 'https://bitbucket.org/group/other.git' }, + { workspaceUuid: '123e4567-e89b-12d3-a456-426614174099' }, + { repositoryUuid: '123e4567-e89b-12d3-a456-426614174099' }, + { bitbucketIntegrationId: resolvedIdentity.integrationId }, + ].map(change => ({ + repository: { + type: 'bitbucket', + url: 'https://bitbucket.org/group/repo.git', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + }, + change, + })), + ])( + 'rejects changed provider resource fields before the first resolution: $change', + async ({ repository, change }) => { + const session = createMetadataSession(); + const expected = parseSessionMetadata({ ...snapshot, repository }); + const current = parseSessionMetadata({ + ...expected, + repository: { ...repository, ...change }, + }); + await session.updateMetadata(current); + await expect( + session.updateResolvedRepositoryIdentity(expected, resolvedIdentity) + ).rejects.toThrow('Repository identity cannot change'); + expect(await session.getMetadata()).toEqual(current); + } + ); + + it('keeps a caller pin and rejects a different resolved integration on replay', async () => { + const session = createMetadataSession(); + const pinned = parseSessionMetadata({ + ...snapshot, + repository: { ...snapshot.repository, githubIntegrationId: resolvedIdentity.integrationId }, + }); + await session.updateMetadata(pinned); + await session.updateResolvedRepositoryIdentity(pinned, resolvedIdentity); + const stored = await session.getMetadata(); + await expect( + session.updateResolvedRepositoryIdentity(pinned, { + ...resolvedIdentity, + integrationId: 'another-integration', + }) + ).rejects.toThrow('Repository identity cannot change'); + expect(await session.getMetadata()).toEqual(stored); + }); +}); + describe('session metadata boundary', () => { it('maps legacy managed SCM containment to GitHub and Kilo only', () => { const metadata = parseSessionMetadata({ diff --git a/services/cloud-agent-next/src/persistence/session-metadata.ts b/services/cloud-agent-next/src/persistence/session-metadata.ts index 2cf4cadd24..8de7bdcae1 100644 --- a/services/cloud-agent-next/src/persistence/session-metadata.ts +++ b/services/cloud-agent-next/src/persistence/session-metadata.ts @@ -6,6 +6,7 @@ import { isGeneratedSharedSandboxId, isValidSandboxId } from '../sandbox-id.js'; import { SHARED_SANDBOX_FAILOVER_SUFFIX } from '../shared-sandbox-route.js'; import { MESSAGE_ID_FORMAT_DESCRIPTION, MESSAGE_ID_PATTERN } from '../session/message-id.js'; import { type AgentSandboxProvider, type SandboxId } from '../types.js'; +import type { ResolvedRepositoryIdentity } from '../session/session-requests.js'; import { AttachmentsSchema, branchNameSchema, @@ -68,9 +69,20 @@ const MetadataAuthSchema = z }) .strip(); +export const ResolvedRepositoryIdentitySchema = z.object({ + kind: z.literal('resolved'), + integrationId: z.string().min(1), + integrationOwner: z.discriminatedUnion('type', [ + z.object({ type: z.literal('user'), id: z.string().min(1) }), + z.object({ type: z.literal('org'), id: z.string().min(1) }), + ]), + instanceUrl: z.string().url(), +}); + const RepositoryCommonSchema = { token: z.string().optional(), upstreamBranch: branchNameSchema.optional(), + resolvedIdentity: ResolvedRepositoryIdentitySchema.optional(), }; const repositoryTypes = new Set(['github', 'gitlab', 'bitbucket', 'git']); @@ -131,6 +143,7 @@ const MetadataRepositorySchema = z.preprocess( type: z.literal('gitlab'), url: z.string(), platform: z.literal('gitlab').optional(), + gitlabIntegrationId: z.string().uuid().optional(), gitlabTokenManaged: z.boolean().optional(), ...RepositoryCommonSchema, }) @@ -145,6 +158,7 @@ const MetadataRepositorySchema = z.preprocess( bitbucketIntegrationId: z.string().uuid().optional(), bitbucketTokenManaged: z.boolean().optional(), upstreamBranch: branchNameSchema.optional(), + resolvedIdentity: ResolvedRepositoryIdentitySchema.optional(), }) .strip(), z @@ -528,6 +542,71 @@ export function serializeSessionMetadata(metadata: SessionMetadata): SessionMeta return CurrentSessionMetadataSchema.parse(metadata); } +export function preserveResolvedRepositoryIdentity( + existing: SessionMetadata, + next: SessionMetadata +): SessionMetadata { + const stored = existing.repository; + const incoming = next.repository; + const identity = stored?.resolvedIdentity; + if (!identity) return next; + const candidate = incoming?.resolvedIdentity; + if ( + !incoming || + existing.identity.userId !== next.identity.userId || + existing.identity.orgId !== next.identity.orgId || + stored.type !== incoming.type || + (stored.type === 'github' && + incoming.type === 'github' && + stored.githubIntegrationId !== incoming.githubIntegrationId) || + (stored.type === 'gitlab' && + incoming.type === 'gitlab' && + stored.gitlabIntegrationId !== incoming.gitlabIntegrationId) || + (stored.type === 'bitbucket' && + incoming.type === 'bitbucket' && + stored.bitbucketIntegrationId !== incoming.bitbucketIntegrationId) || + ('repo' in stored + ? stored.repo !== ('repo' in incoming ? incoming.repo : undefined) + : stored.url !== ('url' in incoming ? incoming.url : undefined)) || + (stored.type === 'bitbucket' && + (incoming.type !== 'bitbucket' || + stored.workspaceUuid !== incoming.workspaceUuid || + stored.repositoryUuid !== incoming.repositoryUuid)) || + (candidate && + (candidate.integrationId !== identity.integrationId || + candidate.integrationOwner.type !== identity.integrationOwner.type || + candidate.integrationOwner.id !== identity.integrationOwner.id || + candidate.instanceUrl !== identity.instanceUrl)) + ) { + throw new Error('Repository identity cannot change'); + } + return { ...next, repository: { ...incoming, resolvedIdentity: identity } }; +} + +export function withResolvedRepositoryIdentity( + metadata: SessionMetadata, + resolved: ResolvedRepositoryIdentity +): SessionMetadata { + const repository = metadata.repository; + if (!repository) throw new Error('Repository identity cannot change'); + const identity = ResolvedRepositoryIdentitySchema.parse(resolved); + const pin = + repository.type === 'github' + ? repository.githubIntegrationId + : repository.type === 'gitlab' + ? repository.gitlabIntegrationId + : repository.type === 'bitbucket' + ? repository.bitbucketIntegrationId + : undefined; + if (pin !== undefined && pin !== identity.integrationId) { + throw new Error('Repository identity cannot change'); + } + return preserveResolvedRepositoryIdentity(metadata, { + ...metadata, + repository: { ...repository, resolvedIdentity: identity }, + }); +} + export function updateProviderRuntime( metadata: SessionMetadata, providerRuntime: z.input diff --git a/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts b/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts index f43feae3e2..7c63018102 100644 --- a/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts +++ b/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts @@ -14,7 +14,13 @@ import type * as CloudAgentProfile from '@kilocode/cloud-agent-profile'; import { t } from '../auth.js'; import type { TRPCContext } from '../../types.js'; -import { createSessionPrepareHandlers } from './session-prepare.js'; +import { + createSessionPrepareHandlers, + prepareInputToSessionCreateRequest, +} from './session-prepare.js'; +import { startInputToSessionCreateRequest } from './session-start.js'; +import { PrepareSessionInput, StartSessionInput } from '../schemas.js'; +import { normalizeRepositoryIdentity } from '../../session/session-requests.js'; const { mergeProfileConfigurationMock, @@ -118,6 +124,60 @@ function createContext(overrides?: { } as TRPCContext; } +describe('launch adapter identity round trips', () => { + const pin = '123e4567-e89b-12d3-a456-426614174022'; + const branch = 'release/selected'; + it.each([ + { type: 'github' as const, repo: 'group/repo', githubIntegrationId: pin }, + { + type: 'gitlab' as const, + url: 'https://gitlab.example.com/gitlab/group/sub/repo.git', + gitlabIntegrationId: pin, + }, + { + type: 'bitbucket' as const, + url: 'https://bitbucket.org/group/repo.git', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + bitbucketIntegrationId: pin, + }, + ])('preserves $type identity and branch through both request adapters', repository => { + const grouped = startInputToSessionCreateRequest( + StartSessionInput.parse({ + message: { prompt: 'Test' }, + agent: { mode: 'code', model: 'claude-3' }, + repository: { ...repository, branch }, + }) + ); + const flat = prepareInputToSessionCreateRequest( + PrepareSessionInput.parse({ + prompt: 'Test', + mode: 'code', + model: 'claude-3', + upstreamBranch: branch, + ...(repository.type === 'github' + ? { githubRepo: repository.repo, githubIntegrationId: repository.githubIntegrationId } + : repository.type === 'gitlab' + ? { + platform: 'gitlab', + gitUrl: repository.url, + gitlabIntegrationId: repository.gitlabIntegrationId, + } + : { + platform: 'bitbucket', + gitUrl: repository.url, + bitbucketIntegrationId: repository.bitbucketIntegrationId, + bitbucketWorkspaceUuid: repository.workspaceUuid, + bitbucketRepositoryUuid: repository.repositoryUuid, + }), + }) + ); + expect(grouped.repository).toEqual({ ...repository, branch }); + expect(flat.repository).toEqual(grouped.repository); + expect(normalizeRepositoryIdentity(flat.repository)).toEqual({ kind: 'legacy-unresolved' }); + }); +}); + const OPERATION_KEY = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; describe('prepareSession operation-ledger admission gate', () => { diff --git a/services/cloud-agent-next/src/router/handlers/session-prepare.ts b/services/cloud-agent-next/src/router/handlers/session-prepare.ts index a363dfa838..78987ced7f 100644 --- a/services/cloud-agent-next/src/router/handlers/session-prepare.ts +++ b/services/cloud-agent-next/src/router/handlers/session-prepare.ts @@ -222,6 +222,7 @@ export function prepareInputToSessionCreateRequest(input: PrepareInput): Session repository = { type: 'gitlab', url: gitUrl, + ...(input.gitlabIntegrationId ? { gitlabIntegrationId: input.gitlabIntegrationId } : {}), branch: input.upstreamBranch, }; } else if ( @@ -339,12 +340,17 @@ const prepareSessionHandler = internalApiProtectedProcedure input.kilocodeOrganizationId ); } - await assertRepositoryAccessBeforeSessionCreation({ - env: ctx.env, - userId: ctx.userId, - orgId: input.kilocodeOrganizationId, - repository: request.repository, - }); + // Ledger retries authorize the stored resolution, not a new owner lookup. + if (!(input.autoInitiate === true && input.operationKey)) { + const resolvedIdentity = await assertRepositoryAccessBeforeSessionCreation({ + env: ctx.env, + userId: ctx.userId, + orgId: input.kilocodeOrganizationId, + createdOnPlatform: input.createdOnPlatform, + repository: request.repository, + }); + if (resolvedIdentity) request.repository = { ...request.repository, resolvedIdentity }; + } const policy = profileResolutionPolicyForSessionCreateOrigin(input.createdOnPlatform); const requestWithProfile = await resolveEffectiveSessionConfiguration(ctx, request, policy); assertModeAvailableForProfile( diff --git a/services/cloud-agent-next/src/router/handlers/session-start.ts b/services/cloud-agent-next/src/router/handlers/session-start.ts index 109f2b6d87..8648b127a5 100644 --- a/services/cloud-agent-next/src/router/handlers/session-start.ts +++ b/services/cloud-agent-next/src/router/handlers/session-start.ts @@ -35,7 +35,7 @@ export function createSessionStartHandlers(): SessionStartHandlers { return { start: startSessionHandler }; } -function startInputToSessionCreateRequest( +export function startInputToSessionCreateRequest( input: z.infer ): SessionCreateRequest { const repo = input.repository; @@ -52,7 +52,12 @@ function startInputToSessionCreateRequest( }; break; case 'gitlab': - repository = { type: 'gitlab', url: repo.url, branch: repo.branch }; + repository = { + type: 'gitlab', + url: repo.url, + ...(repo.gitlabIntegrationId ? { gitlabIntegrationId: repo.gitlabIntegrationId } : {}), + branch: repo.branch, + }; break; case 'bitbucket': repository = { @@ -106,12 +111,14 @@ const startSessionHandler = protectedProcedure db = getPgDb(ctx.env); await assertOrganizationMembership(db, ctx.userId, organizationId); } - await assertRepositoryAccessBeforeSessionCreation({ + const resolvedIdentity = await assertRepositoryAccessBeforeSessionCreation({ env: ctx.env, userId: ctx.userId, orgId: organizationId, + createdOnPlatform: request.options?.createdOnPlatform, repository: request.repository, }); + if (resolvedIdentity) request.repository = { ...request.repository, resolvedIdentity }; const policy = profileResolutionPolicyForSessionCreateOrigin( input.options?.createdOnPlatform diff --git a/services/cloud-agent-next/src/router/schemas.test.ts b/services/cloud-agent-next/src/router/schemas.test.ts index 5aed6d342b..dddc52f7d9 100644 --- a/services/cloud-agent-next/src/router/schemas.test.ts +++ b/services/cloud-agent-next/src/router/schemas.test.ts @@ -216,6 +216,109 @@ describe('grouped unified session input contracts', () => { }); }); +describe('provider launch pins', () => { + const pin = '123e4567-e89b-12d3-a456-426614174022'; + const bitbucket = { + type: 'bitbucket' as const, + url: 'https://bitbucket.org/acme/repo.git', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + bitbucketIntegrationId: pin, + }; + const ordinaryBitbucket = { + ...basePromptInput, + gitUrl: bitbucket.url, + platform: 'bitbucket' as const, + bitbucketWorkspaceUuid: bitbucket.workspaceUuid, + bitbucketRepositoryUuid: bitbucket.repositoryUuid, + bitbucketIntegrationId: pin, + }; + + it.each([ + { type: 'github', repo: 'acme/repo', githubIntegrationId: pin }, + { + type: 'gitlab', + url: 'https://gitlab.example.com/gitlab/group/sub/repo.git', + gitlabIntegrationId: pin, + }, + bitbucket, + ])('retains the $type pin and exact branch in grouped input', repository => { + expect( + StartSessionInput.parse({ + ...baseStartInput, + repository: { ...repository, branch: 'release/selected' }, + }).repository + ).toEqual({ ...repository, branch: 'release/selected' }); + }); + + it('accepts an ordinary Bitbucket pin without granting review context', () => { + expect(PrepareSessionInput.parse(ordinaryBitbucket)).toMatchObject(ordinaryBitbucket); + }); + + it.each([ + { bitbucketWorkspaceSlug: 'acme' }, + { bitbucketRepositorySlug: 'repo' }, + { bitbucketPullRequestId: 42 }, + { bitbucketExpectedHeadSha: '0123456789abcdef0123456789abcdef01234567' }, + ])('rejects automation-only review context %j', reviewField => { + expect(PrepareSessionInput.safeParse({ ...ordinaryBitbucket, ...reviewField }).success).toBe( + false + ); + }); + + it('rejects managed pins on a GitHub source with a conflicting platform', () => { + expect( + PrepareSessionInput.safeParse({ + ...basePromptInput, + githubRepo: 'acme/repo', + platform: 'gitlab', + gitlabIntegrationId: pin, + }).success + ).toBe(false); + expect( + PrepareSessionInput.safeParse({ + ...ordinaryBitbucket, + gitUrl: undefined, + githubRepo: 'acme/repo', + }).success + ).toBe(false); + }); + + it('retains the legacy wrong-provider Bitbucket pin error', () => { + const result = PrepareSessionInput.safeParse({ + ...basePromptInput, + gitUrl: 'https://gitlab.com/acme/repo.git', + platform: 'gitlab', + bitbucketIntegrationId: pin, + }); + if (result.success) throw new Error('Expected an invalid provider pin'); + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + path: ['bitbucketIntegrationId'], + message: 'Bitbucket review context is only valid for Bitbucket code review', + }) + ); + }); + + it('retains the flat GitLab pin and rejects pins on another provider', () => { + const input = { + ...basePromptInput, + gitUrl: 'https://gitlab.example.com/gitlab/group/sub/repo.git', + platform: 'gitlab', + gitlabIntegrationId: pin, + upstreamBranch: 'release/selected', + }; + expect(PrepareSessionInput.parse(input)).toMatchObject(input); + expect(PrepareSessionInput.safeParse({ ...input, platform: 'github' }).success).toBe(false); + expect( + StartSessionInput.safeParse({ + ...baseStartInput, + repository: { ...baseStartInput.repository, gitlabIntegrationId: pin }, + }).success + ).toBe(false); + }); +}); + describe('legacy live attachment input compatibility', () => { it('accepts only the supported isolated Standard allocation', () => { const input = { diff --git a/services/cloud-agent-next/src/router/schemas.ts b/services/cloud-agent-next/src/router/schemas.ts index c2a786a809..f5c2d75b6e 100644 --- a/services/cloud-agent-next/src/router/schemas.ts +++ b/services/cloud-agent-next/src/router/schemas.ts @@ -466,6 +466,7 @@ const PrepareSessionSharedFields = { .enum(['github', 'gitlab', 'bitbucket']) .optional() .describe('Git platform type for correct token/env var handling'), + gitlabIntegrationId: z.string().uuid().optional(), bitbucketWorkspaceUuid: z.string().uuid().optional(), bitbucketWorkspaceSlug: z .string() @@ -672,6 +673,16 @@ export const PrepareSessionInput = z }); } + if ( + data.gitlabIntegrationId !== undefined && + (data.platform !== 'gitlab' || data.gitUrl === undefined) + ) { + ctx.addIssue({ + code: 'custom', + path: ['gitlabIntegrationId'], + message: 'GitLab integration identity is only valid for GitLab repositories', + }); + } const hasBitbucketIds = data.bitbucketWorkspaceUuid !== undefined && data.bitbucketRepositoryUuid !== undefined; if ( @@ -695,11 +706,13 @@ export const PrepareSessionInput = z const bitbucketReviewFields = [ data.bitbucketWorkspaceSlug, data.bitbucketRepositorySlug, - data.bitbucketIntegrationId, data.bitbucketPullRequestId, data.bitbucketExpectedHeadSha, ]; - const hasAnyBitbucketReviewField = bitbucketReviewFields.some(value => value !== undefined); + const hasAnyBitbucketReviewField = + bitbucketReviewFields.some(value => value !== undefined) || + (data.bitbucketIntegrationId !== undefined && + (data.platform !== 'bitbucket' || data.gitUrl === undefined)); const hasCompleteBitbucketReviewContext = bitbucketReviewFields.every( value => value !== undefined ); @@ -707,7 +720,7 @@ export const PrepareSessionInput = z data.createdOnPlatform === 'code-review' && data.platform === 'bitbucket'; if (isBitbucketCodeReview) { - if (!hasCompleteBitbucketReviewContext) { + if (!hasCompleteBitbucketReviewContext || data.bitbucketIntegrationId === undefined) { ctx.addIssue({ code: 'custom', path: ['bitbucketIntegrationId'], @@ -807,12 +820,16 @@ export const RepositoryInputSchema = z.discriminatedUnion('type', [ .uuid() .optional() .describe('GitHub platform integration ID that must authorize the selected repository'), + gitlabIntegrationId: z.never().optional(), + bitbucketIntegrationId: z.never().optional(), branch: branchNameSchema.optional().describe('Branch to checkout'), }), z.object({ type: z.literal('gitlab'), url: gitUrlSchema.describe('GitLab repository HTTPS URL'), + gitlabIntegrationId: z.string().uuid().optional(), githubIntegrationId: z.never().optional(), + bitbucketIntegrationId: z.never().optional(), branch: branchNameSchema.optional().describe('Branch to checkout'), }), z.object({ @@ -822,6 +839,7 @@ export const RepositoryInputSchema = z.discriminatedUnion('type', [ repositoryUuid: z.string().uuid(), bitbucketIntegrationId: z.string().uuid().optional(), githubIntegrationId: z.never().optional(), + gitlabIntegrationId: z.never().optional(), branch: branchNameSchema.optional().describe('Branch to checkout'), }), z.object({ @@ -829,6 +847,8 @@ export const RepositoryInputSchema = z.discriminatedUnion('type', [ url: gitUrlSchema.describe('Git repository HTTPS URL'), token: z.string().optional().describe('Git authentication token'), githubIntegrationId: z.never().optional(), + gitlabIntegrationId: z.never().optional(), + bitbucketIntegrationId: z.never().optional(), branch: branchNameSchema.optional().describe('Branch to checkout'), }), ]); diff --git a/services/cloud-agent-next/src/services/git-token-service-client.test.ts b/services/cloud-agent-next/src/services/git-token-service-client.test.ts index 32b37ddb20..e279f69679 100644 --- a/services/cloud-agent-next/src/services/git-token-service-client.test.ts +++ b/services/cloud-agent-next/src/services/git-token-service-client.test.ts @@ -3,6 +3,8 @@ import { logger } from '../logger.js'; import type { GitTokenService } from '../types.js'; import { issueCloudAgentGitHubSessionCapability, + issueCloudAgentBitbucketSessionCapability, + resolveGitHubTokenForRepo, issueCloudAgentGitLabSessionCapability, resolveCloudAgentGitHubAuthForRepo, resolveManagedBitbucketToken, @@ -36,6 +38,282 @@ function createEnv(service: Partial) { return { GIT_TOKEN_SERVICE: service as GitTokenService }; } +describe('authorized identity producer/consumer contract', () => { + const integrationId = '123e4567-e89b-12d3-a456-426614174022'; + const integrationOwner = { type: 'user' as const, id: 'oauth/user' }; + const githubParams = { + githubRepo: 'acme/repo', + userId: 'oauth/user', + orgId: 'billing-org', + expectedIntegrationId: integrationId, + expectedIntegrationOwner: integrationOwner, + allowUserAuthorization: true, + outboundContainerId: 'container-1', + }; + + it.each(['raw', 'managed', 'capability', 'legacy-capability'] as const)( + 'retains the resolved Personal owner and organization context through %s', + async mode => { + const response = { + success: true as const, + token: 'raw-token', + githubToken: 'managed-token', + capability: 'opaque-capability', + installationId: '123', + appType: 'standard' as const, + accountLogin: 'acme', + integrationId, + integrationOwner, + source: 'installation' as const, + gitAuthor: { name: 'bot', email: 'bot@example.com' }, + }; + const authorized = async (params: Parameters[0]) => + params.orgId === githubParams.orgId && + params.expectedIntegrationId === integrationId && + params.expectedIntegrationOwner?.type === 'user' && + params.expectedIntegrationOwner.id === integrationOwner.id + ? response + : { success: false as const, reason: 'integration_mismatch' as const }; + const env = createEnv({ + getTokenForRepo: authorized, + ...(mode === 'legacy-capability' + ? {} + : { + getCloudAgentAuthForRepo: authorized, + issueGitHubSessionCapability: authorized, + }), + }); + const result = + mode === 'raw' + ? await resolveGitHubTokenForRepo(env, githubParams) + : mode === 'managed' + ? await resolveCloudAgentGitHubAuthForRepo(env, githubParams) + : await issueCloudAgentGitHubSessionCapability(env, githubParams); + expect(result).toMatchObject({ + success: true, + value: { + identity: { + kind: 'resolved', + integrationId, + integrationOwner, + instanceUrl: 'https://github.com', + }, + }, + }); + } + ); + + it.each(['gitlab', 'bitbucket'] as const)( + 'retains %s pins through raw and capability responses', + async provider => { + const authorized = async (params: { expectedIntegrationId?: string }) => + params.expectedIntegrationId === integrationId + ? { + success: true, + token: 'raw-token', + capability: 'opaque-capability', + integrationId, + instanceUrl: 'https://gitlab.example.com/gitlab', + instanceOrigin: 'https://gitlab.example.com/gitlab', + instanceHost: 'gitlab.example.com', + projectPath: 'group/sub/repo', + gitUrl: 'https://bitbucket.org/group/repo.git', + authType: 'oauth', + identity: { accountId: '42', accountLogin: 'actor' }, + glabIsOAuth2: true, + } + : { success: false, reason: 'integration_mismatch' }; + const env = createEnv({ + getGitLabToken: vi.fn(authorized), + issueGitLabSessionCapability: vi.fn(authorized), + getBitbucketToken: vi.fn(authorized), + issueBitbucketSessionCapability: vi.fn(authorized), + } as Partial); + const params = { + userId: 'oauth/user', + orgId: 'org-1', + expectedIntegrationId: integrationId, + outboundContainerId: 'container-1', + gitUrl: 'https://gitlab.example.com/gitlab/group/sub/repo.git', + repositoryUrl: 'https://bitbucket.org/group/repo.git', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + }; + const raw = + provider === 'gitlab' + ? await resolveManagedGitLabToken(env, params) + : await resolveManagedBitbucketToken(env, params); + const capability = + provider === 'gitlab' + ? await issueCloudAgentGitLabSessionCapability(env, params) + : await issueCloudAgentBitbucketSessionCapability(env, params); + expect(raw).toMatchObject({ success: true, token: 'raw-token', integrationId }); + expect(capability).toMatchObject({ + success: true, + value: { capability: 'opaque-capability', integrationId }, + }); + if (provider === 'gitlab') + expect(capability).toMatchObject({ + value: { gitUrl: 'https://gitlab.example.com/gitlab/group/sub/repo.git' }, + }); + } + ); +}); + +describe('GitHub response identity compatibility', () => { + const integrationId = '123e4567-e89b-12d3-a456-426614174022'; + const integrationOwner = { type: 'user' as const, id: 'oauth/user' }; + const params = { + githubRepo: 'acme/repo', + userId: 'oauth/user', + orgId: 'billing-org', + allowUserAuthorization: true, + outboundContainerId: 'container-1', + }; + const oldResponse = { + success: true, + token: 'old-token', + githubToken: 'old-token', + capability: 'old-capability', + installationId: '123', + appType: 'standard', + accountLogin: 'acme', + source: 'installation', + gitAuthor: { name: 'bot', email: 'bot@example.com' }, + }; + const resolvers = [ + { mode: 'raw', resolve: resolveGitHubTokenForRepo }, + { mode: 'managed', resolve: resolveCloudAgentGitHubAuthForRepo }, + { mode: 'capability', resolve: issueCloudAgentGitHubSessionCapability }, + ]; + + it.each(resolvers)( + 'marks old $mode responses as unresolved without inventing identity', + async ({ resolve }) => { + const service = vi.fn().mockResolvedValue(oldResponse); + const result = await resolve( + createEnv({ + getTokenForRepo: service, + getCloudAgentAuthForRepo: service, + issueGitHubSessionCapability: service, + }), + params + ); + expect(result).toMatchObject({ + success: true, + value: { identity: { kind: 'legacy-unresolved' } }, + }); + expect(result).not.toHaveProperty('value.integrationId'); + } + ); + + it.each( + resolvers.flatMap(resolver => + [ + { label: 'pin', expected: { expectedIntegrationId: integrationId } }, + { label: 'owner', expected: { expectedIntegrationOwner: integrationOwner } }, + ].map(selection => ({ ...resolver, ...selection })) + ) + )('rejects an unproven $label from an old $mode response', async ({ resolve, expected }) => { + const service = vi.fn().mockResolvedValue(oldResponse); + const result = await resolve( + createEnv({ + getTokenForRepo: service, + getCloudAgentAuthForRepo: service, + issueGitHubSessionCapability: service, + }), + { ...params, ...expected } + ); + expect(result).toMatchObject({ + success: false, + error: { reason: 'service_compatibility_error' }, + }); + expect(result).not.toHaveProperty('value'); + }); + + it.each( + resolvers.flatMap(resolver => + [ + { integrationId }, + { integrationOwner }, + { integrationId: undefined, integrationOwner: undefined }, + { integrationId: '', integrationOwner }, + { integrationId: 123, integrationOwner }, + { integrationId, integrationOwner: null }, + { integrationId, integrationOwner: { type: 'user', id: '' } }, + { integrationId, integrationOwner: { type: 'team', id: 'owner' } }, + ].map(fields => ({ ...resolver, fields })) + ) + )( + 'rejects malformed $mode identity $fields without a legacy fallback', + async ({ resolve, fields }) => { + const service = vi.fn().mockResolvedValue({ ...oldResponse, ...fields }); + const result = await resolve( + createEnv({ + getTokenForRepo: service, + getCloudAgentAuthForRepo: service, + issueGitHubSessionCapability: service, + }), + params + ); + expect(result).toMatchObject({ + success: false, + error: { reason: 'service_compatibility_error' }, + }); + expect(result).not.toHaveProperty('value'); + } + ); + + it.each(['managed', 'capability'] as const)( + 'does not hide malformed %s identity with a successful fallback', + async mode => { + const malformed = { ...oldResponse, integrationId }; + const resolved = { ...oldResponse, integrationId, integrationOwner }; + const env = createEnv({ + getTokenForRepo: vi.fn().mockResolvedValue(resolved), + getCloudAgentAuthForRepo: vi + .fn() + .mockResolvedValue(mode === 'managed' ? malformed : resolved), + issueGitHubSessionCapability: vi.fn().mockResolvedValue(malformed), + }); + const result = + mode === 'managed' + ? await resolveCloudAgentGitHubAuthForRepo(env, params) + : await issueCloudAgentGitHubSessionCapability(env, params); + expect(result).toMatchObject({ + success: false, + error: { reason: 'service_compatibility_error' }, + }); + expect(result).not.toHaveProperty('value'); + } + ); + + it.each( + resolvers.flatMap(resolver => + [ + { expectedIntegrationId: 'another-integration' }, + { expectedIntegrationOwner: { type: 'org' as const, id: 'billing-org' } }, + { expectedIntegrationOwner: { type: 'user' as const, id: 'another-user' } }, + ].map(expected => ({ ...resolver, expected })) + ) + )('rejects a changed resolved identity from $mode', async ({ resolve, expected }) => { + const service = vi.fn().mockResolvedValue({ ...oldResponse, integrationId, integrationOwner }); + expect( + await resolve( + createEnv({ + getTokenForRepo: service, + getCloudAgentAuthForRepo: service, + issueGitHubSessionCapability: service, + }), + { ...params, ...expected } + ) + ).toMatchObject({ + success: false, + error: { reason: 'integration_mismatch' }, + }); + }); +}); + describe('resolveManagedBitbucketToken', () => { const repositoryParams = { userId: 'user_123', @@ -214,6 +492,7 @@ describe('issueCloudAgentGitHubSessionCapability', () => { value: { githubToken: 'installation-token', installationId: '123', + identity: { kind: 'legacy-unresolved' }, accountLogin: 'acme', appType: 'standard', source: 'installation', @@ -351,6 +630,7 @@ describe('issueCloudAgentGitHubSessionCapability', () => { value: { githubToken: 'user-token', installationId: '123', + identity: { kind: 'legacy-unresolved' }, accountLogin: 'acme', appType: 'standard', source: 'user', @@ -518,6 +798,7 @@ describe('resolveCloudAgentGitHubAuthForRepo', () => { value: { githubToken: 'installation-token', installationId: '123', + identity: { kind: 'legacy-unresolved' }, accountLogin: 'acme', appType: 'standard', source: 'installation', @@ -559,6 +840,7 @@ describe('resolveCloudAgentGitHubAuthForRepo', () => { value: { githubToken: 'installation-token', installationId: '123', + identity: { kind: 'legacy-unresolved' }, appType: 'standard', source: 'installation', }, diff --git a/services/cloud-agent-next/src/services/git-token-service-client.ts b/services/cloud-agent-next/src/services/git-token-service-client.ts index e21d84a8d3..621cc58687 100644 --- a/services/cloud-agent-next/src/services/git-token-service-client.ts +++ b/services/cloud-agent-next/src/services/git-token-service-client.ts @@ -1,4 +1,6 @@ import { logger } from '../logger.js'; +import { ResolvedRepositoryIdentitySchema } from '../persistence/session-metadata.js'; +import type { RepositoryIdentityResolution } from '../session/session-requests.js'; import type { BitbucketTokenFailureReason, GitAuthorConfig, @@ -14,6 +16,7 @@ type GitTokenServiceEnv = { export type ResolvedGitHubToken = { token: string; installationId: string; + identity: RepositoryIdentityResolution; appType: 'standard' | 'lite'; accountLogin: string; }; @@ -27,9 +30,61 @@ export type ResolveGitHubTokenResult = | { success: true; value: ResolvedGitHubToken } | { success: false; error: ResolveGitHubTokenError }; +function githubIdentityFromResponse( + response: { integrationId?: unknown; integrationOwner?: unknown }, + params: Parameters[0] +): + | { success: true; identity: RepositoryIdentityResolution } + | { success: false; error: ResolveGitHubTokenError } { + // Old GitHub deployments omit both fields and can ignore newly added selectors. + // Permit only their unpinned legacy path, without claiming exact identity. + // Remove after old deployments/clients/records disappear and the 30-day ledger window expires. + if ( + !Object.hasOwn(response, 'integrationId') && + !Object.hasOwn(response, 'integrationOwner') && + params.expectedIntegrationId === undefined && + params.expectedIntegrationOwner === undefined + ) { + return { success: true, identity: { kind: 'legacy-unresolved' } }; + } + const parsed = ResolvedRepositoryIdentitySchema.safeParse({ + kind: 'resolved', + integrationId: response.integrationId, + integrationOwner: response.integrationOwner, + instanceUrl: 'https://github.com', + }); + if (!parsed.success) { + return { + success: false, + error: { + reason: 'service_compatibility_error', + message: + 'GitHub token service cannot prove the repository identity (service_compatibility_error)', + }, + }; + } + const identity = parsed.data; + if ( + (params.expectedIntegrationId !== undefined && + params.expectedIntegrationId !== identity.integrationId) || + (params.expectedIntegrationOwner !== undefined && + (params.expectedIntegrationOwner.type !== identity.integrationOwner.type || + params.expectedIntegrationOwner.id !== identity.integrationOwner.id)) + ) { + return { + success: false, + error: { + reason: 'integration_mismatch', + message: 'GitHub repository identity does not match the requested integration', + }, + }; + } + return { success: true, identity }; +} + export async function resolveGitHubTokenForRepo( env: GitTokenServiceEnv, - params: { githubRepo: string; userId: string; orgId?: string; expectedIntegrationId?: string } + params: Parameters[0] ): Promise { try { if (!env.GIT_TOKEN_SERVICE) { @@ -43,6 +98,8 @@ export async function resolveGitHubTokenForRepo( } const result = await env.GIT_TOKEN_SERVICE.getTokenForRepo(params); if (result.success) { + const resolution = githubIdentityFromResponse(result, params); + if (!resolution.success) return resolution; logger .withFields({ installationId: result.installationId, @@ -55,6 +112,7 @@ export async function resolveGitHubTokenForRepo( value: { token: result.token, installationId: result.installationId, + identity: resolution.identity, appType: result.appType, accountLogin: result.accountLogin, }, @@ -83,6 +141,7 @@ export async function resolveGitHubTokenForRepo( export type ResolvedCloudAgentGitHubAuth = { githubToken: string; installationId: string; + identity: RepositoryIdentityResolution; appType: 'standard' | 'lite'; accountLogin: string; source: 'user' | 'installation'; @@ -94,6 +153,7 @@ export type ResolvedCloudAgentGitHubAuth = { export type ResolvedCloudAgentGitHubCapability = { capability: string; installationId: string; + identity: RepositoryIdentityResolution; appType: 'standard' | 'lite'; accountLogin: string; source: 'user' | 'installation'; @@ -102,12 +162,10 @@ export type ResolvedCloudAgentGitHubCapability = { fallbackReason?: ManagedGitHubFallbackReason; }; -type IssueCloudAgentGitHubSessionCapabilityParams = { - githubRepo: string; - userId: string; +type IssueCloudAgentGitHubSessionCapabilityParams = Parameters< + GitTokenService['getTokenForRepo'] +>[0] & { outboundContainerId: string; - orgId?: string; - expectedIntegrationId?: string; allowUserAuthorization: boolean; }; @@ -119,14 +177,19 @@ type CloudAgentGitHubAuthResult = | { success: true; value: ResolvedCloudAgentGitHubAuth } | { success: false; error: ResolveGitHubTokenError }; +// Old service deployments lack managed-auth/capability RPCs. Remove these +// fallbacks only after old clients/records disappear and the 30-day ledger window expires. async function resolveLegacyInstallationAuthForRepo( env: GitTokenServiceEnv, - params: { githubRepo: string; userId: string; orgId?: string; expectedIntegrationId?: string } + params: Parameters[0] ): Promise { const legacyParams = { githubRepo: params.githubRepo, userId: params.userId, ...(params.orgId !== undefined ? { orgId: params.orgId } : {}), + ...(params.expectedIntegrationOwner !== undefined + ? { expectedIntegrationOwner: params.expectedIntegrationOwner } + : {}), ...(params.expectedIntegrationId !== undefined ? { expectedIntegrationId: params.expectedIntegrationId } : {}), @@ -138,6 +201,7 @@ async function resolveLegacyInstallationAuthForRepo( value: { githubToken: result.value.token, installationId: result.value.installationId, + identity: result.value.identity, appType: result.value.appType, accountLogin: result.value.accountLogin, source: 'installation', @@ -147,13 +211,7 @@ async function resolveLegacyInstallationAuthForRepo( export async function resolveCloudAgentGitHubAuthForRepo( env: GitTokenServiceEnv, - params: { - githubRepo: string; - userId: string; - orgId?: string; - expectedIntegrationId?: string; - allowUserAuthorization: boolean; - } + params: Parameters[0] & { allowUserAuthorization: boolean } ): Promise { if (!env.GIT_TOKEN_SERVICE) { return { @@ -179,6 +237,8 @@ export async function resolveCloudAgentGitHubAuthForRepo( }, }; } + const resolution = githubIdentityFromResponse(result, params); + if (!resolution.success) return resolution; logger .withFields({ installationId: result.installationId, @@ -193,6 +253,7 @@ export async function resolveCloudAgentGitHubAuthForRepo( value: { githubToken: result.githubToken, installationId: result.installationId, + identity: resolution.identity, appType: result.appType, accountLogin: result.accountLogin, source: result.source, @@ -218,6 +279,9 @@ function resolveGitHubAuthFallbackForCapability( githubRepo: params.githubRepo, userId: params.userId, ...(params.orgId !== undefined ? { orgId: params.orgId } : {}), + ...(params.expectedIntegrationOwner !== undefined + ? { expectedIntegrationOwner: params.expectedIntegrationOwner } + : {}), ...(params.expectedIntegrationId !== undefined ? { expectedIntegrationId: params.expectedIntegrationId } : {}), @@ -254,6 +318,8 @@ export async function issueCloudAgentGitHubSessionCapability( }, }; } + const resolution = githubIdentityFromResponse(result, params); + if (!resolution.success) return resolution; logger .withFields({ installationId: result.installationId, @@ -268,6 +334,7 @@ export async function issueCloudAgentGitHubSessionCapability( value: { capability: result.capability, installationId: result.installationId, + identity: resolution.identity, appType: result.appType, accountLogin: result.accountLogin, source: result.source, @@ -298,7 +365,13 @@ export type ResolvedCloudAgentGitLabCapability = { }; export type ResolveManagedGitLabTokenResult = - | { success: true; token: string; instanceUrl: string; glabIsOAuth2: boolean } + | { + success: true; + token: string; + instanceUrl: string; + integrationId: string; + glabIsOAuth2: boolean; + } | { success: false; reason: string }; export type ManagedBitbucketTokenFailureReason = @@ -307,9 +380,19 @@ export type ManagedBitbucketTokenFailureReason = | 'rpc_error'; export type ResolveManagedBitbucketTokenResult = - | { success: true; token: string } + | { success: true; token: string; integrationId: string } | { success: false; reason: ManagedBitbucketTokenFailureReason }; +export function isTemporaryManagedGitLabTokenFailure(reason: string): boolean { + return ( + reason === 'token_refresh_failed' || + reason === 'project_lookup_failed' || + reason === 'service_not_configured' || + reason === 'database_not_configured' || + reason === 'rpc_error' + ); +} + export function isTemporaryManagedBitbucketTokenFailure( reason: ManagedBitbucketTokenFailureReason ): boolean { @@ -343,7 +426,7 @@ export async function resolveManagedBitbucketToken( const result = await env.GIT_TOKEN_SERVICE.getBitbucketToken(params); if (result.success) { logger.info('Resolved Bitbucket token via git-token-service'); - return { success: true, token: result.token }; + return { success: true, token: result.token, integrationId: result.integrationId }; } logger.withFields({ reason: result.reason }).info('Bitbucket token lookup failed'); return { success: false, reason: result.reason }; @@ -356,6 +439,7 @@ export async function resolveManagedBitbucketToken( export type ResolvedCloudAgentBitbucketCapability = { capability: string; gitUrl: string; + integrationId: string; }; export async function issueCloudAgentBitbucketSessionCapability( @@ -383,7 +467,14 @@ export async function issueCloudAgentBitbucketSessionCapability( const result = await env.GIT_TOKEN_SERVICE.issueBitbucketSessionCapability(params); if (!result.success) return { success: false, reason: result.reason }; logger.info('Issued Bitbucket session capability via git-token-service'); - return { success: true, value: { capability: result.capability, gitUrl: result.gitUrl } }; + return { + success: true, + value: { + capability: result.capability, + gitUrl: result.gitUrl, + integrationId: result.integrationId, + }, + }; } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.withFields({ error: message }).error('Failed to issue Bitbucket session capability'); @@ -393,13 +484,7 @@ export async function issueCloudAgentBitbucketSessionCapability( export async function issueCloudAgentGitLabSessionCapability( env: GitTokenServiceEnv, - params: { - gitUrl: string; - userId: string; - outboundContainerId: string; - orgId?: string; - createdOnPlatform?: string; - } + params: Parameters[0] ): Promise< { success: true; value: ResolvedCloudAgentGitLabCapability } | { success: false; reason: string } > { @@ -442,12 +527,7 @@ export async function issueCloudAgentGitLabSessionCapability( export async function resolveManagedGitLabToken( env: GitTokenServiceEnv, - params: { - userId: string; - orgId?: string; - repositoryUrl?: string; - createdOnPlatform?: string; - } + params: Parameters[0] ): Promise { try { if (!env.GIT_TOKEN_SERVICE) { @@ -460,6 +540,7 @@ export async function resolveManagedGitLabToken( success: true, token: result.token, instanceUrl: result.instanceUrl, + integrationId: result.integrationId, glabIsOAuth2: result.glabIsOAuth2, }; } diff --git a/services/cloud-agent-next/src/session-prepare.test.ts b/services/cloud-agent-next/src/session-prepare.test.ts index d453dad0e5..4e7edbfa5b 100644 --- a/services/cloud-agent-next/src/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session-prepare.test.ts @@ -234,12 +234,25 @@ function createInternalApiContext(options: { success: true, token: 'managed-github-token', installationId: '123', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'test-user-123' }, accountLogin: 'acme', appType: 'standard', }), + getGitLabToken: vi.fn().mockResolvedValue({ + success: true, + token: 'managed-gitlab-token', + instanceUrl: 'https://gitlab.com', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + glabIsOAuth2: true, + }), getBitbucketToken: options.getBitbucketToken ?? - vi.fn().mockResolvedValue({ success: true, token: 'managed-bitbucket-token' }), + vi.fn().mockResolvedValue({ + success: true, + token: 'managed-bitbucket-token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + }), } as unknown as TRPCContext['env']['GIT_TOKEN_SERVICE'], HYPERDRIVE: { connectionString: 'postgres://profile-test', @@ -624,11 +637,17 @@ describe('prepareSession endpoint', () => { expect(doStub.registerSession).toHaveBeenCalledWith( expect.objectContaining({ - repository: { + repository: expect.objectContaining({ type: 'gitlab', url: 'https://gitlab.com/acme/repo.git', branch: 'feature/gitlab', - }, + resolvedIdentity: { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'test-user-123' }, + instanceUrl: 'https://gitlab.com', + }, + }), }) ); }); @@ -651,11 +670,17 @@ describe('prepareSession endpoint', () => { expect(doStub.registerSession).toHaveBeenCalledWith( expect.objectContaining({ identity: expect.objectContaining({ createdOnPlatform: 'code-review' }), - repository: { + repository: expect.objectContaining({ type: 'gitlab', url: 'https://gitlab.com/acme/repo.git', branch: 'feature/gitlab', - }, + resolvedIdentity: { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'test-user-123' }, + instanceUrl: 'https://gitlab.com', + }, + }), }) ); expect(doStub.registerSession.mock.calls[0]?.[0].repository).not.toHaveProperty('token'); @@ -663,9 +688,11 @@ describe('prepareSession endpoint', () => { it('persists only generic Bitbucket repository identity after access preflight', async () => { const doStub = createMockDOStub(); - const getBitbucketToken = vi - .fn() - .mockResolvedValue({ success: true, token: 'managed-bitbucket-token' }); + const getBitbucketToken = vi.fn().mockResolvedValue({ + success: true, + token: 'managed-bitbucket-token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + }); const caller = appRouter.createCaller(createInternalApiContext({ doStub, getBitbucketToken })); const reviewId = '123e4567-e89b-12d3-a456-426614174023'; const integrationId = '123e4567-e89b-12d3-a456-426614174022'; @@ -713,6 +740,12 @@ describe('prepareSession endpoint', () => { repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', bitbucketIntegrationId: integrationId, branch: 'feature/bitbucket', + resolvedIdentity: { + kind: 'resolved', + integrationId, + integrationOwner: { type: 'org', id: organizationId }, + instanceUrl: 'https://bitbucket.org', + }, }, }) ); @@ -1416,6 +1449,8 @@ describe('start endpoint', () => { success: true, token: 'managed-github-token', installationId: '123', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'test-user-123' }, accountLogin: 'acme', appType: 'standard', }); @@ -1435,7 +1470,17 @@ describe('start endpoint', () => { }); expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ - repository: { type: 'github', repo: 'acme/repo', githubIntegrationId }, + repository: { + type: 'github', + repo: 'acme/repo', + githubIntegrationId, + resolvedIdentity: { + kind: 'resolved', + integrationId: githubIntegrationId, + integrationOwner: { type: 'user', id: 'test-user-123' }, + instanceUrl: 'https://github.com', + }, + }, }) ); }); diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index a74b452cab..723467898e 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -87,8 +87,11 @@ import type { CloudAgentSessionState, PersistenceEnv } from './persistence/types import type { CreateSessionForCloudAgentResult } from '@kilocode/session-ingest-contracts'; import { parseSessionMetadata, + preserveResolvedRepositoryIdentity, + withResolvedRepositoryIdentity, type CredentialContainment, } from './persistence/session-metadata.js'; +import type { ResolvedRepositoryIdentity } from './session/session-requests.js'; import type { ExecutionSession, SandboxId, SandboxInstance, SessionId } from './types.js'; import type { FencedWrapperDispatchRequest } from './execution/types.js'; import { buildCloudAgentRules } from './shared/cloud-agent-rules.js'; @@ -388,6 +391,7 @@ function createEnv(metadata?: CloudAgentSessionState | null): PersistenceEnv { get: vi.fn(() => ({ getMetadata: vi.fn().mockResolvedValue(metadata ?? null), updateMetadata: vi.fn().mockResolvedValue(undefined), + updateResolvedRepositoryIdentity: vi.fn().mockResolvedValue(undefined), })), } as unknown as PersistenceEnv['CLOUD_AGENT_SESSION'], SANDBOX_SESSION: { @@ -411,6 +415,8 @@ function createEnv(metadata?: CloudAgentSessionState | null): PersistenceEnv { success: true, token: 'resolved-gh-token', installationId: '123', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'user_test' }, accountLogin: 'acme', appType: 'standard', }), @@ -418,6 +424,8 @@ function createEnv(metadata?: CloudAgentSessionState | null): PersistenceEnv { success: true, githubToken: 'resolved-gh-token', installationId: '123', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'user_test' }, accountLogin: 'acme', appType: 'standard', source: 'installation', @@ -427,6 +435,8 @@ function createEnv(metadata?: CloudAgentSessionState | null): PersistenceEnv { success: true, capability: 'kgh2.default', installationId: '123', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'user_test' }, accountLogin: 'acme', appType: 'standard', source: 'installation', @@ -436,6 +446,7 @@ function createEnv(metadata?: CloudAgentSessionState | null): PersistenceEnv { getGitLabToken: vi.fn().mockResolvedValue({ success: true, token: 'resolved-gitlab-token', + integrationId: 'integration_1', instanceUrl: 'https://gitlab.com', glabIsOAuth2: true, }), @@ -588,6 +599,7 @@ describe('SessionService.resolveWorkspaceTokens', () => { tokenMocks.resolveManagedBitbucketToken.mockResolvedValue({ success: true, token: 'opaque-workspace-token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', }); }); @@ -700,6 +712,12 @@ describe('SessionService.prepareWorkspace', () => { value: { githubToken: 'resolved-gh-token', installationId: '123', + identity: { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'user_test' }, + instanceUrl: 'https://github.com', + }, accountLogin: 'acme', appType: 'standard', source: 'installation', @@ -711,6 +729,12 @@ describe('SessionService.prepareWorkspace', () => { value: { capability: 'kgh2.default', installationId: '123', + identity: { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'user_test' }, + instanceUrl: 'https://github.com', + }, accountLogin: 'acme', appType: 'standard', source: 'installation', @@ -733,18 +757,127 @@ describe('SessionService.prepareWorkspace', () => { tokenMocks.resolveManagedGitLabToken.mockResolvedValue({ success: true, token: 'resolved-gitlab-token', + integrationId: 'integration_1', instanceUrl: 'https://gitlab.com', glabIsOAuth2: true, }); tokenMocks.resolveManagedBitbucketToken.mockResolvedValue({ success: true, token: 'fresh-bitbucket-token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', }); devcontainerMocks.detectDevContainer.mockResolvedValue(null); devcontainerMocks.bringUpDevContainer.mockReset(); portMocks.randomPort.mockReturnValue(4173); }); + it.each( + [ + { mode: 'raw', token: 'old-raw-token' }, + { mode: 'managed', token: 'old-managed-token' }, + { mode: 'capability', token: 'old-capability' }, + { mode: 'capability-raw-fallback', token: 'old-raw-token' }, + { mode: 'capability-managed-fallback', token: 'old-managed-token' }, + ].flatMap(response => + ['unpinned', 'pin', 'resolved', 'persisted-resolved'].map(selection => ({ + ...response, + selection, + })) + ) + )( + 'prepares only unpinned old GitHub credentials ($mode, $selection)', + async ({ mode, token, selection }) => { + const actual = await vi.importActual( + './services/git-token-service-client.js' + ); + tokenMocks.resolveCloudAgentGitHubAuthForRepo.mockImplementation( + actual.resolveCloudAgentGitHubAuthForRepo + ); + tokenMocks.issueCloudAgentGitHubSessionCapability.mockImplementation( + actual.issueCloudAgentGitHubSessionCapability + ); + const response = { + success: true, + installationId: '123', + appType: 'standard', + accountLogin: 'acme', + }; + const managed = { + ...response, + source: 'installation', + gitAuthor: { name: 'bot', email: 'bot@example.com' }, + }; + const metadata = createMetadata({ + githubRepo: 'acme/repo', + gitUrl: undefined, + gitToken: undefined, + platform: 'github', + orgId: 'billing-org', + upstreamBranch: 'release/selected', + credentialContainment: { + github: mode.startsWith('capability'), + gitlab: false, + kilocode: false, + }, + }); + const identity = { + kind: 'resolved' as const, + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user' as const, id: 'user_test' }, + instanceUrl: 'https://github.com', + }; + if (metadata.repository?.type !== 'github') throw new Error('Expected GitHub repository'); + if (selection === 'pin') metadata.repository.githubIntegrationId = identity.integrationId; + if (selection === 'resolved') metadata.repository.resolvedIdentity = identity; + const before = structuredClone(metadata); + if (selection === 'persisted-resolved' && before.repository) + before.repository.resolvedIdentity = identity; + const env = createEnv(before); + env.GIT_TOKEN_SERVICE = { + getTokenForRepo: vi.fn().mockResolvedValue({ ...response, token: 'old-raw-token' }), + ...(mode === 'managed' || mode === 'capability-managed-fallback' + ? { + getCloudAgentAuthForRepo: vi + .fn() + .mockResolvedValue({ ...managed, githubToken: 'old-managed-token' }), + } + : {}), + ...(mode === 'capability' + ? { + issueGitHubSessionCapability: vi + .fn() + .mockResolvedValue({ ...managed, capability: 'old-capability' }), + } + : {}), + } as never; + const preparation = new SessionService().prepareWorkspace({ + sandbox: createSandbox(createSession(false)), + sandboxId: 'ses-abcdef', + userId: 'user_test', + sessionId: 'agent_test' as SessionId, + env, + metadata, + kilocodeModel: 'test-model', + }); + if (selection === 'unpinned') { + const result = await preparation; + expect(result.ready).toMatchObject({ + githubInstallationId: '123', + branchName: 'release/selected', + }); + expect(result.context.githubToken).toBe(token); + expect(result.runtimeEnv.GH_TOKEN).toBe(token); + expect(metadata.repository.resolvedIdentity).toBeUndefined(); + } else { + await expect(preparation).rejects.toMatchObject({ + code: 'WORKSPACE_SETUP_FAILED', + retryable: true, + }); + } + expect(await fetchSessionMetadata(env, 'user_test', 'agent_test')).toEqual(before); + } + ); + it('prepares a cold workspace and returns ready metadata', async () => { const session = createSession(false); const sandbox = createSandbox(session); @@ -1026,6 +1159,7 @@ describe('SessionService.prepareWorkspace', () => { success: true, value: { capability: 'kbb1.opaque-capability', + integrationId: '123e4567-e89b-12d3-a456-426614174022', gitUrl: 'https://bitbucket.org/acme-team/widgets.git', }, }); @@ -1449,6 +1583,7 @@ describe('SessionService.prepareWorkspace', () => { success: true, value: { capability: 'kbb1.opaque-capability', + integrationId: '123e4567-e89b-12d3-a456-426614174022', gitUrl: 'https://bitbucket.org/acme-team/widgets.git', }, }); @@ -1993,6 +2128,12 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { value: { githubToken: 'resolved-gh-token', installationId: '123', + identity: { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'user_test' }, + instanceUrl: 'https://github.com', + }, accountLogin: 'acme', appType: 'standard', source: 'installation', @@ -2004,6 +2145,12 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { value: { capability: 'kgh2.default', installationId: '123', + identity: { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'user_test' }, + instanceUrl: 'https://github.com', + }, accountLogin: 'acme', appType: 'standard', source: 'installation', @@ -2026,12 +2173,14 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { tokenMocks.resolveManagedGitLabToken.mockResolvedValue({ success: true, token: 'resolved-gitlab-token', + integrationId: 'integration_1', instanceUrl: 'https://gitlab.com', glabIsOAuth2: true, }); tokenMocks.resolveManagedBitbucketToken.mockResolvedValue({ success: true, token: 'fresh-bitbucket-token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', }); devcontainerMocks.detectDevContainer.mockResolvedValue(null); devcontainerMocks.bringUpDevContainer.mockReset(); @@ -2081,6 +2230,223 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }); } + it.each( + ['github', 'gitlab', 'bitbucket'].flatMap(provider => + [false, true].flatMap(contained => + [false, true].map(pinned => ({ provider, contained, pinned })) + ) + ) + )( + 'persists $provider identity before checkout and pins retries (contained=$contained, pinned=$pinned)', + async ({ provider, contained, pinned }) => { + const integrationId = '123e4567-e89b-12d3-a456-426614174022'; + const orgId = 'billing-org'; + const integrationOwner = + provider === 'github' + ? { type: 'user' as const, id: 'user_test' } + : { type: 'org' as const, id: orgId }; + const instanceUrl = + provider === 'github' + ? 'https://github.com' + : provider === 'gitlab' + ? 'https://gitlab.example.com/gitlab' + : 'https://bitbucket.org'; + const url = `${instanceUrl}/acme/repo.git`; + const repository = + provider === 'github' + ? { + type: 'github', + repo: 'acme/repo', + ...(pinned ? { githubIntegrationId: integrationId } : {}), + } + : provider === 'gitlab' + ? { type: 'gitlab', url, ...(pinned ? { gitlabIntegrationId: integrationId } : {}) } + : { + type: 'bitbucket', + url, + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + ...(pinned ? { bitbucketIntegrationId: integrationId } : {}), + }; + const metadata = parseSessionMetadata({ + ...createMetadata(), + identity: { sessionId: 'agent_test', userId: 'user_test', orgId }, + repository: { ...repository, upstreamBranch: 'release/selected' }, + workspace: { + credentialContainment: { + github: contained && provider === 'github', + gitlab: contained && provider === 'gitlab', + bitbucket: contained && provider === 'bitbucket', + kilocode: false, + }, + }, + }); + const staleSnapshot = structuredClone(metadata); + let stored = structuredClone(metadata); + let attempts = 0; + const lookup = async ( + _env: unknown, + params: { + expectedIntegrationId?: string; + expectedIntegrationOwner?: { type: string; id: string }; + orgId?: string; + } + ) => { + const retry = attempts++ > 0; + if ( + params.orgId !== orgId || + params.expectedIntegrationId !== (retry || pinned ? integrationId : undefined) || + (retry && + provider === 'github' && + JSON.stringify(params.expectedIntegrationOwner) !== JSON.stringify(integrationOwner)) + ) { + return { + success: false, + reason: 'integration_mismatch', + error: { reason: 'integration_mismatch' }, + }; + } + return { + success: true, + token: 'fresh-token', + integrationId, + instanceUrl, + glabIsOAuth2: true, + value: { + ...(contained ? { capability: 'opaque-capability' } : { githubToken: 'fresh-token' }), + integrationId, + identity: { kind: 'resolved', integrationId, integrationOwner, instanceUrl }, + installationId: '123', + appType: 'standard', + source: 'installation', + gitUrl: url, + instanceOrigin: instanceUrl, + instanceHost: new URL(instanceUrl).host, + projectPath: 'acme/repo', + glabIsOAuth2: true, + }, + }; + }; + tokenMocks.resolveCloudAgentGitHubAuthForRepo.mockImplementation(lookup); + tokenMocks.issueCloudAgentGitHubSessionCapability.mockImplementation(lookup); + tokenMocks.resolveManagedGitLabToken.mockImplementation(lookup); + tokenMocks.issueCloudAgentGitLabSessionCapability.mockImplementation(lookup); + tokenMocks.resolveManagedBitbucketToken.mockImplementation(lookup); + tokenMocks.issueCloudAgentBitbucketSessionCapability.mockImplementation(lookup); + const configure = (env: PersistenceEnv) => { + env.CLOUD_AGENT_SESSION.get = vi.fn(() => ({ + getMetadata: async () => structuredClone(stored), + updateResolvedRepositoryIdentity: async ( + expected: Pick, + identity: ResolvedRepositoryIdentity + ) => { + const authorized = withResolvedRepositoryIdentity({ ...stored, ...expected }, identity); + stored = preserveResolvedRepositoryIdentity(authorized, stored); + return { + repository: stored.repository, + workspace: stored.workspace, + lifecycle: stored.lifecycle, + }; + }, + })) as never; + }; + const first = await buildPromptWrapperRequests(metadata, configure); + expect(stored.repository?.resolvedIdentity).toEqual({ + kind: 'resolved', + integrationId, + integrationOwner, + instanceUrl, + }); + expect(stored.identity.orgId).toBe(orgId); + expect(JSON.stringify(stored)).not.toContain('fresh-token'); + expect(first.readyRequest.workspace).toMatchObject({ + branchName: 'release/selected', + upstreamBranch: 'release/selected', + strictBranch: true, + }); + stored = parseSessionMetadata({ + ...stored, + repository: { ...stored.repository, upstreamBranch: 'release/current' }, + workspace: { ...stored.workspace, branchName: 'workspace/current' }, + lifecycle: { ...stored.lifecycle, preparedAt: 1 }, + }); + const retry = await buildPromptWrapperRequests(staleSnapshot, configure); + expect(retry.readyRequest.repo).toEqual(first.readyRequest.repo); + expect(retry.readyRequest.workspace).toMatchObject({ + branchName: 'workspace/current', + upstreamBranch: 'release/current', + strictBranch: false, + preferSnapshot: true, + }); + const resumed = await buildPromptWrapperRequests(structuredClone(stored), configure); + expect(resumed.readyRequest.workspace).toEqual(retry.readyRequest.workspace); + expect(stored.lifecycle.preparedAt).toBe(1); + expect(stored.repository?.upstreamBranch).toBe('release/current'); + } + ); + + it('keeps an empty repository and the legacy generated branch without inventing a selection', async () => { + const metadata = parseSessionMetadata({ ...createMetadata(), repository: undefined }); + const result = await buildPromptWrapperRequests(metadata); + expect(result.readyRequest.repo).toBeUndefined(); + expect(result.readyRequest.workspace).toMatchObject({ + branchName: 'session/agent_test', + strictBranch: false, + }); + }); + + it.each(['github', 'gitlab', 'bitbucket'] as const)( + 'rejects replacement %s credentials before wrapper readiness', + async provider => { + const base = provider === 'bitbucket' ? createBitbucketMetadata(false) : createMetadata(); + const repository = + provider === 'github' ? { type: 'github', repo: 'acme/repo' } : base.repository; + const identity = { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174099', + integrationOwner: + provider === 'bitbucket' + ? { type: 'org', id: base.identity.orgId } + : { type: 'user', id: 'user_test' }, + instanceUrl: + provider === 'github' + ? 'https://github.com' + : provider === 'gitlab' + ? 'https://gitlab.com' + : 'https://bitbucket.org', + }; + const metadata = parseSessionMetadata({ + ...base, + repository: { ...repository, resolvedIdentity: identity }, + }); + await expect(buildPromptWrapperRequests(metadata)).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + retryable: false, + message: 'Repository identity cannot change', + }); + expect(metadata.repository?.resolvedIdentity).toEqual(identity); + } + ); + + it('returns a retryable failure instead of credentials when identity persistence fails', async () => { + const metadata = createMetadata(); + await expect( + buildPromptWrapperRequests(metadata, env => { + env.CLOUD_AGENT_SESSION.get = vi.fn(() => ({ + getMetadata: async () => metadata, + updateResolvedRepositoryIdentity: async () => { + throw new Error('storage unavailable'); + }, + })) as never; + }) + ).rejects.toMatchObject({ + code: 'WORKSPACE_SETUP_FAILED', + retryable: true, + message: 'Unable to persist repository identity', + }); + expect(metadata.repository?.resolvedIdentity).toBeUndefined(); + }); + it('prefers and requires snapshot restore in wrapper readiness for clone metadata', async () => { const result = await buildPromptWrapperRequests(createCloneMetadata()); @@ -2164,6 +2530,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { tokenMocks.resolveManagedGitLabToken.mockResolvedValueOnce({ success: true, token: 'resolved-gitlab-token', + integrationId: 'integration_1', instanceUrl: 'https://gitlab.example.com:8443/gitlab', glabIsOAuth2: true, }); @@ -2988,6 +3355,12 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { value: { capability: 'kgh2.selected-user', installationId: '123', + identity: { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'user_test' }, + instanceUrl: 'https://github.com', + }, accountLogin: 'acme', appType: 'standard', source: 'user', @@ -3108,6 +3481,12 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { value: { capability: 'kgh2.installation', installationId: '123', + identity: { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'user_test' }, + instanceUrl: 'https://github.com', + }, accountLogin: 'acme', appType: 'standard', source: 'installation', @@ -3142,6 +3521,12 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { value: { capability: 'kgh2.selected-user', installationId: '123', + identity: { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: 'user_test' }, + instanceUrl: 'https://github.com', + }, accountLogin: 'acme', appType: 'standard', source: 'user', diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index c71e16b239..7577cffc3e 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -54,7 +54,13 @@ import { getEffectiveCredentialContainment, parseSessionMetadata, requiresContainmentSandbox, + preserveResolvedRepositoryIdentity, + withResolvedRepositoryIdentity, } from './persistence/session-metadata.js'; +import { + normalizeRepositoryIdentity, + type ResolvedRepositoryIdentity, +} from './session/session-requests.js'; import { withDORetry } from './utils/do-retry.js'; import { resolveSessionStub } from './sandbox-session/session-stub.js'; import { decryptWithPrivateKey, mergeEnvVarsWithSecrets } from './utils/encryption.js'; @@ -1705,11 +1711,57 @@ export class SessionService { return session; } + private async persistRepositoryIdentity( + env: PersistenceEnv, + metadata: CloudAgentSessionState, + identity: ResolvedRepositoryIdentity + ): Promise { + let next: CloudAgentSessionState; + try { + next = withResolvedRepositoryIdentity(metadata, identity); + } catch { + throw ExecutionError.invalidRequest('Repository identity cannot change'); + } + try { + const current = await withDORetry( + () => resolveSessionStub(env, metadata.identity.userId, metadata.identity.sessionId), + stub => + stub.updateResolvedRepositoryIdentity( + { identity: metadata.identity, repository: metadata.repository }, + identity + ), + 'updateResolvedRepositoryIdentity' + ); + Object.assign(metadata, { repository: next.repository }, current); + } catch (error) { + if (error instanceof Error && error.message.includes('Repository identity cannot change')) { + throw ExecutionError.invalidRequest('Repository identity cannot change'); + } + throw ExecutionError.workspaceSetupFailed('Unable to persist repository identity'); + } + } + async resolveWorkspaceTokens( env: PersistenceEnv, metadata: CloudAgentSessionState, sandboxId: SandboxId ): Promise { + if (metadata.repository) { + const persisted = await fetchSessionMetadata( + env, + metadata.identity.userId, + metadata.identity.sessionId + ); + if (persisted?.repository?.resolvedIdentity) { + try { + metadata.repository = preserveResolvedRepositoryIdentity(persisted, metadata).repository; + } catch { + throw ExecutionError.invalidRequest('Repository identity cannot change'); + } + } + } + const identity = normalizeRepositoryIdentity(metadata.repository ?? {}); + const resolvedIdentity = identity.kind === 'resolved' ? identity : undefined; const github = githubRepository(metadata); const git = gitRepository(metadata); const credentialContainment = getEffectiveCredentialContainment(metadata); @@ -1732,9 +1784,14 @@ export class SessionService { githubRepo: github.repo, userId: metadata.identity.userId, orgId: metadata.identity.orgId, - ...(github.githubIntegrationId - ? { expectedIntegrationId: github.githubIntegrationId } - : {}), + ...(resolvedIdentity + ? { + expectedIntegrationId: resolvedIdentity.integrationId, + expectedIntegrationOwner: resolvedIdentity.integrationOwner, + } + : github.githubIntegrationId + ? { expectedIntegrationId: github.githubIntegrationId } + : {}), allowUserAuthorization: metadata.identity.createdOnPlatform === 'cloud-agent-web' || metadata.identity.createdOnPlatform === 'slack', @@ -1748,10 +1805,23 @@ export class SessionService { }) : await resolveCloudAgentGitHubAuthForRepo(env, authParams); if (!result.success) { + if (result.error.reason === 'service_compatibility_error') { + throw ExecutionError.workspaceSetupFailed(result.error.message); + } throw ExecutionError.invalidRequest( `GitHub token or active app installation required for this repository (${result.error.reason})` ); } + if (result.value.identity.kind === 'resolved') { + await this.persistRepositoryIdentity(env, metadata, result.value.identity); + } else if (resolvedIdentity || github.githubIntegrationId) { + throw ExecutionError.workspaceSetupFailed( + 'GitHub token service cannot prove the repository identity (service_compatibility_error)' + ); + } + // Old unpinned GitHub responses omit both identity fields. This retains legacy + // checkout, not exact identity. Remove after old deployments/records disappear + // and the 30-day ledger window expires; never apply this to other providers. githubToken = 'capability' in result.value ? result.value.capability : result.value.githubToken; githubInstallationId = result.value.installationId; @@ -1788,10 +1858,23 @@ export class SessionService { }), orgId: metadata.identity.orgId, createdOnPlatform: metadata.identity.createdOnPlatform, + ...(resolvedIdentity + ? { expectedIntegrationId: resolvedIdentity.integrationId } + : git.type === 'gitlab' && git.gitlabIntegrationId + ? { expectedIntegrationId: git.gitlabIntegrationId } + : {}), }); if (!result.success) { throw ExecutionError.invalidRequest(gitLabTokenLookupFailureMessage(result.reason)); } + await this.persistRepositoryIdentity(env, metadata, { + kind: 'resolved', + integrationId: result.value.integrationId, + integrationOwner: metadata.identity.orgId + ? { type: 'org', id: metadata.identity.orgId } + : { type: 'user', id: metadata.identity.userId }, + instanceUrl: result.value.instanceOrigin, + }); gitToken = result.value.capability; gitlabCapabilityGitUrl = result.value.gitUrl; gitlabTokenManaged = true; @@ -1803,10 +1886,23 @@ export class SessionService { orgId: metadata.identity.orgId, repositoryUrl: git.url, createdOnPlatform: metadata.identity.createdOnPlatform, + ...(resolvedIdentity + ? { expectedIntegrationId: resolvedIdentity.integrationId } + : git.type === 'gitlab' && git.gitlabIntegrationId + ? { expectedIntegrationId: git.gitlabIntegrationId } + : {}), }); if (!result.success) { throw ExecutionError.invalidRequest(gitLabTokenLookupFailureMessage(result.reason)); } + await this.persistRepositoryIdentity(env, metadata, { + kind: 'resolved', + integrationId: result.integrationId, + integrationOwner: metadata.identity.orgId + ? { type: 'org', id: metadata.identity.orgId } + : { type: 'user', id: metadata.identity.userId }, + instanceUrl: result.instanceUrl, + }); gitToken = result.token; gitlabTokenManaged = true; gitlabInstanceUrl = result.instanceUrl; @@ -1838,9 +1934,11 @@ export class SessionService { outboundContainerId: getOutboundContainerId(env, sandboxId, { managedScmContainment: containmentSandboxRequired, }), - ...(git.bitbucketIntegrationId - ? { expectedIntegrationId: git.bitbucketIntegrationId } - : {}), + ...(resolvedIdentity + ? { expectedIntegrationId: resolvedIdentity.integrationId } + : git.bitbucketIntegrationId + ? { expectedIntegrationId: git.bitbucketIntegrationId } + : {}), workspaceUuid: git.workspaceUuid, repositoryUuid: git.repositoryUuid, repositoryUrl: git.url, @@ -1860,6 +1958,12 @@ export class SessionService { } throw ExecutionError.invalidRequest(message); } + await this.persistRepositoryIdentity(env, metadata, { + kind: 'resolved', + integrationId: result.value.integrationId, + integrationOwner: { type: 'org', id: metadata.identity.orgId }, + instanceUrl: 'https://bitbucket.org', + }); gitToken = result.value.capability; // The canonical clone URL is resolved from the workspace/repo UUIDs at // issue time; git.url may carry a stale/renamed slug. Redeem validates @@ -1871,9 +1975,11 @@ export class SessionService { const result = await resolveManagedBitbucketToken(env, { userId: metadata.identity.userId, orgId: metadata.identity.orgId, - ...(git.bitbucketIntegrationId - ? { expectedIntegrationId: git.bitbucketIntegrationId } - : {}), + ...(resolvedIdentity + ? { expectedIntegrationId: resolvedIdentity.integrationId } + : git.bitbucketIntegrationId + ? { expectedIntegrationId: git.bitbucketIntegrationId } + : {}), workspaceUuid: git.workspaceUuid, repositoryUuid: git.repositoryUuid, repositoryUrl: git.url, @@ -1886,6 +1992,12 @@ export class SessionService { } throw ExecutionError.invalidRequest(message); } + await this.persistRepositoryIdentity(env, metadata, { + kind: 'resolved', + integrationId: result.integrationId, + integrationOwner: { type: 'org', id: metadata.identity.orgId }, + instanceUrl: 'https://bitbucket.org', + }); gitToken = result.token; bitbucketTokenManaged = true; } diff --git a/services/cloud-agent-next/src/session/session-prepare.test.ts b/services/cloud-agent-next/src/session/session-prepare.test.ts index 15476eed5b..d32589d1f7 100644 --- a/services/cloud-agent-next/src/session/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session/session-prepare.test.ts @@ -213,6 +213,20 @@ function makeEnv(doStub: ReturnType): Env { HYPERDRIVE: { connectionString: 'postgres://session-create-test', } as Env['HYPERDRIVE'], + GIT_TOKEN_SERVICE: { + getGitLabToken: vi.fn().mockResolvedValue({ + success: true, + token: 'managed-token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + instanceUrl: 'https://gitlab.com', + glabIsOAuth2: true, + }), + getBitbucketToken: vi.fn().mockResolvedValue({ + success: true, + token: 'managed-token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + }), + }, } as unknown as Env; } @@ -415,16 +429,17 @@ describe('createSessionWithLedger admission ladder', () => { it('passes a normalized Bitbucket repository URL to the session-ingest create call', async () => { const ctx = makeContext(makeDoStub()); + const orgId = '123e4567-e89b-12d3-a456-426614174030'; await runCreate( ctx, makeRequest({ - options: { operationKey: OPERATION_KEY }, + options: { operationKey: OPERATION_KEY, kilocodeOrganizationId: orgId }, repository: { type: 'bitbucket', url: 'https://bitbucket.org/acme/widgets.git', - workspaceUuid: 'workspace-1', - repositoryUuid: 'repo-1', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', }, }) ); @@ -434,7 +449,7 @@ describe('createSessionWithLedger admission ladder', () => { CLOUD_AGENT_SESSION_ID, USER_ID, expect.any(Object), - undefined, + orgId, 'cloud-agent', expect.any(String), 'https://bitbucket.org/acme/widgets', @@ -1524,6 +1539,184 @@ describe('createSessionWithLedger changed-intent rejection', () => { expect(recordOperationProgressMock).not.toHaveBeenCalled(); } + it('fences the selected pin and branch before a retryable authorization failure', async () => { + const row = makeLedgerRow(); + recordOperationProgressMock.mockImplementation( + async (_db: unknown, _id: string, progress: Record) => { + row.canonical_result = { ...row.canonical_result, ...progress }; + } + ); + const ctx = makeContext(makeDoStub()); + ctx.env.GIT_TOKEN_SERVICE = { + getGitLabToken: vi.fn().mockRejectedValue(new Error('binding unavailable')), + } as never; + const request = makeRequest({ + repository: { + type: 'gitlab', + url: 'https://gitlab.com/group/repo.git', + gitlabIntegrationId: '123e4567-e89b-12d3-a456-426614174022', + branch: 'release/selected', + }, + }); + admitOperationMock.mockResolvedValueOnce({ admission: 'admitted', row }); + await expect(runCreate(ctx, request)).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' }); + admitOperationMock.mockResolvedValueOnce({ admission: 'takeover', row }); + await expect( + runCreate(ctx, { + ...request, + repository: { ...request.repository, branch: 'release/different' }, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'session_creation_failed' }); + recordOperationProgressMock.mockReset().mockResolvedValue(undefined); + }); + + it.each(['token_refresh_failed', 'project_lookup_failed'])( + 'recovers from %s with the same launch key and repository selection', + async reason => { + const request = makeRequest({ + repository: { + type: 'gitlab', + url: 'https://gitlab.example.com/gitlab/group/sub/repo.git', + gitlabIntegrationId: '123e4567-e89b-12d3-a456-426614174022', + branch: 'release/selected', + }, + options: { operationKey: OPERATION_KEY }, + }); + const original = structuredClone(request); + const row = makeLedgerRow({ canonical_result: {} }); + recordOperationProgressMock.mockImplementation( + async (_db: unknown, _id: string, progress: Record) => { + row.canonical_result = { ...row.canonical_result, ...progress }; + } + ); + let admissions = 0; + admitOperationMock.mockImplementation( + async (_db: unknown, input: { operationKey: string }) => { + if (input.operationKey !== OPERATION_KEY) throw new Error('Launch key changed'); + return { admission: admissions++ === 0 ? 'admitted' : 'takeover', row }; + } + ); + const storedRepositories: unknown[] = []; + const doStub = makeDoStub({ + createSessionWithInitialAdmission: vi.fn(async command => { + storedRepositories.push(command.repository); + return { + success: true, + outcome: 'queued', + messageId: INITIAL_MESSAGE_ID, + compatibilityDelivery: 'queued', + }; + }), + }); + const ctx = makeContext(doStub); + const getGitLabToken = vi + .fn() + .mockResolvedValueOnce({ success: false, reason }) + .mockResolvedValue({ + success: true, + token: 'private-token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + instanceUrl: 'https://gitlab.example.com/gitlab', + glabIsOAuth2: true, + }); + ctx.env.GIT_TOKEN_SERVICE = { getGitLabToken } as never; + try { + await expect(runCreate(ctx, request)).rejects.toMatchObject({ + code: 'SERVICE_UNAVAILABLE', + }); + expect(storedRepositories).toEqual([]); + await expect(runCreate(ctx, request)).resolves.toEqual({ + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + }); + expect(storedRepositories).toEqual([ + { + ...original.repository, + resolvedIdentity: { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: USER_ID }, + instanceUrl: 'https://gitlab.example.com/gitlab', + }, + }, + ]); + expect(request).toEqual(original); + expect(JSON.stringify(row.canonical_result)).not.toContain('private-token'); + } finally { + recordOperationProgressMock.mockReset().mockResolvedValue(undefined); + } + } + ); + + it('reuses a durable GitLab resolution after an uncertain creation response', async () => { + const request = makeRequest({ + repository: { + type: 'gitlab', + url: 'https://gitlab.example.com/gitlab/group/sub/repo.git', + branch: 'release/selected', + }, + options: { operationKey: OPERATION_KEY }, + }); + const identity = { + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'user', id: USER_ID }, + instanceUrl: 'https://gitlab.example.com/gitlab', + }; + const row = makeLedgerRow({ canonical_result: {} }); + recordOperationProgressMock.mockImplementation( + async (_db: unknown, _id: string, progress: Record) => { + row.canonical_result = { ...row.canonical_result, ...progress }; + } + ); + const persisted: unknown[] = []; + const doStub = makeDoStub({ + createSessionWithInitialAdmission: vi.fn(async command => { + persisted.push(command.repository); + throw new Error('response lost'); + }), + }); + const ctx = makeContext(doStub); + ctx.env.GIT_TOKEN_SERVICE = { + getGitLabToken: vi.fn().mockResolvedValue({ + success: true, + token: 'private-token', + integrationId: identity.integrationId, + instanceUrl: identity.instanceUrl, + glabIsOAuth2: true, + }), + } as never; + admitOperationMock.mockResolvedValueOnce({ admission: 'admitted', row }); + await expect(runCreate(ctx, request)).rejects.toThrow('response lost'); + expect(row.canonical_result?.repositoryIdentity).toEqual(identity); + expect(persisted).toEqual([{ ...request.repository, resolvedIdentity: identity }]); + expect(JSON.stringify(row.canonical_result)).not.toContain('private-token'); + + // Recreate only after the existing ladder confirms no ownership row. + admitOperationMock.mockResolvedValueOnce({ admission: 'takeover', row }); + getPgDbMock.mockReturnValue(makeDb([[], [{ email: 'test@example.com' }]])); + ctx.env.GIT_TOKEN_SERVICE = { + getGitLabToken: vi.fn(async (params: { expectedIntegrationId?: string }) => + params.expectedIntegrationId === identity.integrationId + ? { success: false, reason: 'integration_mismatch' } + : { + success: true, + token: 'replacement-token', + integrationId: 'another-integration', + instanceUrl: identity.instanceUrl, + glabIsOAuth2: true, + } + ), + } as never; + await expect(runCreate(ctx, request)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'GitLab repository authorization failed (integration_mismatch)', + }); + expect(persisted).toHaveLength(1); + expect(row.canonical_result?.repositoryIdentity).toEqual(identity); + recordOperationProgressMock.mockReset().mockResolvedValue(undefined); + }); + it('records the create intent fingerprint with the first admitted create progress', async () => { const request = originalRequest(); const doStub = makeDoStub(); @@ -1607,6 +1800,46 @@ describe('createSessionWithLedger changed-intent rejection', () => { options: ORIGINAL_OPTIONS, }), }, + ...(['gitlab', 'bitbucket'] as const).flatMap(type => { + const repository = + type === 'gitlab' + ? { + type, + url: 'https://gitlab.example.com/gitlab/group/sub/repo.git', + gitlabIntegrationId: '123e4567-e89b-12d3-a456-426614174022', + branch: 'release/selected', + } + : { + type, + url: 'https://bitbucket.org/group/repo.git', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + bitbucketIntegrationId: '123e4567-e89b-12d3-a456-426614174022', + branch: 'release/selected', + }; + return [ + { + name: `the ${type} integration`, + original: makeRequest({ repository, options: ORIGINAL_OPTIONS }), + retry: makeRequest({ + repository: { + ...repository, + [type === 'gitlab' ? 'gitlabIntegrationId' : 'bitbucketIntegrationId']: + '123e4567-e89b-12d3-a456-426614174099', + }, + options: ORIGINAL_OPTIONS, + }), + }, + { + name: `the ${type} branch`, + original: makeRequest({ repository, options: ORIGINAL_OPTIONS }), + retry: makeRequest({ + repository: { ...repository, branch: 'release/different' }, + options: ORIGINAL_OPTIONS, + }), + }, + ]; + }), { name: 'the model', retry: makeRequest({ agent: { mode: 'code', model: 'gpt-4' }, options: ORIGINAL_OPTIONS }), diff --git a/services/cloud-agent-next/src/session/session-registration.ts b/services/cloud-agent-next/src/session/session-registration.ts index 1c3193d935..87f0cdc0c2 100644 --- a/services/cloud-agent-next/src/session/session-registration.ts +++ b/services/cloud-agent-next/src/session/session-registration.ts @@ -61,6 +61,8 @@ import type { } from '../execution/types.js'; import { throwAdmissionError } from './queue-message.js'; import type { SessionCreateRequest, SessionRepositoryRequest } from './session-requests.js'; +import { ResolvedRepositoryIdentitySchema } from '../persistence/session-metadata.js'; +import { assertRepositoryAccessBeforeSessionCreation } from './validate-repository-access.js'; export type SessionRegistrationInput = SessionCreateRequest; @@ -1130,7 +1132,12 @@ function repositoryCreateIntent(repository: SessionRepositoryRequest): Record { + const stored = row.canonical_result?.repositoryIdentity; + const repository = + stored === undefined + ? input.repository + : { + ...input.repository, + resolvedIdentity: ResolvedRepositoryIdentitySchema.parse(stored), + }; + if ( + repository.type === 'git' || + (repository.type === 'github' && + !repository.githubIntegrationId && + !repository.resolvedIdentity) + ) { + return input; + } + // Fence the submitted intent even when authorization fails or its response is lost. + await recordOperationProgress(db, row.id, { + [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: await sessionCreateIntentFingerprint(input), + }); + const resolvedIdentity = await assertRepositoryAccessBeforeSessionCreation({ + env: ctx.env, + userId: ctx.userId, + orgId: input.options?.kilocodeOrganizationId, + createdOnPlatform: input.options?.createdOnPlatform, + repository, + }); + if (!resolvedIdentity) return input; + if ( + repository.resolvedIdentity && + JSON.stringify(ResolvedRepositoryIdentitySchema.parse(repository.resolvedIdentity)) !== + JSON.stringify(resolvedIdentity) + ) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'session_creation_failed' }); + } + // Record authorization separately so old request fingerprint bytes do not change. + // A lost response must not repeat an unpinned owner lookup. + await recordOperationProgress(db, row.id, { + repositoryIdentity: resolvedIdentity, + }); + return { ...input, repository: { ...repository, resolvedIdentity } }; +} + async function executeLedgerCreate( input: SessionRegistrationInput, ctx: SessionRegistrationContext, @@ -1419,6 +1470,7 @@ async function executeLedgerCreate( row: OperationLedgerRow, admissionKind: 'new' | 'takeover' ): Promise { + input = await resolveLedgerRepository(input, ctx, db, row); const hooks = await buildLedgerHooks(input, ctx, options, db, row, admissionKind); const billingOrigin = { billingOrigin: options.billingOrigin }; let result: { cloudAgentSessionId: string; kiloSessionId: string }; @@ -1498,6 +1550,7 @@ async function resumeCloneCreate( throw creationInProgressError(); } + input = await resolveLedgerRepository(input, ctx, db, row); const hooks = await buildLedgerHooks(input, ctx, options, db, row, 'takeover'); const sessionService = new SessionService(); const createdOnPlatform = input.options?.createdOnPlatform ?? 'cloud-agent'; diff --git a/services/cloud-agent-next/src/session/session-requests.ts b/services/cloud-agent-next/src/session/session-requests.ts index dd38afb150..5c7f8e0eac 100644 --- a/services/cloud-agent-next/src/session/session-requests.ts +++ b/services/cloud-agent-next/src/session/session-requests.ts @@ -5,6 +5,7 @@ import type { SessionFinalization, } from '../execution/types.js'; import type { SessionProfileBundle } from '../session-profile.js'; +import type { Owner } from '../types.js'; export type ProfileOverrides = { envVars?: Record; @@ -16,7 +17,26 @@ export type ProfileOverrides = { appendSystemPrompt?: string; }; -export type SessionRepositoryRequest = +export type ResolvedRepositoryIdentity = { + kind: 'resolved'; + integrationId: string; + integrationOwner: Owner; + instanceUrl: string; +}; + +export type RepositoryIdentityResolution = + | ResolvedRepositoryIdentity + | { kind: 'legacy-unresolved' }; + +export function normalizeRepositoryIdentity(repository: { + resolvedIdentity?: ResolvedRepositoryIdentity; +}): RepositoryIdentityResolution { + // Old requests and records lack authorized identity. Remove this fallback only + // after old clients/records disappear and the 30-day ledger window expires. + return repository.resolvedIdentity ?? { kind: 'legacy-unresolved' }; +} + +export type SessionRepositoryRequest = ( | { type: 'github'; repo: string; @@ -26,6 +46,7 @@ export type SessionRepositoryRequest = | { type: 'gitlab'; url: string; + gitlabIntegrationId?: string; branch?: string; } | { @@ -41,7 +62,8 @@ export type SessionRepositoryRequest = url: string; token?: string; branch?: string; - }; + } +) & { resolvedIdentity?: ResolvedRepositoryIdentity }; export type SessionRuntimeIntent = { devcontainer?: boolean; diff --git a/services/cloud-agent-next/src/session/validate-repository-access.test.ts b/services/cloud-agent-next/src/session/validate-repository-access.test.ts index 4740649c32..403e5da702 100644 --- a/services/cloud-agent-next/src/session/validate-repository-access.test.ts +++ b/services/cloud-agent-next/src/session/validate-repository-access.test.ts @@ -27,6 +27,8 @@ describe('GitHub session creation preflight', () => { success: true, token: 'token', installationId: '123', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'org', id: '123e4567-e89b-12d3-a456-426614174030' }, accountLogin: 'acme', appType: 'standard', }); @@ -44,7 +46,12 @@ describe('GitHub session creation preflight', () => { githubIntegrationId: expectedIntegrationId, }, }) - ).resolves.toBeUndefined(); + ).resolves.toEqual({ + kind: 'resolved', + integrationId: expectedIntegrationId, + integrationOwner: { type: 'org', id: orgId }, + instanceUrl: 'https://github.com', + }); expect(getTokenForRepo).toHaveBeenCalledWith({ githubRepo: 'acme/repo', userId: 'user-1', @@ -53,6 +60,44 @@ describe('GitHub session creation preflight', () => { }); }); + it.each(['pin', 'resolved-owner'] as const)( + 'returns a retryable compatibility error for an old producer with a %s', + async selection => { + const integrationId = '123e4567-e89b-12d3-a456-426614174022'; + const getTokenForRepo = vi.fn().mockResolvedValue({ + success: true, + token: 'old-token', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + }); + await expect( + assertRepositoryAccessBeforeSessionCreation({ + env: { GIT_TOKEN_SERVICE: { getTokenForRepo } } as never, + userId: 'user-1', + orgId: 'billing-org', + repository: { + type: 'github', + repo: 'acme/repo', + ...(selection === 'pin' + ? { githubIntegrationId: integrationId } + : { + resolvedIdentity: { + kind: 'resolved', + integrationId, + integrationOwner: { type: 'user', id: 'user-1' }, + instanceUrl: 'https://github.com', + }, + }), + }, + }) + ).rejects.toMatchObject({ + code: 'SERVICE_UNAVAILABLE', + message: 'GitHub repository authorization failed (service_compatibility_error)', + }); + } + ); + it('rejects an integration mismatch before session allocation', async () => { const getTokenForRepo = vi.fn().mockResolvedValue({ success: false, @@ -76,9 +121,81 @@ describe('GitHub session creation preflight', () => { }); }); +describe('GitLab session creation preflight', () => { + const pin = '123e4567-e89b-12d3-a456-426614174022'; + const url = 'https://gitlab.example.com/gitlab/group/sub/repo.git'; + + it.each([ + { orgId: undefined, gitlabIntegrationId: undefined }, + { orgId: undefined, gitlabIntegrationId: pin }, + { orgId: 'org-1', gitlabIntegrationId: undefined }, + { orgId: 'org-1', gitlabIntegrationId: pin }, + ])('resolves exact owner, URL, and optional pin %j', async ({ orgId, gitlabIntegrationId }) => { + const getGitLabToken = vi.fn( + async (params: { orgId?: string; expectedIntegrationId?: string; repositoryUrl?: string }) => + params.orgId === orgId && + params.expectedIntegrationId === gitlabIntegrationId && + params.repositoryUrl === url + ? { + success: true, + token: 'private-token', + instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: pin, + glabIsOAuth2: true, + } + : { success: false, reason: 'integration_mismatch' } + ); + const result = await assertRepositoryAccessBeforeSessionCreation({ + env: { GIT_TOKEN_SERVICE: { getGitLabToken } } as never, + userId: 'oauth/user', + orgId, + repository: { type: 'gitlab', url, gitlabIntegrationId }, + }); + expect(result).toEqual({ + kind: 'resolved', + integrationId: pin, + integrationOwner: orgId ? { type: 'org', id: orgId } : { type: 'user', id: 'oauth/user' }, + instanceUrl: 'https://gitlab.example.com/gitlab', + }); + expect(JSON.stringify(result)).not.toContain('private-token'); + }); + + it.each([ + ['integration_mismatch', 'BAD_REQUEST'], + ['ambiguous_integration', 'BAD_REQUEST'], + ['no_integration_found', 'BAD_REQUEST'], + ['not_authorized', 'BAD_REQUEST'], + ['no_project_token', 'BAD_REQUEST'], + ['token_refresh_failed', 'SERVICE_UNAVAILABLE'], + ['project_lookup_failed', 'SERVICE_UNAVAILABLE'], + ['service_not_configured', 'SERVICE_UNAVAILABLE'], + ['rpc_error', 'SERVICE_UNAVAILABLE'], + ['database_not_configured', 'SERVICE_UNAVAILABLE'], + ])('preserves %s as %s', async (reason, code) => { + await expect( + assertRepositoryAccessBeforeSessionCreation({ + env: { + GIT_TOKEN_SERVICE: { + getGitLabToken: vi.fn().mockResolvedValue({ success: false, reason }), + }, + } as never, + userId: 'user-1', + repository: { type: 'gitlab', url, gitlabIntegrationId: pin }, + }) + ).rejects.toMatchObject({ + code, + message: `GitLab repository authorization failed (${reason})`, + }); + }); +}); + describe('Bitbucket session creation preflight', () => { it('validates organization sessions against the organization-owned integration', async () => { - const getBitbucketToken = vi.fn().mockResolvedValue({ success: true, token: 'token' }); + const getBitbucketToken = vi.fn().mockResolvedValue({ + success: true, + token: 'token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + }); const orgId = '123e4567-e89b-12d3-a456-426614174030'; await expect( @@ -88,7 +205,12 @@ describe('Bitbucket session creation preflight', () => { orgId, repository, }) - ).resolves.toBeUndefined(); + ).resolves.toEqual({ + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'org', id: orgId }, + instanceUrl: 'https://bitbucket.org', + }); expect(getBitbucketToken).toHaveBeenCalledWith({ userId: 'user-1', orgId, @@ -99,7 +221,11 @@ describe('Bitbucket session creation preflight', () => { }); it('forwards an expected integration id when the repository carries one', async () => { - const getBitbucketToken = vi.fn().mockResolvedValue({ success: true, token: 'token' }); + const getBitbucketToken = vi.fn().mockResolvedValue({ + success: true, + token: 'token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + }); const orgId = '123e4567-e89b-12d3-a456-426614174030'; const integrationId = '123e4567-e89b-12d3-a456-426614174022'; @@ -110,7 +236,12 @@ describe('Bitbucket session creation preflight', () => { orgId, repository: { ...repository, bitbucketIntegrationId: integrationId }, }) - ).resolves.toBeUndefined(); + ).resolves.toEqual({ + kind: 'resolved', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + integrationOwner: { type: 'org', id: orgId }, + instanceUrl: 'https://bitbucket.org', + }); expect(getBitbucketToken).toHaveBeenCalledWith({ userId: 'user-1', orgId, @@ -122,7 +253,11 @@ describe('Bitbucket session creation preflight', () => { }); it('rejects personal Bitbucket sessions before invoking the service binding', async () => { - const getBitbucketToken = vi.fn().mockResolvedValue({ success: true, token: 'token' }); + const getBitbucketToken = vi.fn().mockResolvedValue({ + success: true, + token: 'token', + integrationId: '123e4567-e89b-12d3-a456-426614174022', + }); await expect( assertRepositoryAccessBeforeSessionCreation({ @@ -137,6 +272,29 @@ describe('Bitbucket session creation preflight', () => { expect(getBitbucketToken).not.toHaveBeenCalled(); }); + it('rejects a stale Bitbucket pin without selecting a replacement', async () => { + await expect( + assertRepositoryAccessBeforeSessionCreation({ + env: { + GIT_TOKEN_SERVICE: { + getBitbucketToken: vi + .fn() + .mockResolvedValue({ success: false, reason: 'integration_mismatch' }), + }, + } as never, + userId: 'user-1', + orgId: '123e4567-e89b-12d3-a456-426614174030', + repository: { + ...repository, + bitbucketIntegrationId: '123e4567-e89b-12d3-a456-426614174022', + }, + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Bitbucket repository authorization failed (integration_mismatch)', + }); + }); + it('keeps insufficient workspace permissions distinguishable', async () => { const getBitbucketToken = vi.fn().mockResolvedValue({ success: false, diff --git a/services/cloud-agent-next/src/session/validate-repository-access.ts b/services/cloud-agent-next/src/session/validate-repository-access.ts index dafda2e307..9a6513a275 100644 --- a/services/cloud-agent-next/src/session/validate-repository-access.ts +++ b/services/cloud-agent-next/src/session/validate-repository-access.ts @@ -1,38 +1,86 @@ import { TRPCError } from '@trpc/server'; import type { PersistenceEnv } from '../persistence/types.js'; +import { ResolvedRepositoryIdentitySchema } from '../persistence/session-metadata.js'; import { isTemporaryManagedBitbucketTokenFailure, + isTemporaryManagedGitLabTokenFailure, resolveGitHubTokenForRepo, resolveManagedBitbucketToken, + resolveManagedGitLabToken, } from '../services/git-token-service-client.js'; -import type { SessionRepositoryRequest } from './session-requests.js'; +import { + normalizeRepositoryIdentity, + type ResolvedRepositoryIdentity, + type SessionRepositoryRequest, +} from './session-requests.js'; export async function assertRepositoryAccessBeforeSessionCreation(input: { env: PersistenceEnv; userId: string; orgId?: string; + createdOnPlatform?: string; repository: SessionRepositoryRequest; -}): Promise { - if (input.repository.type === 'github' && input.repository.githubIntegrationId) { +}): Promise { + const repository = input.repository; + const identity = normalizeRepositoryIdentity(repository); + const resolvedId = identity.kind === 'resolved' ? identity.integrationId : undefined; + if (repository.type === 'github') { + // Unpinned old GitHub requests retain lazy authorization. Remove this fallback + // after old clients/records disappear and the 30-day ledger window expires. + if (!resolvedId && !repository.githubIntegrationId) return; const result = await resolveGitHubTokenForRepo(input.env, { - githubRepo: input.repository.repo, + githubRepo: repository.repo, userId: input.userId, ...(input.orgId ? { orgId: input.orgId } : {}), - expectedIntegrationId: input.repository.githubIntegrationId, + expectedIntegrationId: resolvedId ?? repository.githubIntegrationId, + ...(identity.kind === 'resolved' + ? { expectedIntegrationOwner: identity.integrationOwner } + : {}), }); - if (!result.success) { + if (!result.success || result.value.identity.kind !== 'resolved') { + const reason = result.success ? 'service_compatibility_error' : result.error.reason; throw new TRPCError({ code: - result.error.reason === 'service_not_configured' || result.error.reason === 'rpc_error' + reason === 'service_not_configured' || + reason === 'rpc_error' || + reason === 'service_compatibility_error' ? 'SERVICE_UNAVAILABLE' : 'BAD_REQUEST', - message: `GitHub repository authorization failed (${result.error.reason})`, + message: `GitHub repository authorization failed (${reason})`, }); } - return; + return result.value.identity; } - if (input.repository.type !== 'bitbucket') return; + if (repository.type === 'gitlab') { + const result = await resolveManagedGitLabToken(input.env, { + userId: input.userId, + ...(input.orgId ? { orgId: input.orgId } : {}), + repositoryUrl: repository.url, + ...((resolvedId ?? repository.gitlabIntegrationId) + ? { expectedIntegrationId: resolvedId ?? repository.gitlabIntegrationId } + : {}), + ...(input.createdOnPlatform ? { createdOnPlatform: input.createdOnPlatform } : {}), + }); + if (!result.success) { + throw new TRPCError({ + code: isTemporaryManagedGitLabTokenFailure(result.reason) + ? 'SERVICE_UNAVAILABLE' + : 'BAD_REQUEST', + message: `GitLab repository authorization failed (${result.reason})`, + }); + } + return ResolvedRepositoryIdentitySchema.parse({ + kind: 'resolved', + integrationId: result.integrationId, + integrationOwner: input.orgId + ? { type: 'org', id: input.orgId } + : { type: 'user', id: input.userId }, + instanceUrl: result.instanceUrl, + }); + } + + if (repository.type !== 'bitbucket') return; if (!input.orgId) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -43,12 +91,12 @@ export async function assertRepositoryAccessBeforeSessionCreation(input: { const result = await resolveManagedBitbucketToken(input.env, { userId: input.userId, orgId: input.orgId, - ...(input.repository.bitbucketIntegrationId - ? { expectedIntegrationId: input.repository.bitbucketIntegrationId } + ...((resolvedId ?? repository.bitbucketIntegrationId) + ? { expectedIntegrationId: resolvedId ?? repository.bitbucketIntegrationId } : {}), - workspaceUuid: input.repository.workspaceUuid, - repositoryUuid: input.repository.repositoryUuid, - repositoryUrl: input.repository.url, + workspaceUuid: repository.workspaceUuid, + repositoryUuid: repository.repositoryUuid, + repositoryUrl: repository.url, }); if (!result.success) { throw new TRPCError({ @@ -58,4 +106,10 @@ export async function assertRepositoryAccessBeforeSessionCreation(input: { message: `Bitbucket repository authorization failed (${result.reason})`, }); } + return ResolvedRepositoryIdentitySchema.parse({ + kind: 'resolved', + integrationId: result.integrationId, + integrationOwner: { type: 'org', id: input.orgId }, + instanceUrl: 'https://bitbucket.org', + }); } diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index 6ffab2a897..72d3a77eb4 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -1,4 +1,6 @@ import type { getSandbox, ExecutionSession, Sandbox } from '@cloudflare/sandbox'; +import type { Owner } from '../../../packages/app-shared/src/code-review/repository-identity.js'; +export type { Owner }; import type { CloudAgentSession } from './persistence/CloudAgentSession.js'; import type { CloudAgentQueueReport } from '@kilocode/worker-utils/cloud-agent-queue-report'; import type { AccessibleCloudAgentSession } from '@kilocode/worker-utils/cloud-agent-session-access'; @@ -177,11 +179,16 @@ export type InterruptResult = { processesFound: boolean; }; +// Old GitHub RPC responses omit integrationId/integrationOwner. The client normalizes +// that form explicitly. Require these fields after old deployments/clients/records +// disappear and the 30-day ledger window expires. type GetTokenForRepoResult = | { success: true; token: string; installationId: string; + integrationId?: string; + integrationOwner?: Owner; accountLogin: string; appType: 'standard' | 'lite'; } @@ -215,6 +222,7 @@ type ManagedGitHubAuthParams = { userId: string; orgId?: string; expectedIntegrationId?: string; + expectedIntegrationOwner?: Owner; allowUserAuthorization: boolean; }; @@ -223,6 +231,8 @@ type GetCloudAgentAuthForRepoResult = success: true; githubToken: string; installationId: string; + integrationId?: string; + integrationOwner?: Owner; accountLogin: string; appType: 'standard' | 'lite'; source: 'user' | 'installation'; @@ -246,6 +256,8 @@ type IssueGitHubSessionCapabilityResult = success: true; capability: string; installationId: string; + integrationId?: string; + integrationOwner?: Owner; accountLogin: string; appType: 'standard' | 'lite'; source: 'user' | 'installation'; @@ -296,10 +308,17 @@ type GetGitLabTokenFailureReason = | 'ambiguous_integration' | 'project_lookup_failed' | 'no_project_token' - | 'invalid_instance_url'; + | 'invalid_instance_url' + | 'integration_mismatch'; type GetGitLabTokenResult = - | { success: true; token: string; instanceUrl: string; glabIsOAuth2: boolean } + | { + success: true; + token: string; + instanceUrl: string; + integrationId: string; + glabIsOAuth2: boolean; + } | { success: false; reason: GetGitLabTokenFailureReason }; type GitLabSessionIdentity = { @@ -364,11 +383,11 @@ export type BitbucketTokenFailureReason = | 'repository_mismatch'; type GetBitbucketTokenResult = - | { success: true; token: string } + | { success: true; token: string; integrationId: string } | { success: false; reason: BitbucketTokenFailureReason }; type IssueBitbucketSessionCapabilityResult = - | { success: true; capability: string; gitUrl: string } + | { success: true; capability: string; gitUrl: string; integrationId: string } | { success: false; reason: BitbucketTokenFailureReason | 'capability_configuration_error' }; type RedeemBitbucketSessionCapabilityResult = @@ -436,6 +455,7 @@ export type GitTokenService = { userId: string; orgId?: string; expectedIntegrationId?: string; + expectedIntegrationOwner?: Owner; }): Promise; getToken(installationId: string, appType?: 'standard' | 'lite'): Promise; getCloudAgentAuthForRepo?( @@ -453,6 +473,7 @@ export type GitTokenService = { getGitLabToken(params: { userId: string; orgId?: string; + expectedIntegrationId?: string; repositoryUrl?: string; createdOnPlatform?: string; }): Promise; @@ -469,6 +490,7 @@ export type GitTokenService = { userId: string; outboundContainerId: string; orgId?: string; + expectedIntegrationId?: string; createdOnPlatform?: string; }): Promise; redeemGitLabSessionCapability(params: { diff --git a/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts b/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts index 3df046ed69..baeee529be 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts @@ -1519,12 +1519,27 @@ describe('prepareWrapperBootstrapWorkspace', () => { }); }); - it('classifies strict missing branches', async () => { - const request = makeRequest(tmpDir); - request.workspace.strictBranch = true; - request.materialized.setupCommands = []; - expect( - prepareWrapperBootstrapWorkspace(request, undefined, { + it.each(['github', 'gitlab', 'bitbucket'] as const)( + 'classifies strict missing %s branches without a fallback', + async provider => { + const request = makeRequest(tmpDir); + if (provider !== 'github') { + request.repo = { + kind: 'git', + url: + provider === 'gitlab' + ? 'https://gitlab.example.com/gitlab/acme/repo.git' + : 'https://bitbucket.org/acme/repo.git', + platform: provider, + token: 'managed-token', + refreshRemote: true, + }; + } + request.workspace.branchName = 'release/selected'; + request.workspace.upstreamBranch = 'release/selected'; + request.workspace.strictBranch = true; + request.materialized.setupCommands = []; + const outcome = await prepareWrapperBootstrapWorkspace(request, undefined, { git: async args => { if (args[0] === 'clone') { await fsp.mkdir(path.join(request.workspace.workspacePath, '.git'), { @@ -1539,12 +1554,13 @@ describe('prepareWrapperBootstrapWorkspace', () => { imported: true, diffs: { applied: 0, skipped: 0, total: 0 }, }), - }) - ).rejects.toMatchObject({ - subtype: 'git_branch_missing', - retryable: false, - }); - }); + }).catch((error: unknown) => error); + expect(outcome).toMatchObject({ + subtype: 'git_branch_missing', + retryable: false, + }); + } + ); it('exposes redacted setup command and stderr on failure but redacts secrets', async () => { const request = makeRequest(tmpDir);