From 63e271ab2a75bc8e46462cb169741001a3ad45b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 29 Aug 2026 06:47:45 +0200 Subject: [PATCH 1/4] fix(git-token): return resolved Bitbucket integration identity --- .../bitbucket-runtime-token-resolver.test.ts | 158 ++++++++++++++---- .../src/bitbucket-runtime-token-resolver.ts | 8 +- services/git-token-service/src/index.test.ts | 142 ++++++++++++---- services/git-token-service/src/index.ts | 7 +- 4 files changed, 247 insertions(+), 68 deletions(-) diff --git a/services/git-token-service/src/bitbucket-runtime-token-resolver.test.ts b/services/git-token-service/src/bitbucket-runtime-token-resolver.test.ts index 9a94cbad61..c9a8086a4b 100644 --- a/services/git-token-service/src/bitbucket-runtime-token-resolver.test.ts +++ b/services/git-token-service/src/bitbucket-runtime-token-resolver.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { BitbucketApiError, type BitbucketRepository } from './bitbucket-api.js'; import { listBitbucketRepositories, + resolveBitbucketCapabilitySubject, resolveBitbucketToken, selectCachedBitbucketRepository, } from './bitbucket-runtime-token-resolver.js'; @@ -78,26 +79,48 @@ describe('Bitbucket runtime token resolver', () => { expect(deps.oauthAuthorizationService.getAuthorization).not.toHaveBeenCalled(); }); - it('releases only the opaque token after exact cached repository validation', async () => { - const deps = dependencies(); + it.each([ + ['legacy', {}], + ['pinned', { expectedIntegrationId: authorization.integrationId }], + ] as const)( + 'returns the authorized Workspace Access Token identity for a %s request', + async (_name, pin) => { + const deps = dependencies(); + deps.oauthAuthorizationService.getAuthorization.mockResolvedValue(oauthAuthorization); + const params = tokenParams(pin); - await expect(resolveBitbucketToken({} as CloudflareEnv, tokenParams(), deps)).resolves.toEqual({ - success: true, - token: 'ATCT-runtime-token', - }); - expect(deps.authorizationService.getAuthorization).toHaveBeenCalledWith({ - userId: 'member-1', - orgId: organizationId, - }); - expect(deps.findCachedRepository).toHaveBeenCalledWith({ - integrationId: authorization.integrationId, - organizationId, - workspace: authorization.workspace, - repositoryUuid, - }); - expect(deps.listRepositories).not.toHaveBeenCalled(); - expect(deps.oauthAuthorizationService.getAuthorization).not.toHaveBeenCalled(); - }); + await expect(resolveBitbucketToken({} as CloudflareEnv, params, deps)).resolves.toEqual({ + success: true, + token: 'ATCT-runtime-token', + integrationId: authorization.integrationId, + }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, params, deps) + ).resolves.toEqual({ + success: true, + subject: { + integrationId: authorization.integrationId, + workspaceUuid, + workspaceSlug: 'acme', + repositoryUuid, + repositoryFullName: 'acme/widgets', + token: 'ATCT-runtime-token', + }, + }); + expect(deps.authorizationService.getAuthorization).toHaveBeenCalledWith({ + userId: 'member-1', + orgId: organizationId, + }); + expect(deps.findCachedRepository).toHaveBeenCalledWith({ + integrationId: authorization.integrationId, + organizationId, + workspace: authorization.workspace, + repositoryUuid, + }); + expect(deps.listRepositories).not.toHaveBeenCalled(); + expect(deps.oauthAuthorizationService.getAuthorization).not.toHaveBeenCalled(); + } + ); it('lists repositories through the same organization-only static authorization', async () => { const deps = dependencies(); @@ -138,25 +161,65 @@ describe('Bitbucket runtime token resolver', () => { }); }); - it('uses the cached repository and Cloud Agent validity threshold for OAuth tokens', async () => { + it.each([ + ['legacy', {}], + ['pinned', { expectedIntegrationId: oauthAuthorization.integrationId }], + ] as const)( + 'returns the authorized OAuth identity and retains the validity threshold for a %s request', + async (_name, pin) => { + const deps = dependencies(); + deps.authorizationService.getAuthorization.mockResolvedValue({ status: 'not_connected' }); + deps.oauthAuthorizationService.getAuthorization.mockResolvedValue(oauthAuthorization); + const params = tokenParams(pin); + + await expect(resolveBitbucketToken({} as CloudflareEnv, params, deps)).resolves.toEqual({ + success: true, + token: 'oauth-runtime-token', + integrationId: oauthAuthorization.integrationId, + }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, params, deps) + ).resolves.toEqual({ + success: true, + subject: { + integrationId: oauthAuthorization.integrationId, + workspaceUuid, + workspaceSlug: 'acme', + repositoryUuid, + repositoryFullName: 'acme/widgets', + token: 'oauth-runtime-token', + }, + }); + expect(deps.oauthAuthorizationService.getAuthorization).toHaveBeenCalledWith( + { userId: 'member-1', orgId: organizationId }, + BITBUCKET_CLOUD_AGENT_MINIMUM_VALIDITY_MS + ); + expect(deps.findCachedRepository).toHaveBeenCalledWith({ + integrationId: oauthAuthorization.integrationId, + organizationId, + workspace: oauthAuthorization.workspace, + repositoryUuid, + }); + } + ); + + it('rejects a Workspace Access Token pin after selecting the OAuth integration', async () => { const deps = dependencies(); deps.authorizationService.getAuthorization.mockResolvedValue({ status: 'not_connected' }); deps.oauthAuthorizationService.getAuthorization.mockResolvedValue(oauthAuthorization); + const params = tokenParams({ expectedIntegrationId: authorization.integrationId }); - await expect(resolveBitbucketToken({} as CloudflareEnv, tokenParams(), deps)).resolves.toEqual({ - success: true, - token: 'oauth-runtime-token', + await expect(resolveBitbucketToken({} as CloudflareEnv, params, deps)).resolves.toEqual({ + success: false, + reason: 'integration_mismatch', }); - expect(deps.oauthAuthorizationService.getAuthorization).toHaveBeenCalledWith( - { userId: 'member-1', orgId: organizationId }, - BITBUCKET_CLOUD_AGENT_MINIMUM_VALIDITY_MS - ); - expect(deps.findCachedRepository).toHaveBeenCalledWith({ - integrationId: oauthAuthorization.integrationId, - organizationId, - workspace: oauthAuthorization.workspace, - repositoryUuid, + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, params, deps) + ).resolves.toEqual({ + success: false, + reason: 'integration_mismatch', }); + expect(deps.findCachedRepository).not.toHaveBeenCalled(); }); it('does not fall back to OAuth while a Workspace Access Token needs attention', async () => { @@ -204,6 +267,25 @@ describe('Bitbucket runtime token resolver', () => { } ); + it.each([ + 'not_connected', + 'reconnect_required', + 'insufficient_permissions', + 'temporarily_unavailable', + ] as const)('returns authorization failure %s without identity or credentials', async status => { + const deps = dependencies(); + deps.authorizationService.getAuthorization.mockResolvedValue({ status }); + + await expect(resolveBitbucketToken({} as CloudflareEnv, tokenParams(), deps)).resolves.toEqual({ + success: false, + reason: status, + }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, tokenParams(), deps) + ).resolves.toEqual({ success: false, reason: status }); + expect(deps.findCachedRepository).not.toHaveBeenCalled(); + }); + it.each([ ['not_connected', 'not_connected'], ['repository_not_found', 'repository_not_found'], @@ -220,6 +302,9 @@ describe('Bitbucket runtime token resolver', () => { success: false, reason, }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, tokenParams(), deps) + ).resolves.toEqual({ success: false, reason }); expect(deps.listRepositories).not.toHaveBeenCalled(); expect(deps.authorizationService.invalidateAuthorization).not.toHaveBeenCalled(); } @@ -229,15 +314,19 @@ describe('Bitbucket runtime token resolver', () => { [{ workspaceUuid: '123e4567-e89b-12d3-a456-426614174099' }, 'workspace_mismatch'], [{ repositoryUrl: 'https://bitbucket.org/other/widgets.git' }, 'workspace_mismatch'], [{ repositoryUrl: 'https://user@bitbucket.org/acme/widgets.git' }, 'invalid_request'], - [{ expectedIntegrationId: '123e4567-e89b-12d3-a456-426614174099' }, 'integration_mismatch'], + [{ expectedIntegrationId: oauthAuthorization.integrationId }, 'integration_mismatch'], ] as const)( 'fails before provider access when request identity drifts %#', async (overrides, reason) => { const deps = dependencies(); + deps.oauthAuthorizationService.getAuthorization.mockResolvedValue(oauthAuthorization); await expect( resolveBitbucketToken({} as CloudflareEnv, tokenParams(overrides), deps) ).resolves.toEqual({ success: false, reason }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, tokenParams(overrides), deps) + ).resolves.toEqual({ success: false, reason }); expect(deps.findCachedRepository).not.toHaveBeenCalled(); expect(deps.authorizationService.invalidateAuthorization).not.toHaveBeenCalled(); } @@ -255,6 +344,9 @@ describe('Bitbucket runtime token resolver', () => { success: false, reason: 'repository_mismatch', }); + await expect( + resolveBitbucketCapabilitySubject({} as CloudflareEnv, tokenParams(), deps) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); expect(deps.authorizationService.invalidateAuthorization).not.toHaveBeenCalled(); }); }); diff --git a/services/git-token-service/src/bitbucket-runtime-token-resolver.ts b/services/git-token-service/src/bitbucket-runtime-token-resolver.ts index f39d45b1b8..2fc20ca4dc 100644 --- a/services/git-token-service/src/bitbucket-runtime-token-resolver.ts +++ b/services/git-token-service/src/bitbucket-runtime-token-resolver.ts @@ -39,7 +39,7 @@ export type GetBitbucketTokenParams = { }; export type GetBitbucketTokenResult = - | { success: true; token: string } + | { success: true; token: string; integrationId: string } | { success: false; reason: @@ -398,7 +398,11 @@ export async function resolveBitbucketToken( ): Promise { const resolved = await resolveBitbucketAuthorizedRepository(env, params, dependencyOverrides); if (!resolved.success) return resolved; - return { success: true, token: resolved.authorization.token }; + return { + success: true, + token: resolved.authorization.token, + integrationId: resolved.authorization.integrationId, + }; } export type BitbucketCapabilitySubject = { diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 556b2664e5..dd08a29678 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -553,25 +553,67 @@ describe('GitTokenRPCEntrypoint Bitbucket session capability', () => { return result.capability; } - it('issues an opaque capability and the canonical git URL', async () => { - const result = await createService().issueBitbucketSessionCapability(issueParams); - expect(result).toEqual({ - success: true, - capability: expect.stringMatching(/^kbb1\./), - gitUrl: 'https://bitbucket.org/acme/widgets.git', - }); - if (result.success) { + it.each([ + ['legacy request', {}], + ['matching pin', { expectedIntegrationId: subject.integrationId }], + // The RPC projection must trust the resolver, not echo a caller's claimed pin. + ['different caller pin', { expectedIntegrationId: '123e4567-e89b-12d3-a456-426614174099' }], + ] as const)( + 'returns the capability, canonical URL, and resolved integration for %s', + async (_name, pin) => { + const result = await createService().issueBitbucketSessionCapability({ + ...issueParams, + ...pin, + }); + expect(result).toEqual({ + success: true, + capability: expect.stringMatching(/^kbb1\./), + gitUrl: 'https://bitbucket.org/acme/widgets.git', + integrationId: subject.integrationId, + }); + if (!result.success) throw new Error(`issue failed: ${result.reason}`); expect(result.capability).not.toContain(subject.token); + const { BitbucketSessionCapabilityCodec } = await import('./bitbucket-session-capability.js'); + const claims = new BitbucketSessionCapabilityCodec( + Buffer.alloc(32, 7).toString('base64') + ).decode(result.capability); + expect(claims).toMatchObject({ + integrationId: subject.integrationId, + workspaceUuid: subject.workspaceUuid, + repositoryUuid: subject.repositoryUuid, + repositoryFullName: 'acme/widgets', + outboundContainerId: 'outbound-container-1', + }); } - }); + ); - it('propagates a resolution failure from issue', async () => { + it.each([ + 'invalid_request', + 'not_connected', + 'reconnect_required', + 'temporarily_unavailable', + 'insufficient_permissions', + 'integration_mismatch', + 'workspace_mismatch', + 'repository_not_found', + 'repository_mismatch', + ])('preserves issue failure %s without exposing identity or credentials', async reason => { serviceMocks.resolveBitbucketCapabilitySubject .mockReset() - .mockResolvedValue({ success: false, reason: 'reconnect_required' }); - await expect(createService().issueBitbucketSessionCapability(issueParams)).resolves.toEqual({ + .mockResolvedValue({ success: false, reason }); + await expect( + createService().issueBitbucketSessionCapability({ + ...issueParams, + expectedIntegrationId: subject.integrationId, + }) + ).resolves.toEqual({ success: false, reason }); + }); + + it('exposes no resolved identity when capability configuration fails', async () => { + const service = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); + await expect(service.issueBitbucketSessionCapability(issueParams)).resolves.toEqual({ success: false, - reason: 'reconnect_required', + reason: 'capability_configuration_error', }); }); @@ -631,6 +673,22 @@ describe('GitTokenRPCEntrypoint Bitbucket session capability', () => { ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); }); + it('rejects redemption when the integration changes without token rotation', async () => { + const capability = await issueCapability(); + serviceMocks.resolveBitbucketCapabilitySubject.mockResolvedValue({ + success: true, + subject: { ...subject, integrationId: '123e4567-e89b-12d3-a456-426614174099' }, + }); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'POST', + requestUrl: 'https://bitbucket.org/acme/widgets.git/git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); + }); + it.each([ // Single-encoded traversal is caught by the raw check. ['https://bitbucket.org/acme/widgets.git/%2e%2e/git-upload-pack'], @@ -652,42 +710,64 @@ describe('GitTokenRPCEntrypoint Bitbucket session capability', () => { }); describe('GitTokenRPCEntrypoint Bitbucket runtime authorization', () => { + const integrationId = '123e4567-e89b-12d3-a456-426614174022'; + const params = { + userId: 'user-1', + orgId: '123e4567-e89b-12d3-a456-426614174030', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + repositoryUrl: 'https://bitbucket.org/acme/widgets.git', + }; + it('requires an organization before invoking the reachable V1 resolver', async () => { serviceMocks.resolveBitbucketToken.mockReset(); await expect( - createService().getBitbucketToken({ - userId: 'user-1', - workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', - repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', - repositoryUrl: 'https://bitbucket.org/acme/widgets.git', - }) + createService().getBitbucketToken({ ...params, orgId: undefined }) ).resolves.toEqual({ success: false, reason: 'invalid_request' }); expect(serviceMocks.resolveBitbucketToken).not.toHaveBeenCalled(); }); - it('returns only the opaque token from an integration-fenced V1 resolution', async () => { + it.each([ + ['legacy request', {}], + ['matching pin', { expectedIntegrationId: integrationId }], + // The resolver owns authorization; this projection must never echo the request pin. + ['different caller pin', { expectedIntegrationId: '123e4567-e89b-12d3-a456-426614174099' }], + ] as const)('returns only the token and resolved integration for %s', async (_name, pin) => { serviceMocks.resolveBitbucketToken.mockReset().mockResolvedValue({ success: true, token: 'ATCT-runtime-token', - integrationId: 'must-not-cross-rpc', + integrationId, credentialId: 'must-not-cross-rpc', credentialVersion: 7, }); - const params = { - userId: 'user-1', - orgId: '123e4567-e89b-12d3-a456-426614174030', - workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', - repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', - repositoryUrl: 'https://bitbucket.org/acme/widgets.git', - expectedIntegrationId: '123e4567-e89b-12d3-a456-426614174022', - }; - await expect(createService().getBitbucketToken(params)).resolves.toEqual({ + await expect(createService().getBitbucketToken({ ...params, ...pin })).resolves.toEqual({ success: true, token: 'ATCT-runtime-token', + integrationId, }); - expect(serviceMocks.resolveBitbucketToken).toHaveBeenCalledWith(expect.anything(), params); + expect(serviceMocks.resolveBitbucketToken).toHaveBeenCalledWith(expect.anything(), { + ...params, + ...pin, + }); + }); + + it.each([ + 'invalid_request', + 'not_connected', + 'reconnect_required', + 'temporarily_unavailable', + 'insufficient_permissions', + 'integration_mismatch', + 'workspace_mismatch', + 'repository_not_found', + 'repository_mismatch', + ])('preserves token failure %s without exposing identity or credentials', async reason => { + serviceMocks.resolveBitbucketToken.mockReset().mockResolvedValue({ success: false, reason }); + await expect( + createService().getBitbucketToken({ ...params, expectedIntegrationId: integrationId }) + ).resolves.toEqual({ success: false, reason }); }); }); diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index ee5059b1c2..9f7af8f0e4 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -257,7 +257,7 @@ export type IssueBitbucketSessionCapabilityParams = { }; type BitbucketTokenFailureReason = Extract['reason']; export type IssueBitbucketSessionCapabilityResult = - | { success: true; capability: string; gitUrl: string } + | { success: true; capability: string; gitUrl: string; integrationId: string } | { success: false; reason: BitbucketTokenFailureReason | 'capability_configuration_error' }; export type RedeemBitbucketSessionCapabilityParams = { @@ -1107,7 +1107,9 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { async getBitbucketToken(params: GetBitbucketTokenParams): Promise { if (!params.orgId) return { success: false, reason: 'invalid_request' }; const result = await resolveBitbucketToken(this.env, params); - return result.success ? { success: true, token: result.token } : result; + return result.success + ? { success: true, token: result.token, integrationId: result.integrationId } + : result; } async issueBitbucketSessionCapability( @@ -1142,6 +1144,7 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { success: true, capability, gitUrl: `https://bitbucket.org/${subject.repositoryFullName}.git`, + integrationId: subject.integrationId, }; } catch { return { success: false, reason: 'capability_configuration_error' }; From 2541a93d89f7cecac18c0a38f53207ea46349e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 29 Aug 2026 08:01:07 +0200 Subject: [PATCH 2/4] fix(provider-review): specify the Worker decoder BOM default --- services/git-token-service/src/bitbucket-interactive-api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/git-token-service/src/bitbucket-interactive-api.ts b/services/git-token-service/src/bitbucket-interactive-api.ts index 4b0ea29ed6..5a94e55c02 100644 --- a/services/git-token-service/src/bitbucket-interactive-api.ts +++ b/services/git-token-service/src/bitbucket-interactive-api.ts @@ -367,7 +367,7 @@ export function createBitbucketInteractiveApi(options: { ?.split(';')[0] .trim() .toLowerCase(); - const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes); let data: Record | undefined; if (representation === 'text') { // Raw file media types come from extensions, not content. fileMetadata carries binary state. From 8769dc07ac94ec7419b2eb6be3359728d786431c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 29 Aug 2026 08:05:57 +0200 Subject: [PATCH 3/4] fix(git-token): preserve resolved GitHub integration identity --- .../src/github-session-capability.test.ts | 71 ++ .../src/github-session-capability.ts | 9 + services/git-token-service/src/index.test.ts | 773 ++++++++++++++++-- services/git-token-service/src/index.ts | 35 +- ...stallation-lookup-service.behavior.test.ts | 305 ++++--- .../src/installation-lookup-service.test.ts | 96 ++- .../src/installation-lookup-service.ts | 87 +- 7 files changed, 1176 insertions(+), 200 deletions(-) diff --git a/services/git-token-service/src/github-session-capability.test.ts b/services/git-token-service/src/github-session-capability.test.ts index 90b11fafe8..03715c98d0 100644 --- a/services/git-token-service/src/github-session-capability.test.ts +++ b/services/git-token-service/src/github-session-capability.test.ts @@ -86,6 +86,77 @@ describe('GitHubSessionCapabilityCodec', () => { expect(decoded).not.toHaveProperty('integrationId'); }); + describe.each([undefined, claims.outboundContainerId])('container %s', outboundContainerId => { + it.each([ + { type: 'user', id: 'oauth/personal-owner' }, + { type: 'org', id: claims.orgId }, + ] as const)( + 'seals the $type integration owner separately from the session context', + integrationOwner => { + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + const { outboundContainerId: _outboundContainerId, ...unboundClaims } = claims; + const decoded = codec.decode( + codec.issue({ + ...unboundClaims, + userId: 'oauth/personal-owner', + ...(outboundContainerId === undefined ? {} : { outboundContainerId }), + integrationOwner, + }) + ); + + expect(decoded).toMatchObject({ + userId: 'oauth/personal-owner', + orgId: claims.orgId, + integrationId: claims.integrationId, + integrationOwner, + owner: 'acme', + repo: 'widgets', + identity: claims.identity, + }); + } + ); + + it.each([ + ['unknown owner type', { type: 'team', id: claims.orgId }], + ['empty Personal owner', { type: 'user', id: '' }], + ['invalid organization ID', { type: 'org', id: 'not-a-uuid' }], + ['unknown owner field', { type: 'user', id: 'user_1', orgId: claims.orgId }], + ])('rejects a decrypted claim with %s', (_name, integrationOwner) => { + const version = outboundContainerId === undefined ? 1 : 2; + const serializedClaims = JSON.stringify({ + ...claims, + purpose: 'github_scm_session', + version, + outboundContainerId, + integrationOwner, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const capability = `kgh${version}.${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + expect(() => new GitHubSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); + + it('rejects an owner claim without its integration pin', () => { + const version = outboundContainerId === undefined ? 1 : 2; + const serializedClaims = JSON.stringify({ + ...claims, + purpose: 'github_scm_session', + version, + outboundContainerId, + integrationId: undefined, + integrationOwner: { type: 'user', id: 'user_1' }, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const capability = `kgh${version}.${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + expect(() => new GitHubSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); + }); + it.each([ ['legacy unbound v1', 'kgh1.', 1, 2 * 60 * 60 * 1000, false], ['container-bound v2', 'kgh2.', 2, 4 * 60 * 60 * 1000, true], diff --git a/services/git-token-service/src/github-session-capability.ts b/services/git-token-service/src/github-session-capability.ts index 13b890b3e9..774445c61f 100644 --- a/services/git-token-service/src/github-session-capability.ts +++ b/services/git-token-service/src/github-session-capability.ts @@ -1,6 +1,12 @@ import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encryption'; import { Buffer } from 'node:buffer'; import { z } from 'zod'; +import type { Owner } from '../../../packages/app-shared/src/code-review/repository-identity.js'; + +export const GitHubIntegrationOwnerSchema: z.ZodType = z.discriminatedUnion('type', [ + z.object({ type: z.literal('user'), id: z.string().min(1) }).strict(), + z.object({ type: z.literal('org'), id: z.string().uuid() }).strict(), +]); const LEGACY_CAPABILITY_PREFIX = 'kgh1.'; const BOUND_CAPABILITY_PREFIX = 'kgh2.'; @@ -42,6 +48,7 @@ const GitHubSessionCapabilityClaimsBaseSchema = z.object({ userId: z.string().min(1), orgId: z.string().uuid().optional(), integrationId: z.string().uuid().optional(), + integrationOwner: GitHubIntegrationOwnerSchema.optional(), owner: GitHubPathPartSchema, repo: GitHubPathPartSchema, source: z.enum(['user', 'installation']), @@ -62,6 +69,7 @@ const GitHubSessionCapabilityClaimsSchema = z GitHubBoundSessionCapabilityClaimsSchema, ]) .refine(claims => claims.expiresAt > claims.issuedAt) + .refine(claims => claims.integrationOwner === undefined || claims.integrationId !== undefined) .refine( claims => claims.expiresAt - claims.issuedAt <= getGitHubSessionCapabilityLifetimeMs(claims.version) @@ -74,6 +82,7 @@ export type GitHubSessionCapabilitySubject = { outboundContainerId?: string; orgId?: string; integrationId?: string; + integrationOwner?: Owner; owner: string; repo: string; source: GitHubAuthSource; diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index dd08a29678..0e3c8a4b1a 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -1,8 +1,15 @@ import { signKiloToken } from '@kilocode/worker-utils'; +import * as dbClient from '@kilocode/db/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as GitLabCredentialBrokerHandlerModule from './gitlab-credential-broker-handler.js'; import type * as GitLabLookupServiceModule from './gitlab-lookup-service.js'; import type * as InteractiveReviewHandlerModule from './interactive-review-handler.js'; +import type * as InstallationLookupServiceModule from './installation-lookup-service.js'; +import { GitHubSessionCapabilityCodec } from './github-session-capability.js'; +import type { + FindInstallationParams, + ManagedInstallationLookupSuccess, +} from './installation-lookup-service.js'; const serviceMocks = vi.hoisted(() => ({ findInstallationId: vi.fn(), @@ -781,37 +788,51 @@ describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { vi.clearAllMocks(); }); - it('mints repository-scoped tokens after resolving an authorized installation', async () => { - serviceMocks.findInstallationId.mockResolvedValue({ - success: true, - installationId: '123', - accountLogin: 'old-owner', - githubAppType: 'lite', - }); - serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); - serviceMocks.getToken.mockResolvedValue('installation-wide-token'); + it.each([ + undefined, + '00000000-0000-4000-8000-000000000002', + '00000000-0000-4000-8000-000000000099', + ])( + 'returns a scoped token and the resolved identity rather than the caller pin %s', + async expectedIntegrationId => { + serviceMocks.findInstallationId.mockResolvedValue({ + success: true, + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user-1' }, + installationId: '123', + accountLogin: 'old-owner', + githubAppType: 'lite', + }); + serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); + serviceMocks.getToken.mockResolvedValue('installation-wide-token'); - const result = await createService().getTokenForRepo({ - githubRepo: 'renamed-owner/repository', - userId: 'user-1', - }); + const result = await createService().getTokenForRepo({ + githubRepo: 'renamed-owner/repository', + userId: 'user-1', + expectedIntegrationId, + }); - expect(result).toEqual({ - success: true, - token: 'scoped-token', - installationId: '123', - accountLogin: 'old-owner', - appType: 'lite', - }); - expect(serviceMocks.getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'lite'); - expect(serviceMocks.getToken).not.toHaveBeenCalled(); - }); + expect(result).toEqual({ + success: true, + token: 'scoped-token', + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user-1' }, + installationId: '123', + accountLogin: 'old-owner', + appType: 'lite', + }); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'lite'); + expect(serviceMocks.getToken).not.toHaveBeenCalled(); + } + ); it('repairs stale login metadata after a lookup miss before minting a token', async () => { serviceMocks.findInstallationId .mockResolvedValueOnce({ success: false, reason: 'no_installation_found' }) .mockResolvedValueOnce({ success: true, + integrationId: 'integration-1', + integrationOwner: { type: 'user', id: 'user-1' }, installationId: '123', accountLogin: 'renamed-owner', githubAppType: 'standard', @@ -837,7 +858,11 @@ describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { userId: 'user-1', }); - expect(result).toMatchObject({ success: true, token: 'scoped-token' }); + expect(result).toMatchObject({ + success: true, + token: 'scoped-token', + integrationId: 'integration-1', + }); expect(serviceMocks.updateAccountLogin).toHaveBeenCalledWith('integration-1', 'renamed-owner'); expect(consoleLog).toHaveBeenCalledWith( JSON.stringify({ @@ -960,6 +985,8 @@ describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { it('does not fall back to an installation-wide token when scoped minting fails', async () => { serviceMocks.findInstallationId.mockResolvedValue({ success: true, + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user_1' }, installationId: '123', accountLogin: 'old-owner', githubAppType: 'standard', @@ -972,57 +999,681 @@ describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { expect(serviceMocks.getToken).not.toHaveBeenCalled(); }); - it('repairs stale login metadata within the expected integration fence', async () => { - const params = { - githubRepo: 'acme/repository', - userId: 'user-1', - orgId: '00000000-0000-4000-8000-000000000001', - expectedIntegrationId: '00000000-0000-4000-8000-000000000002', - }; - serviceMocks.findInstallationId - .mockResolvedValueOnce({ success: false, reason: 'integration_mismatch' }) - .mockResolvedValueOnce({ - success: true, - installationId: '123', - accountLogin: 'acme', - githubAppType: 'standard', - }); - serviceMocks.findRefreshCandidates.mockResolvedValue({ - success: true, - candidates: [ - { + it.each([undefined, '00000000-0000-4000-8000-000000000001'])( + 'repairs stale login metadata within the expected integration fence for owner scope %s', + async orgId => { + const params = { + githubRepo: 'acme/repository', + userId: 'user-1', + orgId, + expectedIntegrationId: '00000000-0000-4000-8000-000000000002', + }; + serviceMocks.findInstallationId + .mockResolvedValueOnce({ success: false, reason: 'integration_mismatch' }) + .mockResolvedValueOnce({ + success: true, integrationId: params.expectedIntegrationId, + integrationOwner: + orgId === undefined ? { type: 'user', id: 'user-1' } : { type: 'org', id: orgId }, installationId: '123', - accountLogin: 'old-acme', + accountLogin: 'acme', githubAppType: 'standard', - }, - ], + }); + serviceMocks.findRefreshCandidates.mockResolvedValue({ + success: true, + candidates: [ + { + integrationId: params.expectedIntegrationId, + installationId: '123', + accountLogin: 'old-acme', + githubAppType: 'standard', + }, + ], + }); + serviceMocks.refreshInstallationAccountLoginIfDue.mockResolvedValue('acme'); + serviceMocks.updateAccountLogin.mockResolvedValue(true); + serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await expect(createService().getTokenForRepo(params)).resolves.toMatchObject({ + success: true, + token: 'scoped-token', + integrationId: params.expectedIntegrationId, + }); + expect(serviceMocks.findInstallationId).toHaveBeenCalledTimes(2); + expect(serviceMocks.findRefreshCandidates).toHaveBeenCalledWith(params); + expect(serviceMocks.updateAccountLogin).toHaveBeenCalledWith( + params.expectedIntegrationId, + 'acme' + ); + } + ); +}); + +const outboundContainerId = 'outbound-container-1'; + +describe('GitTokenRPCEntrypoint GitHub launch integration identity', () => { + const integration: ManagedInstallationLookupSuccess = { + success: true, + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user_1' }, + installationId: '123', + accountLogin: 'acme', + githubAppType: 'standard', + repoName: 'repo', + permissions: { contents: 'write', pull_requests: 'write' }, + }; + const userAuthor = { name: 'octocat', email: '1+octocat@users.noreply.github.com' }; + const installationAuthor = { + name: 'kiloconnect[bot]', + email: '240665456+kiloconnect[bot]@users.noreply.github.com', + }; + const codec = new GitHubSessionCapabilityCodec(Buffer.alloc(32, 7).toString('base64')); + + beforeEach(() => { + vi.clearAllMocks(); + serviceMocks.findInstallationId.mockResolvedValue(integration); + serviceMocks.findManagedInstallationForRepo.mockResolvedValue(integration); + serviceMocks.findRefreshCandidates.mockResolvedValue({ success: true, candidates: [] }); + serviceMocks.getTokenForRepo.mockResolvedValue('installation-token'); + serviceMocks.selectUserAuthorization.mockResolvedValue({ + selected: true, + token: 'user-token', + gitAuthor: userAuthor, + }); + }); + + describe.each([undefined, integration.integrationId, '00000000-0000-4000-8000-000000000099'])( + 'caller pin %s', + expectedIntegrationId => { + it.each([false, true])( + 'returns and seals the authorized identity with user authorization %s', + async allowUserAuthorization => { + const params = { + githubRepo: 'acme/repo', + userId: 'user_1', + expectedIntegrationId, + allowUserAuthorization, + outboundContainerId, + }; + const expectedMetadata = { + success: true, + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + ...(allowUserAuthorization + ? { source: 'user', gitAuthor: userAuthor, commitCoAuthor: installationAuthor } + : { source: 'installation', gitAuthor: installationAuthor }), + }; + const service = createService(); + await expect(service.getCloudAgentAuthForRepo(params)).resolves.toEqual({ + ...expectedMetadata, + githubToken: allowUserAuthorization ? 'user-token' : 'installation-token', + }); + const issued = await service.issueGitHubSessionCapability(params); + expect(issued).toEqual({ ...expectedMetadata, capability: expect.any(String) }); + if (!issued.success) throw new Error('Expected capability'); + expect(codec.decode(issued.capability)).toMatchObject({ + integrationId: integration.integrationId, + owner: 'acme', + repo: 'repo', + }); + expect(issued).not.toHaveProperty('githubToken'); + } + ); + } + ); + + it.each([ + 'no_user_authorization', + 'revoked', + 'refresh_failed', + 'insufficient_user_access', + 'credential_unreadable', + 'credential_configuration_error', + ])('retains the resolved identity when user selection falls back for %s', async reason => { + serviceMocks.selectUserAuthorization.mockResolvedValue({ selected: false, reason }); + await expect( + createService().getCloudAgentAuthForRepo({ + githubRepo: 'acme/repo', + userId: 'user_1', + allowUserAuthorization: true, + }) + ).resolves.toEqual({ + success: true, + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + githubToken: 'installation-token', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + source: 'installation', + gitAuthor: installationAuthor, + fallbackReason: reason, }); - serviceMocks.refreshInstallationAccountLoginIfDue.mockResolvedValue('acme'); - serviceMocks.updateAccountLogin.mockResolvedValue(true); - serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); - vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it.each(['lite_installation', 'insufficient_user_access'])( + 'retains the resolved identity for the installation fallback %s', + async fallbackReason => { + const githubAppType = fallbackReason === 'lite_installation' ? 'lite' : 'standard'; + serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ + ...integration, + githubAppType, + permissions: { contents: 'read' }, + }); + await expect( + createService().getCloudAgentAuthForRepo({ + githubRepo: 'acme/repo', + userId: 'user_1', + allowUserAuthorization: true, + }) + ).resolves.toMatchObject({ + success: true, + integrationId: integration.integrationId, + githubToken: 'installation-token', + source: 'installation', + appType: githubAppType, + fallbackReason, + }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + } + ); + + describe.each([undefined, '00000000-0000-4000-8000-000000000001'])('owner scope %s', orgId => { + const actor = { userId: 'user_1', ...(orgId === undefined ? {} : { orgId }) }; + + it.each([false, true])( + 'retains the exact integration after managed login repair with user authorization %s', + async allowUserAuthorization => { + serviceMocks.findManagedInstallationForRepo.mockResolvedValueOnce({ + success: false, + reason: 'integration_mismatch', + }); + serviceMocks.findRefreshCandidates.mockResolvedValue({ + success: true, + candidates: [ + { + integrationId: integration.integrationId, + installationId: '123', + accountLogin: 'old-acme', + githubAppType: 'standard', + }, + ], + }); + serviceMocks.refreshInstallationAccountLoginIfDue.mockResolvedValue('acme'); + serviceMocks.updateAccountLogin.mockResolvedValue(true); + vi.spyOn(console, 'log').mockImplementation(() => {}); + await expect( + createService().getCloudAgentAuthForRepo({ + ...actor, + githubRepo: 'acme/repo', + expectedIntegrationId: integration.integrationId, + allowUserAuthorization, + }) + ).resolves.toMatchObject({ + success: true, + integrationId: integration.integrationId, + githubToken: allowUserAuthorization ? 'user-token' : 'installation-token', + source: allowUserAuthorization ? 'user' : 'installation', + }); + } + ); + + describe.each([undefined, outboundContainerId])('capability container %s', containerId => { + const container = containerId === undefined ? {} : { outboundContainerId: containerId }; + + it.each([false, true])( + 'pins a legacy request through token refresh with user authorization %s', + async allowUserAuthorization => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + ...actor, + ...container, + githubRepo: 'acme/repo', + allowUserAuthorization, + }); + if (!issued.success) throw new Error('Expected capability'); + expect(issued.integrationId).toBe(integration.integrationId); + serviceMocks.findManagedInstallationForRepo.mockImplementation( + async (params: FindInstallationParams) => + params.expectedIntegrationId === integration.integrationId + ? integration + : { success: false, reason: 'integration_mismatch' } + ); + serviceMocks.getTokenForRepo.mockResolvedValue('refreshed-installation-token'); + serviceMocks.selectUserAuthorization.mockResolvedValue({ + selected: true, + token: 'refreshed-user-token', + gitAuthor: userAuthor, + }); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + ...container, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ + success: true, + authorization: allowUserAuthorization + ? 'Bearer refreshed-user-token' + : 'Bearer refreshed-installation-token', + }); + } + ); + + it.each([false, true])( + 'rejects replacement after an unpinned request with user authorization %s', + async allowUserAuthorization => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + ...actor, + ...container, + githubRepo: 'acme/repo', + allowUserAuthorization, + }); + if (!issued.success) throw new Error('Expected capability'); + serviceMocks.findManagedInstallationForRepo.mockImplementation( + async (params: FindInstallationParams) => + params.expectedIntegrationId === undefined + ? { ...integration, integrationId: '00000000-0000-4000-8000-000000000099' } + : { success: false, reason: 'integration_mismatch' } + ); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + ...container, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } + ); + + it.each([false, true])( + 'redeems an old unpinned capability with user authorization %s', + async allowUserAuthorization => { + const capability = codec.issue({ + ...actor, + ...container, + owner: 'acme', + repo: 'repo', + source: allowUserAuthorization ? 'user' : 'installation', + identity: { + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + gitAuthor: allowUserAuthorization ? userAuthor : installationAuthor, + ...(allowUserAuthorization ? { commitCoAuthor: installationAuthor } : {}), + }, + }); + serviceMocks.findManagedInstallationForRepo.mockImplementation( + async (params: FindInstallationParams) => + params.expectedIntegrationId === undefined + ? integration + : { success: false, reason: 'integration_mismatch' } + ); + const service = createService(); + await expect( + service.redeemGitHubSessionCapability({ + capability, + ...container, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ + success: true, + authorization: allowUserAuthorization + ? 'Bearer user-token' + : 'Bearer installation-token', + }); + if (containerId === undefined) { + await expect( + service.redeemGitHubSessionCapability({ + capability, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/other/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + } + } + ); + }); + }); + + async function usePersonalInstallationLookup() { + const { InstallationLookupService } = await vi.importActual< + typeof InstallationLookupServiceModule + >('./installation-lookup-service.js'); + const row = { + id: integration.integrationId, + platform_installation_id: '123', + platform_account_login: 'acme', + github_app_type: 'standard', + integration_status: 'active', + owned_by_organization_id: null, + owned_by_user_id: 'user_1', + repository_access: 'all', + repositories: null, + permissions: { contents: 'write', pull_requests: 'write' }, + }; + const query = { + from: vi.fn(() => query), + leftJoin: vi.fn(() => query), + innerJoin: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + limit: vi.fn(async () => [row]), + }; + vi.spyOn(dbClient, 'getWorkerDb').mockReturnValue({ select: () => query } as never); + const lookup = new InstallationLookupService({ + HYPERDRIVE: { connectionString: 'postgres://test' }, + } as CloudflareEnv); + serviceMocks.findInstallationId.mockImplementation((params: FindInstallationParams) => + lookup.findInstallationId(params) + ); + serviceMocks.findManagedInstallationForRepo.mockImplementation( + (params: FindInstallationParams) => lookup.findManagedInstallationForRepo(params) + ); + return row; + } - await expect(createService().getTokenForRepo(params)).resolves.toMatchObject({ + it('keeps an authorized legacy Personal fallback redeemable in an organization session', async () => { + await usePersonalInstallationLookup(); + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + orgId: '00000000-0000-4000-8000-000000000001', + outboundContainerId, + }); + expect(issued).toMatchObject({ success: true, integrationId: integration.integrationId }); + if (!issued.success) throw new Error('Expected capability'); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: true, authorization: 'Bearer installation-token' }); + }); + + const organizationSession = { + githubRepo: 'acme/repo', + userId: 'user_1', + orgId: '00000000-0000-4000-8000-000000000001', + }; + + it('retains the resolved Personal owner across raw-token retries in an organization session', async () => { + await usePersonalInstallationLookup(); + const service = createService(); + const resolved = await service.getTokenForRepo(organizationSession); + expect(resolved).toEqual({ success: true, - token: 'scoped-token', + token: 'installation-token', + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + installationId: '123', + accountLogin: 'acme', + appType: 'standard', }); - expect(serviceMocks.findInstallationId).toHaveBeenCalledTimes(2); - expect(serviceMocks.findRefreshCandidates).toHaveBeenCalledWith(params); - expect(serviceMocks.updateAccountLogin).toHaveBeenCalledWith( - params.expectedIntegrationId, - 'acme' + if (!resolved.success) throw new Error('Expected raw token'); + serviceMocks.getTokenForRepo.mockResolvedValue('refreshed-installation-token'); + await expect( + service.getTokenForRepo({ + ...organizationSession, + expectedIntegrationId: resolved.integrationId, + expectedIntegrationOwner: resolved.integrationOwner, + }) + ).resolves.toEqual({ ...resolved, token: 'refreshed-installation-token' }); + }); + + describe.each([undefined, outboundContainerId])('Personal fallback container %s', containerId => { + const container = containerId === undefined ? {} : { outboundContainerId: containerId }; + + it.each([false, true])( + 'retains the owner across capability retries with user authorization %s', + async allowUserAuthorization => { + await usePersonalInstallationLookup(); + const service = createService(); + const params = { ...organizationSession, ...container, allowUserAuthorization }; + const issued = await service.issueGitHubSessionCapability(params); + expect(issued).toMatchObject({ + success: true, + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + }); + if (!issued.success) throw new Error('Expected capability'); + expect(codec.decode(issued.capability)).toMatchObject({ + orgId: organizationSession.orgId, + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + }); + const retried = await service.issueGitHubSessionCapability({ + ...params, + expectedIntegrationId: issued.integrationId, + expectedIntegrationOwner: issued.integrationOwner, + }); + expect(retried).toMatchObject({ + success: true, + integrationId: integration.integrationId, + integrationOwner: { type: 'user', id: 'user_1' }, + }); + if (!retried.success) throw new Error('Expected retry capability'); + serviceMocks.getTokenForRepo.mockResolvedValue('refreshed-installation-token'); + serviceMocks.selectUserAuthorization.mockResolvedValue({ + selected: true, + token: 'refreshed-user-token', + gitAuthor: userAuthor, + }); + for (const { capability } of [issued, retried]) { + await expect( + service.redeemGitHubSessionCapability({ + capability, + ...container, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ + success: true, + authorization: allowUserAuthorization + ? 'Bearer refreshed-user-token' + : 'Bearer refreshed-installation-token', + }); + } + } + ); + + it.each([false, true])( + 'preserves old unpinned fallback and organization-only pin semantics with user authorization %s', + async allowUserAuthorization => { + const row = await usePersonalInstallationLookup(); + const subject = { + userId: organizationSession.userId, + orgId: organizationSession.orgId, + ...container, + owner: 'acme', + repo: 'repo', + source: allowUserAuthorization ? ('user' as const) : ('installation' as const), + identity: { + installationId: '123', + accountLogin: 'acme', + appType: 'standard' as const, + gitAuthor: allowUserAuthorization ? userAuthor : installationAuthor, + ...(allowUserAuthorization ? { commitCoAuthor: installationAuthor } : {}), + }, + }; + const service = createService(); + const request = { + ...container, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }; + const expected = { + success: true, + authorization: allowUserAuthorization ? 'Bearer user-token' : 'Bearer installation-token', + }; + await expect( + service.redeemGitHubSessionCapability({ + ...request, + capability: codec.issue(subject), + }) + ).resolves.toEqual(expected); + const pinned = codec.issue({ ...subject, integrationId: integration.integrationId }); + await expect( + service.redeemGitHubSessionCapability({ ...request, capability: pinned }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + const personalPinned = codec.issue({ + ...subject, + orgId: undefined, + integrationId: integration.integrationId, + }); + await expect( + service.redeemGitHubSessionCapability({ ...request, capability: personalPinned }) + ).resolves.toEqual(expected); + Object.assign(row, { + owned_by_organization_id: organizationSession.orgId, + owned_by_user_id: null, + }); + await expect( + service.redeemGitHubSessionCapability({ ...request, capability: pinned }) + ).resolves.toEqual(expected); + await expect( + service.redeemGitHubSessionCapability({ ...request, capability: personalPinned }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } ); }); -}); -const outboundContainerId = 'outbound-container-1'; + it.each([ + [ + 'transferred to the session organization', + { + owned_by_organization_id: organizationSession.orgId, + owned_by_user_id: null, + }, + ], + ['transferred to another user', { owned_by_user_id: 'oauth/another-user' }], + ['replaced', { id: '00000000-0000-4000-8000-000000000099' }], + ['suspended', { integration_status: 'suspended' }], + [ + 'removed from the repository', + { + repository_access: 'selected', + repositories: [{ full_name: 'acme/other-repo' }], + }, + ], + ])( + 'rejects raw retries and redemption after the Personal integration is %s', + async (_name, change) => { + const row = await usePersonalInstallationLookup(); + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + ...organizationSession, + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected capability'); + Object.assign(row, change); + await expect( + service.getTokenForRepo({ + ...organizationSession, + expectedIntegrationId: issued.integrationId, + expectedIntegrationOwner: issued.integrationOwner, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } + ); + + it.each([false, true])( + 'rejects a changed integration owner with identical provider identity and user authorization %s', + async allowUserAuthorization => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization, + }); + if (!issued.success) throw new Error('Expected capability'); + serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ + ...integration, + integrationOwner: { type: 'org', id: organizationSession.orgId }, + }); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } + ); + + it.each([false, true])( + 'rejects a changed integration with identical provider identity and user authorization %s', + async allowUserAuthorization => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization, + }); + if (!issued.success) throw new Error('Expected capability'); + serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ + ...integration, + integrationId: '00000000-0000-4000-8000-000000000099', + }); + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } + ); + + it.each([ + ['database_not_configured', 'database_not_configured'], + ['invalid_repo_format', 'invalid_repo_format'], + ['no_installation_found', 'no_installation_found'], + ['invalid_org_id', 'invalid_org_id'], + ['integration_mismatch', 'integration_mismatch'], + ['ambiguous_installation', 'no_installation_found'], + ])('preserves the public error for lookup failure %s', async (reason, publicReason) => { + serviceMocks.findInstallationId.mockResolvedValue({ success: false, reason }); + serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ success: false, reason }); + const service = createService(); + const params = { githubRepo: 'acme/repo', userId: 'user_1' }; + const failure = { success: false, reason: publicReason }; + await expect(service.getTokenForRepo(params)).resolves.toEqual(failure); + await expect(service.getCloudAgentAuthForRepo(params)).resolves.toEqual(failure); + await expect(service.issueGitHubSessionCapability(params)).resolves.toEqual(failure); + }); +}); describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { beforeEach(() => { vi.clearAllMocks(); serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ success: true, + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user_1' }, installationId: '123', accountLogin: 'acme', githubAppType: 'standard', @@ -1107,6 +1758,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { userId: 'user_1', orgId, expectedIntegrationId, + expectedIntegrationOwner: { type: 'user', id: 'user_1' }, githubRepo: 'acme/repo', }); }); @@ -1143,6 +1795,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { userId: 'user_1', orgId: '00000000-0000-4000-8000-000000000001', expectedIntegrationId: '00000000-0000-4000-8000-000000000002', + expectedIntegrationOwner: { type: 'user', id: 'user_1' }, githubRepo: 'acme/repo', }); expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); @@ -1632,6 +2285,8 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { if (!issued.success) throw new Error('Expected successful issuance'); serviceMocks.findManagedInstallationForRepo.mockResolvedValueOnce({ success: true, + integrationId: '00000000-0000-4000-8000-000000000002', + integrationOwner: { type: 'user', id: 'user_1' }, installationId: '456', accountLogin: 'acme', githubAppType: 'standard', diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 9f7af8f0e4..0fb4e56e8b 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -1,4 +1,5 @@ import { timingSafeEqual } from '@kilocode/encryption'; +import type { Owner } from '../../../packages/app-shared/src/code-review/repository-identity.js'; import { BITBUCKET_REPOSITORY_LIST_AUDIENCE, extractBearerToken, @@ -102,11 +103,15 @@ export type GetTokenForRepoParams = { userId: string; orgId?: string; expectedIntegrationId?: string; + // Supply the resolved owner with the pin; orgId remains the session context. + expectedIntegrationOwner?: Owner; }; export type GetTokenForRepoSuccess = { success: true; token: string; + integrationId: string; + integrationOwner: Owner; installationId: string; accountLogin: string; appType: GitHubAppType; @@ -145,6 +150,8 @@ export type GetCloudAgentAuthForRepoParams = GetTokenForRepoParams & { export type GetCloudAgentAuthForRepoSuccess = { success: true; githubToken: string; + integrationId: string; + integrationOwner: Owner; installationId: string; accountLogin: string; appType: GitHubAppType; @@ -842,6 +849,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: true, token, + integrationId: installation.integrationId, + integrationOwner: installation.integrationOwner, installationId: installation.installationId, accountLogin: installation.accountLogin, appType: installation.githubAppType, @@ -876,6 +885,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { installation.repoName, installation.githubAppType ), + integrationId: installation.integrationId, + integrationOwner: installation.integrationOwner, installationId: installation.installationId, accountLogin: installation.accountLogin, appType: installation.githubAppType, @@ -899,6 +910,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: true, githubToken: selection.token, + integrationId: installation.integrationId, + integrationOwner: installation.integrationOwner, installationId: installation.installationId, accountLogin: installation.accountLogin, appType: installation.githubAppType, @@ -929,9 +942,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { ? { outboundContainerId: params.outboundContainerId } : {}), ...(params.orgId !== undefined ? { orgId: params.orgId } : {}), - ...(params.expectedIntegrationId !== undefined - ? { integrationId: params.expectedIntegrationId } - : {}), + integrationId: auth.integrationId, + integrationOwner: auth.integrationOwner, ...repository, source: auth.source, identity: this.getSessionIdentity(auth), @@ -942,6 +954,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: true, capability, + integrationId: auth.integrationId, + integrationOwner: auth.integrationOwner, installationId: auth.installationId, accountLogin: auth.accountLogin, appType: auth.appType, @@ -976,12 +990,15 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { : validateLegacyGitHubCapabilityUpstream(params.requestMethod, params.requestUrl, claims); if (upstreamFailure) return { success: false, reason: upstreamFailure }; - const authParams = { + const authParams: GetTokenForRepoParams = { userId: claims.userId, ...(claims.orgId !== undefined ? { orgId: claims.orgId } : {}), ...(claims.integrationId !== undefined ? { expectedIntegrationId: claims.integrationId } : {}), + ...(claims.integrationOwner !== undefined + ? { expectedIntegrationOwner: claims.integrationOwner } + : {}), githubRepo: `${claims.owner}/${claims.repo}`, }; let auth: GetCloudAgentAuthForRepoResult | null; @@ -1000,6 +1017,14 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { if (!auth || !auth.success || auth.source !== claims.source) { return { success: false, reason: 'source_unavailable' }; } + if ( + (claims.integrationId !== undefined && auth.integrationId !== claims.integrationId) || + (claims.integrationOwner !== undefined && + (auth.integrationOwner.type !== claims.integrationOwner.type || + auth.integrationOwner.id !== claims.integrationOwner.id)) + ) { + return { success: false, reason: 'integration_mismatch' }; + } if (!this.matchesSessionIdentity(claims.identity, auth)) { return { success: false, reason: 'identity_mismatch' }; } @@ -1051,6 +1076,8 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: true, githubToken: selection.token, + integrationId: installation.integrationId, + integrationOwner: installation.integrationOwner, installationId: installation.installationId, accountLogin: installation.accountLogin, appType: installation.githubAppType, diff --git a/services/git-token-service/src/installation-lookup-service.behavior.test.ts b/services/git-token-service/src/installation-lookup-service.behavior.test.ts index 751f843b8c..20cba9d59a 100644 --- a/services/git-token-service/src/installation-lookup-service.behavior.test.ts +++ b/services/git-token-service/src/installation-lookup-service.behavior.test.ts @@ -6,13 +6,17 @@ vi.mock('@kilocode/db/client', () => ({ getWorkerDb: vi.fn(), })); +const integrationId = '00000000-0000-4000-8000-000000000002'; +const otherIntegrationId = '00000000-0000-4000-8000-000000000003'; +const orgId = '00000000-0000-4000-8000-000000000001'; + type InstallationRow = { - id?: string; + id: string; platform_installation_id: string; platform_account_login: string | null; github_app_type: 'standard' | 'lite' | null; owned_by_organization_id: string | null; - owned_by_user_id?: string | null; + owned_by_user_id: string | null; integration_status?: string; repository_access?: string | null; repositories?: { full_name: string }[] | null; @@ -58,16 +62,20 @@ describe('InstallationLookupService', () => { it('fails closed when multiple active personal installations match the requested owner', async () => { const service = createService([ { + id: integrationId, platform_installation_id: '100', platform_account_login: 'old-owner', github_app_type: 'standard', owned_by_organization_id: null, + owned_by_user_id: 'user-1', }, { + id: otherIntegrationId, platform_installation_id: '200', platform_account_login: 'other-owner', github_app_type: 'lite', owned_by_organization_id: null, + owned_by_user_id: 'user-1', }, ]); @@ -87,10 +95,9 @@ describe('InstallationLookupService', () => { platform_account_login: 'pre-rename-owner', github_app_type: null, owned_by_organization_id: null, + owned_by_user_id: 'user-1', }, - ]) as unknown as { - findRefreshCandidates(params: { githubRepo: string; userId: string }): Promise; - }; + ]); const result = await service.findRefreshCandidates({ githubRepo: 'renamed-owner/repository', @@ -142,10 +149,12 @@ describe('InstallationLookupService', () => { it('resolves an exact-login integration using the legacy standard app type', async () => { const service = createService([ { + id: integrationId, platform_installation_id: '100', platform_account_login: 'renamed-owner', github_app_type: null, owned_by_organization_id: null, + owned_by_user_id: 'user-1', }, ]); @@ -156,6 +165,8 @@ describe('InstallationLookupService', () => { expect(result).toEqual({ success: true, + integrationId, + integrationOwner: { type: 'user', id: 'user-1' }, installationId: '100', accountLogin: 'renamed-owner', githubAppType: 'standard', @@ -165,10 +176,12 @@ describe('InstallationLookupService', () => { it('rejects selected repository metadata for a different owner', async () => { const service = createService([ { + id: integrationId, platform_installation_id: '100', platform_account_login: 'renamed-owner', github_app_type: 'standard', owned_by_organization_id: null, + owned_by_user_id: 'user-1', repository_access: 'selected', repositories: [{ full_name: 'other-owner/repository' }], permissions: { contents: 'write', pull_requests: 'write' }, @@ -186,23 +199,27 @@ describe('InstallationLookupService', () => { it('fails closed when organization and personal installations both match the requested owner', async () => { const service = createService([ { + id: integrationId, platform_installation_id: 'org-installation', platform_account_login: 'organization-owner', github_app_type: 'standard', - owned_by_organization_id: '00000000-0000-4000-8000-000000000001', + owned_by_organization_id: orgId, + owned_by_user_id: null, }, { + id: otherIntegrationId, platform_installation_id: 'personal-installation', platform_account_login: 'personal-owner', github_app_type: 'lite', owned_by_organization_id: null, + owned_by_user_id: 'user-1', }, ]); const result = await service.findInstallationId({ githubRepo: 'renamed-owner/repository', userId: 'user-1', - orgId: '00000000-0000-4000-8000-000000000001', + orgId, }); expect(result).toEqual({ success: false, reason: 'ambiguous_installation' }); @@ -211,125 +228,211 @@ describe('InstallationLookupService', () => { it('fails closed when multiple active organization installations match the requested owner', async () => { const service = createService([ { + id: integrationId, platform_installation_id: 'org-installation-1', platform_account_login: 'organization-owner', github_app_type: 'standard', - owned_by_organization_id: '00000000-0000-4000-8000-000000000001', + owned_by_organization_id: orgId, + owned_by_user_id: null, }, { + id: otherIntegrationId, platform_installation_id: 'org-installation-2', platform_account_login: 'organization-owner', github_app_type: 'standard', - owned_by_organization_id: '00000000-0000-4000-8000-000000000001', + owned_by_organization_id: orgId, + owned_by_user_id: null, }, ]); const result = await service.findInstallationId({ githubRepo: 'renamed-owner/repository', userId: 'user-1', - orgId: '00000000-0000-4000-8000-000000000001', + orgId, }); expect(result).toEqual({ success: false, reason: 'ambiguous_installation' }); }); - it('resolves the exact active organization integration with repository access', async () => { - const integrationId = '00000000-0000-4000-8000-000000000002'; - const orgId = '00000000-0000-4000-8000-000000000001'; - const service = createService([ - { - id: integrationId, - platform_installation_id: '100', - platform_account_login: 'renamed-owner', - github_app_type: 'standard', - integration_status: 'active', - owned_by_organization_id: orgId, - owned_by_user_id: null, - repository_access: 'selected', - repositories: [{ full_name: 'renamed-owner/repository' }], - permissions: { contents: 'write', pull_requests: 'write' }, - }, - ]); - - await expect( - service.findManagedInstallationForRepo({ + describe.each(['findInstallationId', 'findManagedInstallationForRepo'] as const)('%s', method => { + describe.each([undefined, orgId])('owner scope %s', requestedOrgId => { + const params = { githubRepo: 'renamed-owner/repository', - userId: 'user-1', - orgId, - expectedIntegrationId: integrationId, - }) - ).resolves.toEqual({ - success: true, - installationId: '100', - accountLogin: 'renamed-owner', - githubAppType: 'standard', - repoName: 'repository', - permissions: { contents: 'write', pull_requests: 'write' }, - }); - }); - - it.each<[string, Partial]>([ - ['wrong organization', { owned_by_organization_id: '00000000-0000-4000-8000-000000000099' }], - ['personal row', { owned_by_organization_id: null, owned_by_user_id: 'user-1' }], - ['suspended row', { integration_status: 'suspended' }], - ['repository owner mismatch', { platform_account_login: 'other-owner' }], - [ - 'selected repository mismatch', - { repositories: [{ full_name: 'renamed-owner/other-repository' }] }, - ], - ])('rejects an expected integration with %s', async (_reason, override) => { - const integrationId = '00000000-0000-4000-8000-000000000002'; - const orgId = '00000000-0000-4000-8000-000000000001'; - const service = createService([ - { + userId: 'oauth/personal-owner', + ...(requestedOrgId === undefined ? {} : { orgId: requestedOrgId }), + }; + const row: InstallationRow = { id: integrationId, platform_installation_id: '100', platform_account_login: 'renamed-owner', - github_app_type: 'standard', + github_app_type: 'lite', integration_status: 'active', - owned_by_organization_id: orgId, - owned_by_user_id: null, + owned_by_organization_id: requestedOrgId ?? null, + owned_by_user_id: requestedOrgId === undefined ? params.userId : null, repository_access: 'selected', - repositories: [{ full_name: 'renamed-owner/repository' }], - permissions: null, - ...override, - }, - ]); - - await expect( - service.findManagedInstallationForRepo({ - githubRepo: 'renamed-owner/repository', - userId: 'user-1', - orgId, - expectedIntegrationId: integrationId, - }) - ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); - }); - - it('does not accept an expected integration without an organization', async () => { - const service = createService([]); - - await expect( - service.findManagedInstallationForRepo({ - githubRepo: 'renamed-owner/repository', - userId: 'user-1', - expectedIntegrationId: '00000000-0000-4000-8000-000000000002', - }) - ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); - expect(getWorkerDb).not.toHaveBeenCalled(); - }); - - it('returns integration_mismatch when the expected row is not visible to the fenced query', async () => { - const service = createService([]); - - await expect( - service.findManagedInstallationForRepo({ - githubRepo: 'renamed-owner/repository', - userId: 'user-1', - orgId: '00000000-0000-4000-8000-000000000001', - expectedIntegrationId: '00000000-0000-4000-8000-000000000002', - }) - ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + repositories: [{ full_name: 'Renamed-Owner/Repository' }], + permissions: { contents: 'read' }, + }; + const integrationOwner = + requestedOrgId === undefined + ? { type: 'user' as const, id: params.userId } + : { type: 'org' as const, id: requestedOrgId }; + const expectedSuccess = { + success: true, + integrationId, + integrationOwner, + installationId: '100', + accountLogin: 'renamed-owner', + githubAppType: 'lite', + ...(method === 'findManagedInstallationForRepo' + ? { repoName: 'repository', permissions: { contents: 'read' } } + : {}), + }; + + it('returns the resolved identity for a legacy unpinned request', async () => { + await expect(createService([row])[method](params)).resolves.toEqual(expectedSuccess); + }); + + it.each(['selected', 'all'])( + 'resolves an exact active integration with %s access', + async repositoryAccess => { + const service = createService([{ ...row, repository_access: repositoryAccess }]); + await expect( + service[method]({ ...params, expectedIntegrationId: integrationId }) + ).resolves.toEqual(expectedSuccess); + } + ); + + it('returns the database owner for a legacy organization session and its explicit retry', async () => { + const service = createService([row]); + await expect(service[method]({ ...params, orgId })).resolves.toEqual(expectedSuccess); + await expect( + service[method]({ + ...params, + orgId, + expectedIntegrationId: integrationId, + expectedIntegrationOwner: integrationOwner, + }) + ).resolves.toEqual(expectedSuccess); + }); + + it('requires an integration pin with an explicit owner', async () => { + await expect( + createService([row])[method]({ + ...params, + expectedIntegrationOwner: integrationOwner, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it('rejects an explicit owner outside the caller or session context', async () => { + await expect( + createService([row])[method]({ + ...params, + ...(integrationOwner.type === 'user' + ? { userId: 'oauth/another-user' } + : { orgId: undefined }), + expectedIntegrationId: integrationId, + expectedIntegrationOwner: integrationOwner, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it('rejects an explicit organization owner from another session organization', async () => { + const otherOrgId = '00000000-0000-4000-8000-000000000099'; + await expect( + createService([ + { + ...row, + owned_by_organization_id: otherOrgId, + owned_by_user_id: null, + }, + ])[method]({ + ...params, + orgId, + expectedIntegrationId: integrationId, + expectedIntegrationOwner: { type: 'org', id: otherOrgId }, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it('rejects unrecognized fields in an explicit owner', async () => { + const expectedIntegrationOwner = { ...integrationOwner, unknown: true }; + await expect( + createService([row])[method]({ + ...params, + expectedIntegrationId: integrationId, + expectedIntegrationOwner, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it.each<[string, Partial]>([ + ['different integration', { id: otherIntegrationId }], + [ + 'wrong organization', + { owned_by_organization_id: '00000000-0000-4000-8000-000000000099' }, + ], + ['wrong personal owner', { owned_by_user_id: 'oauth/another-user' }], + ['suspended row', { integration_status: 'suspended' }], + ['repository owner mismatch', { platform_account_login: 'other-owner' }], + [ + 'selected repository mismatch', + { repositories: [{ full_name: 'renamed-owner/other-repository' }] }, + ], + ['missing repository access', { repository_access: null }], + ])('rejects an expected integration with %s', async (_reason, override) => { + for (const expectedIntegrationOwner of [undefined, integrationOwner]) { + await expect( + createService([{ ...row, ...override }])[method]({ + ...params, + ...(expectedIntegrationOwner === undefined ? {} : { orgId }), + expectedIntegrationId: integrationId, + expectedIntegrationOwner, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + } + }); + + it('excludes the other owner scope even when the integration ID matches', async () => { + const otherOwner = + requestedOrgId === undefined + ? { owned_by_organization_id: orgId, owned_by_user_id: null } + : { owned_by_organization_id: null, owned_by_user_id: params.userId }; + await expect( + createService([{ ...row, ...otherOwner }])[method]({ + ...params, + expectedIntegrationId: integrationId, + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it('returns integration_mismatch when the expected row is not visible to the authorized query', async () => { + await expect( + createService([])[method]({ ...params, expectedIntegrationId: integrationId }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + }); + + it('preserves the missing-installation error for legacy requests', async () => { + await expect(createService([])[method](params)).resolves.toEqual({ + success: false, + reason: 'no_installation_found', + }); + }); + + it('rejects ambiguous legacy integration selection', async () => { + await expect( + createService([row, { ...row, id: otherIntegrationId }])[method](params) + ).resolves.toEqual({ success: false, reason: 'ambiguous_installation' }); + }); + + it('rejects an invalid integration pin before querying', async () => { + await expect( + createService([row])[method]({ ...params, expectedIntegrationId: 'not-a-uuid' }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + expect(getWorkerDb).not.toHaveBeenCalled(); + }); + }); }); it.each(['owner/repository/extra', 'owner/', '/repository', 'owner//repository', 'owner'])( diff --git a/services/git-token-service/src/installation-lookup-service.test.ts b/services/git-token-service/src/installation-lookup-service.test.ts index 8d8c147261..1e270548ef 100644 --- a/services/git-token-service/src/installation-lookup-service.test.ts +++ b/services/git-token-service/src/installation-lookup-service.test.ts @@ -57,23 +57,83 @@ describe('buildInstallationLookupQuery', () => { expect(query.params).toContain(10); }); - it('uses a supplied integration ID as an exact organization authorization fence', () => { - const db = getWorkerDb('postgres://unused:unused@localhost:0/unused'); - const expectedIntegrationId = '00000000-0000-4000-8000-000000000002'; - const query = buildManagedInstallationLookupQuery(db, { - ...params, - expectedIntegrationId, - }).toSQL(); - - expect(query.sql).toContain('"platform_integrations"."id" ='); - expect(query.sql).toContain('"platform_integrations"."integration_status" ='); - expect(query.sql).toContain('"platform_integrations"."owned_by_organization_id" ='); - expect(query.sql).toContain('"platform_integrations"."owned_by_user_id" is null'); - expect(query.sql).toContain('"organization_memberships"."id" is not null'); - expect(query.sql).not.toContain('"platform_integrations"."owned_by_user_id" ='); - expect(query.params).toContain(expectedIntegrationId); - expect(query.params).toContain(params.orgId); - expect(query.params).toContain('renamed-owner'); - expect(query.params).toContain(1); + describe.each([ + ['raw token', buildInstallationLookupQuery], + ['managed auth', buildManagedInstallationLookupQuery], + ['login repair', buildInstallationRefreshCandidatesQuery], + ] as const)('%s authorization', (_path, buildLookupQuery) => { + it.each([undefined, { type: 'org', id: params.orgId }] as const)( + 'requires the exact organization owner, membership, and an unblocked user with selector %s', + expectedIntegrationOwner => { + const db = getWorkerDb('postgres://unused:unused@localhost:0/unused'); + const expectedIntegrationId = '00000000-0000-4000-8000-000000000002'; + const query = buildLookupQuery(db, { + ...params, + expectedIntegrationId, + expectedIntegrationOwner, + }).toSQL(); + + expect(query.sql).toContain('"platform_integrations"."id" ='); + expect(query.sql).toContain('"platform_integrations"."integration_status" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_organization_id" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_user_id" is null'); + expect(query.sql).toContain('"organization_memberships"."id" is not null'); + expect(query.sql).toContain('"organization_memberships"."kilo_user_id" ='); + expect(query.sql).toContain('exists (select'); + expect(query.sql).toContain('"kilocode_users"."blocked_reason" is null'); + expect(query.sql).not.toContain('"platform_integrations"."owned_by_user_id" ='); + expect(query.params).toContain(expectedIntegrationId); + expect(query.params).toContain(params.orgId); + expect(query.params).toContain('active'); + } + ); + + it('requires the exact Personal owner and excludes organization and blocked-user access', () => { + const db = getWorkerDb('postgres://unused:unused@localhost:0/unused'); + const expectedIntegrationId = '00000000-0000-4000-8000-000000000002'; + const query = buildLookupQuery(db, { + githubRepo: params.githubRepo, + userId: 'oauth/personal-owner', + expectedIntegrationId, + }).toSQL(); + + expect(query.sql).toContain('"platform_integrations"."id" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_user_id" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_organization_id" is null'); + expect(query.sql).toContain('"kilocode_users"."blocked_reason" is null'); + expect(query.sql).toContain('"platform_integrations"."integration_status" ='); + expect(query.sql).toContain('"platform_integrations"."platform_installation_id" is not null'); + expect(query.sql).not.toContain('"platform_integrations"."owned_by_user_id" is null'); + expect(query.sql).not.toContain('"organization_memberships"."id" is not null'); + expect(query.sql).not.toContain('false'); + expect(query.params).toContain('oauth/personal-owner'); + expect(query.params).toContain(expectedIntegrationId); + expect(query.params).toContain('active'); + }); + + it('retains session membership and blocked-user guards for an explicit Personal pin', () => { + const db = getWorkerDb('postgres://unused:unused@localhost:0/unused'); + const expectedIntegrationId = '00000000-0000-4000-8000-000000000002'; + const query = buildLookupQuery(db, { + ...params, + userId: 'oauth/personal-owner', + expectedIntegrationId, + expectedIntegrationOwner: { type: 'user', id: 'oauth/personal-owner' }, + }).toSQL(); + + expect(query.sql).toContain('exists (select'); + expect(query.sql).toContain('"organization_memberships"."kilo_user_id" ='); + expect(query.sql).toContain('"platform_integrations"."id" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_user_id" ='); + expect(query.sql).toContain('"platform_integrations"."owned_by_organization_id" is null'); + expect(query.sql).toContain('"kilocode_users"."blocked_reason" is null'); + expect(query.sql).toContain('"platform_integrations"."integration_status" ='); + expect(query.sql).not.toContain('"platform_integrations"."owned_by_user_id" is null'); + expect(query.sql).not.toContain(' or '); + expect(query.params).toContain(expectedIntegrationId); + expect(query.params.filter(param => param === params.orgId)).toHaveLength(1); + expect(query.params.filter(param => param === 'oauth/personal-owner')).toHaveLength(4); + expect(query.params).toContain('active'); + }); }); }); diff --git a/services/git-token-service/src/installation-lookup-service.ts b/services/git-token-service/src/installation-lookup-service.ts index df690cc1a2..ef8126a22a 100644 --- a/services/git-token-service/src/installation-lookup-service.ts +++ b/services/git-token-service/src/installation-lookup-service.ts @@ -6,23 +6,24 @@ import { kilocode_users, } from '@kilocode/db/schema'; import { eq, and, exists, isNull, isNotNull, or, sql } from 'drizzle-orm'; +import type { Owner } from '../../../packages/app-shared/src/code-review/repository-identity.js'; +import { GitHubIntegrationOwnerSchema } from './github-session-capability.js'; export type FindInstallationParams = { githubRepo: string; userId: string; orgId?: string; expectedIntegrationId?: string; + expectedIntegrationOwner?: Owner; }; const InstallationLookupResultSchema = z.object({ + id: z.string(), platform_installation_id: z.string(), platform_account_login: z.string().nullable(), github_app_type: z.enum(['standard', 'lite']).nullable().optional(), owned_by_organization_id: z.string().nullable(), -}); - -const InstallationRefreshCandidateSchema = InstallationLookupResultSchema.extend({ - id: z.string(), + owned_by_user_id: z.string().nullable(), }); const ManagedInstallationLookupResultSchema = InstallationLookupResultSchema.extend({ @@ -40,13 +41,14 @@ const ManagedInstallationLookupResultSchema = InstallationLookupResultSchema.ext const ExactManagedInstallationLookupResultSchema = ManagedInstallationLookupResultSchema.extend({ id: z.string().uuid(), integration_status: z.string(), - owned_by_user_id: z.string().nullable(), }); const MAX_INSTALLATION_LOGIN_REFRESH_CANDIDATES = 10; export type InstallationLookupSuccess = { success: true; + integrationId: string; + integrationOwner: Owner; installationId: string; accountLogin: string; githubAppType: 'standard' | 'lite'; @@ -86,6 +88,23 @@ export type ManagedInstallationLookupResult = | InstallationLookupFailure | { success: false; reason: 'repository_not_installed' }; +function getExpectedIntegrationOwner(params: FindInstallationParams): Owner { + return ( + params.expectedIntegrationOwner ?? + (params.orgId === undefined + ? { type: 'user', id: params.userId } + : { type: 'org', id: params.orgId }) + ); +} + +function getIntegrationOwner(row: z.infer): Owner { + return GitHubIntegrationOwnerSchema.parse( + row.owned_by_organization_id === null + ? { type: 'user', id: row.owned_by_user_id } + : { type: 'org', id: row.owned_by_organization_id } + ); +} + function buildAuthorizedInstallationsQuery( db: WorkerDb, params: FindInstallationParams, @@ -109,17 +128,23 @@ function buildAuthorizedInstallationsQuery( ) ) ); + const expectedOwner = getExpectedIntegrationOwner(params); const exactIntegrationOwner = params.expectedIntegrationId === undefined ? undefined - : params.orgId === undefined - ? sql`false` - : and( - eq(platform_integrations.id, params.expectedIntegrationId), - eq(platform_integrations.owned_by_organization_id, params.orgId), - isNull(platform_integrations.owned_by_user_id), - isNotNull(organization_memberships.id) - ); + : and( + eq(platform_integrations.id, params.expectedIntegrationId), + expectedOwner.type === 'user' + ? and( + eq(platform_integrations.owned_by_user_id, expectedOwner.id), + isNull(platform_integrations.owned_by_organization_id) + ) + : and( + eq(platform_integrations.owned_by_organization_id, expectedOwner.id), + isNull(platform_integrations.owned_by_user_id), + isNotNull(organization_memberships.id) + ) + ); const legacyAuthorizedOwner = params.expectedIntegrationId === undefined ? or( @@ -228,12 +253,24 @@ export class InstallationLookupService { if ( params.expectedIntegrationId !== undefined && - (params.orgId === undefined || - !z.string().uuid().safeParse(params.expectedIntegrationId).success) + !z.string().uuid().safeParse(params.expectedIntegrationId).success ) { return { success: false, reason: 'integration_mismatch' }; } + if (params.expectedIntegrationOwner !== undefined) { + const owner = GitHubIntegrationOwnerSchema.safeParse(params.expectedIntegrationOwner); + if ( + !owner.success || + params.expectedIntegrationId === undefined || + (owner.data.type === 'user' + ? owner.data.id !== params.userId + : owner.data.id !== params.orgId) + ) { + return { success: false, reason: 'integration_mismatch' }; + } + } + const repoParts = params.githubRepo.split('/'); if (repoParts.length !== 2 || repoParts.some(part => part.length === 0)) { return { success: false, reason: 'invalid_repo_format' }; @@ -258,6 +295,8 @@ export class InstallationLookupService { return { success: true, + integrationId: selected.data.id, + integrationOwner: getIntegrationOwner(selected.data), installationId: selected.data.platform_installation_id, accountLogin: selected.data.platform_account_login ?? '', githubAppType: selected.data.github_app_type ?? 'standard', @@ -284,6 +323,8 @@ export class InstallationLookupService { return { success: true, + integrationId: selected.id, + integrationOwner: getIntegrationOwner(selected), installationId: selected.platform_installation_id, accountLogin: selected.platform_account_login ?? '', githubAppType: selected.github_app_type ?? 'standard', @@ -302,7 +343,7 @@ export class InstallationLookupService { return { success: true, candidates: rows.map(row => { - const parsed = InstallationRefreshCandidateSchema.parse(row); + const parsed = InstallationLookupResultSchema.parse(row); return { integrationId: parsed.id, installationId: parsed.platform_installation_id, @@ -348,6 +389,8 @@ export class InstallationLookupService { return { success: true, + integrationId: selected.data.id, + integrationOwner: getIntegrationOwner(selected.data), installationId: selected.data.platform_installation_id, accountLogin: selected.data.platform_account_login ?? '', githubAppType: selected.data.github_app_type ?? 'standard', @@ -385,6 +428,8 @@ export class InstallationLookupService { return { success: true, + integrationId: selected.id, + integrationOwner: getIntegrationOwner(selected), installationId: selected.platform_installation_id, accountLogin: selected.platform_account_login ?? '', githubAppType: selected.github_app_type ?? 'standard', @@ -397,11 +442,17 @@ export class InstallationLookupService { selected: z.infer, params: FindInstallationParams ): boolean { + const expectedOwner = getExpectedIntegrationOwner(params); + const matchesOwner = + expectedOwner.type === 'user' + ? selected.owned_by_user_id === expectedOwner.id && + selected.owned_by_organization_id === null + : selected.owned_by_organization_id === expectedOwner.id && + selected.owned_by_user_id === null; if ( selected.id !== params.expectedIntegrationId || selected.integration_status !== 'active' || - selected.owned_by_organization_id !== params.orgId || - selected.owned_by_user_id !== null || + !matchesOwner || selected.platform_account_login?.toLowerCase() !== params.githubRepo.split('/')[0]?.toLowerCase() ) { From 97e9ed1dfdc55b4174068ce7c623006915e89188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 29 Aug 2026 09:47:29 +0200 Subject: [PATCH 4/4] feat(cloud-agent): preserve provider identity through checkout --- .../src/persistence/CloudAgentSession.ts | 84 ++- .../src/persistence/session-metadata.test.ts | 496 +++++++++++++++++- .../src/persistence/session-metadata.ts | 79 +++ .../router/handlers/session-prepare.test.ts | 62 ++- .../src/router/handlers/session-prepare.ts | 18 +- .../src/router/handlers/session-start.ts | 13 +- .../src/router/schemas.test.ts | 103 ++++ .../cloud-agent-next/src/router/schemas.ts | 26 +- .../services/git-token-service-client.test.ts | 282 ++++++++++ .../src/services/git-token-service-client.ts | 143 +++-- .../src/session-prepare.test.ts | 63 ++- .../src/session-service.test.ts | 385 ++++++++++++++ .../cloud-agent-next/src/session-service.ts | 130 ++++- .../src/session/session-prepare.test.ts | 241 ++++++++- .../src/session/session-registration.ts | 71 ++- .../src/session/session-requests.ts | 26 +- .../validate-repository-access.test.ts | 170 +++++- .../src/session/validate-repository-access.ts | 84 ++- services/cloud-agent-next/src/types.ts | 30 +- .../wrapper/src/session-bootstrap.test.ts | 40 +- 20 files changed, 2404 insertions(+), 142 deletions(-) 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);