diff --git a/services/cloud-agent-next/src/persistence/session-metadata.ts b/services/cloud-agent-next/src/persistence/session-metadata.ts index 33ce5c0e2e..798d06783c 100644 --- a/services/cloud-agent-next/src/persistence/session-metadata.ts +++ b/services/cloud-agent-next/src/persistence/session-metadata.ts @@ -202,6 +202,7 @@ const CredentialContainmentSchema = z .object({ github: z.boolean(), gitlab: z.boolean(), + bitbucket: z.boolean().optional(), kilocode: z.boolean(), }) .strip(); @@ -314,7 +315,7 @@ export function getEffectiveCredentialContainment( export function requiresContainmentSandbox(metadata: SessionMetadata): boolean { const containment = getEffectiveCredentialContainment(metadata); - return containment.github || containment.gitlab || containment.kilocode; + return containment.github || containment.gitlab || containment.bitbucket || containment.kilocode; } export function getSandboxProvider(metadata: SessionMetadata): AgentSandboxProvider { diff --git a/services/cloud-agent-next/src/sandbox-outbound.test.ts b/services/cloud-agent-next/src/sandbox-outbound.test.ts index 46318ae968..e11adfec6f 100644 --- a/services/cloud-agent-next/src/sandbox-outbound.test.ts +++ b/services/cloud-agent-next/src/sandbox-outbound.test.ts @@ -1299,3 +1299,68 @@ describe('handleManagedScmOutbound Kilo authorization', () => { expect(forward).toHaveBeenCalledOnce(); }); }); + +describe('handleManagedScmOutbound Bitbucket', () => { + const BITBUCKET_CAPABILITY = 'kbb1.opaque'; + const REDEEMED_BITBUCKET_AUTHORIZATION = `Basic ${Buffer.from('x-token-auth:upstream-token').toString('base64')}`; + + function bitbucketEnv( + redeemBitbucketSessionCapability: ReturnType + ): Cloudflare.Env { + return { + GIT_TOKEN_SERVICE: { + redeemGitHubSessionCapability: vi.fn(), + redeemGitLabSessionCapability: vi.fn(), + redeemKiloSessionCapability: vi.fn(), + redeemBitbucketSessionCapability, + }, + INTERNAL_API_SECRET_PROD: { get: vi.fn(async () => 'trusted-internal-secret') }, + SESSION_INGEST: { fetch: vi.fn() }, + } as unknown as Cloudflare.Env; + } + + function bitbucketGitRequest(username = 'x-token-auth'): Request { + return new Request('https://bitbucket.org/acme/widgets.git/git-upload-pack', { + method: 'POST', + headers: { Authorization: basicCredential(BITBUCKET_CAPABILITY, 'Basic', username) }, + }); + } + + it('redeems a Bitbucket capability and forwards with injected auth', async () => { + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + const redeem = vi.fn(async () => ({ + success: true, + headers: { authorization: REDEEMED_BITBUCKET_AUTHORIZATION }, + })); + + const response = await handleOutbound(bitbucketGitRequest(), bitbucketEnv(redeem)); + + expect(response.status).toBe(200); + expect(redeem).toHaveBeenCalledWith({ + capability: BITBUCKET_CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'POST', + requestUrl: 'https://bitbucket.org/acme/widgets.git/git-upload-pack', + }); + const forwardedRequest = forward.mock.calls[0][0] as Request; + expect(forwardedRequest.headers.get('Authorization')).toBe(REDEEMED_BITBUCKET_AUTHORIZATION); + }); + + it('fails closed when Bitbucket redemption is rejected', async () => { + const redeem = vi.fn(async () => ({ success: false, reason: 'container_mismatch' })); + const response = await handleOutbound(bitbucketGitRequest(), bitbucketEnv(redeem)); + expect(response.status).toBe(502); + }); + + it('does not redeem a Bitbucket capability sent under the wrong Basic username', async () => { + const redeem = vi.fn(); + // A bitbucket capability is only valid git auth under x-token-auth. + const response = await handleOutbound( + bitbucketGitRequest('x-access-token'), + bitbucketEnv(redeem) + ); + expect(redeem).not.toHaveBeenCalled(); + expect(response.status).toBe(502); + }); +}); diff --git a/services/cloud-agent-next/src/sandbox-outbound.ts b/services/cloud-agent-next/src/sandbox-outbound.ts index d3631ff8ae..d54472b0f4 100644 --- a/services/cloud-agent-next/src/sandbox-outbound.ts +++ b/services/cloud-agent-next/src/sandbox-outbound.ts @@ -15,13 +15,22 @@ export { MANAGED_SCM_OUTBOUND_HANDLER } from './sandbox-id.js'; const GITHUB_CAPABILITY_PREFIXES = ['kgh1.', 'kgh2.']; const GITLAB_CAPABILITY_PREFIXES = ['kgl1.', 'kgl2.']; const KILO_CAPABILITY_PREFIXES = ['kka1.']; +const BITBUCKET_CAPABILITY_PREFIXES = ['kbb1.']; const MAX_KILO_SESSION_BOOTSTRAP_BYTES = 16_000; type GitHubTokenRedemptionBinding = Pick; type GitLabTokenRedemptionBinding = Pick; type KiloTokenRedemptionBinding = Pick; +type BitbucketTokenRedemptionBinding = { + redeemBitbucketSessionCapability: NonNullable< + GitTokenService['redeemBitbucketSessionCapability'] + >; +}; type ManagedScmOutboundContext = { containerId: string }; -type RedeemableAuthorization = { provider: 'github' | 'gitlab' | 'kilo'; capability: string }; +type RedeemableAuthorization = { + provider: 'github' | 'gitlab' | 'kilo' | 'bitbucket'; + capability: string; +}; type AuthorizationExtraction = | { type: 'none' } | { type: 'capability'; value: RedeemableAuthorization } @@ -31,10 +40,11 @@ const NO_AUTHORIZATION_CAPABILITY = { type: 'none' } satisfies AuthorizationExtr type ScmClient = 'github-cli' | 'gitlab-cli' | 'git-lfs' | 'git' | 'other'; type ScmMethod = 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'OPTIONS' | 'other'; -type ScmTarget = 'github-api' | 'github-git' | 'gitlab' | 'other'; +type ScmTarget = 'github-api' | 'github-git' | 'gitlab' | 'bitbucket' | 'other'; type AuthorizationClass = | 'github-managed' | 'gitlab-managed' + | 'bitbucket-managed' | 'unsupported-managed' | 'mixed' | 'unmanaged' @@ -90,6 +100,7 @@ function classifyScmTarget(url: URL): ScmTarget { if (url.hostname === 'api.github.com') return 'github-api'; if (url.hostname === 'github.com') return 'github-git'; if (url.hostname === 'gitlab.com') return 'gitlab'; + if (url.hostname === 'bitbucket.org') return 'bitbucket'; return 'other'; } @@ -107,7 +118,9 @@ function classifyScmRoute(url: URL, target: ScmTarget): string { if (url.pathname.endsWith('/git-receive-pack')) return 'git-receive-pack'; return 'github-git-other'; } - return target === 'gitlab' ? 'gitlab' : 'other'; + if (target === 'gitlab') return 'gitlab'; + if (target === 'bitbucket') return 'bitbucket'; + return 'other'; } function getSafeRequestLogFields(request: Request) { @@ -250,6 +263,17 @@ function supportsKiloSessionCapabilityRedemption( ); } +function supportsBitbucketSessionCapabilityRedemption( + service: unknown +): service is BitbucketTokenRedemptionBinding { + return ( + typeof service === 'object' && + service !== null && + 'redeemBitbucketSessionCapability' in service && + typeof service.redeemBitbucketSessionCapability === 'function' + ); +} + function classifyCapability(capability: string): AuthorizationExtraction { if (GITHUB_CAPABILITY_PREFIXES.some(prefix => capability.startsWith(prefix))) { return { type: 'capability', value: { provider: 'github', capability } }; @@ -260,7 +284,10 @@ function classifyCapability(capability: string): AuthorizationExtraction { if (KILO_CAPABILITY_PREFIXES.some(prefix => capability.startsWith(prefix))) { return { type: 'capability', value: { provider: 'kilo', capability } }; } - return /^(?:kgh|kgl|kka)\d+\./.test(capability) + if (BITBUCKET_CAPABILITY_PREFIXES.some(prefix => capability.startsWith(prefix))) { + return { type: 'capability', value: { provider: 'bitbucket', capability } }; + } + return /^(?:kgh|kgl|kka|kbb)\d+\./.test(capability) ? { type: 'unsupported_capability' } : NO_AUTHORIZATION_CAPABILITY; } @@ -287,6 +314,9 @@ function extractGitCapability(authorization: string | null): AuthorizationExtrac if (username === 'oauth2' && extraction.value.provider === 'gitlab') { return extraction; } + if (username === 'x-token-auth' && extraction.value.provider === 'bitbucket') { + return extraction; + } return { type: 'unsupported_capability' }; } @@ -330,7 +360,11 @@ function getAuthorizationClass( ) { return 'mixed'; } - return capability.provider === 'github' ? 'github-managed' : 'gitlab-managed'; + return capability.provider === 'github' + ? 'github-managed' + : capability.provider === 'bitbucket' + ? 'bitbucket-managed' + : 'gitlab-managed'; } async function forwardRedeemedRequest( @@ -667,6 +701,82 @@ async function handleManagedGitLabOutbound( return response; } +async function handleManagedBitbucketOutbound( + request: Request, + env: Cloudflare.Env, + capability: { capability: string }, + outboundContainerId: string +): Promise { + const logFields = { + ...getSafeRequestLogFields(request), + provider: 'bitbucket', + capabilityVersion: getCapabilityVersion(capability.capability), + outboundContainerId, + }; + logDiagnostic('debug', logFields, 'Redeeming managed Bitbucket outbound request'); + + const tokenService = env.GIT_TOKEN_SERVICE; + if (!supportsBitbucketSessionCapabilityRedemption(tokenService)) { + logDiagnostic( + 'warn', + { ...logFields, failureStage: 'redemption-binding' }, + 'Managed Bitbucket outbound redemption unavailable' + ); + return new Response('Bitbucket authorization unavailable', { status: 502 }); + } + + let result: Awaited< + ReturnType + >; + try { + result = await tokenService.redeemBitbucketSessionCapability({ + capability: capability.capability, + outboundContainerId, + requestMethod: request.method, + requestUrl: request.url, + }); + } catch (error) { + logDiagnostic( + 'warn', + { ...logFields, failureStage: 'redemption-rpc', errorClass: classifyDiagnosticError(error) }, + 'Managed Bitbucket outbound redemption failed' + ); + return new Response('Bitbucket authorization unavailable', { status: 502 }); + } + + if (!result.success) { + logDiagnostic( + 'warn', + { ...logFields, failureStage: 'redemption-policy', reason: result.reason }, + 'Managed Bitbucket outbound redemption rejected' + ); + return new Response('Bitbucket authorization unavailable', { status: 502 }); + } + + let response: Response; + try { + response = await forwardRedeemedRequest(request, result.headers); + } catch (error) { + logDiagnostic( + 'warn', + { + ...logFields, + failureStage: 'upstream-forward', + errorClass: classifyDiagnosticError(error), + }, + 'Managed Bitbucket outbound forwarding failed' + ); + return new Response('Bitbucket authorization unavailable', { status: 502 }); + } + + logDiagnostic( + 'info', + { ...logFields, upstreamStatus: response.status }, + 'Managed Bitbucket outbound request forwarded' + ); + return response; +} + export function handleManagedScmOutbound( request: Request, env: Cloudflare.Env, @@ -725,6 +835,9 @@ export function handleManagedScmOutbound( if (capability.provider === 'kilo') { return handleManagedKiloOutbound(request, env, capability, ctx.containerId); } + if (capability.provider === 'bitbucket') { + return handleManagedBitbucketOutbound(request, env, capability, ctx.containerId); + } return handleManagedGitLabOutbound(request, env, capability, ctx.containerId); } 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 0e34d40284..0f6fce3ee0 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 @@ -345,6 +345,44 @@ export async function resolveManagedBitbucketToken( } } +export type ResolvedCloudAgentBitbucketCapability = { + capability: string; + gitUrl: string; +}; + +export async function issueCloudAgentBitbucketSessionCapability( + env: GitTokenServiceEnv, + params: { + userId: string; + orgId: string; + outboundContainerId: string; + expectedIntegrationId?: string; + workspaceUuid: string; + repositoryUuid: string; + repositoryUrl: string; + } +): Promise< + | { success: true; value: ResolvedCloudAgentBitbucketCapability } + | { + success: false; + reason: ManagedBitbucketTokenFailureReason | 'capability_configuration_error'; + } +> { + if (!env.GIT_TOKEN_SERVICE?.issueBitbucketSessionCapability) { + return { success: false, reason: 'service_not_configured' }; + } + try { + 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 } }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.withFields({ error: message }).error('Failed to issue Bitbucket session capability'); + return { success: false, reason: 'rpc_error' }; + } +} + export async function issueCloudAgentGitLabSessionCapability( env: GitTokenServiceEnv, params: { diff --git a/services/cloud-agent-next/src/session-prepare.test.ts b/services/cloud-agent-next/src/session-prepare.test.ts index a9d084069e..55bff9ef5e 100644 --- a/services/cloud-agent-next/src/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session-prepare.test.ts @@ -551,7 +551,7 @@ describe('prepareSession endpoint', () => { sandboxId: 'crv-abcdef', sandboxProvider: 'cloudflare', shallow: true, - credentialContainment: { github: true, gitlab: false, kilocode: false }, + credentialContainment: { github: true, gitlab: false, bitbucket: false, kilocode: false }, }, }) ); @@ -803,7 +803,7 @@ describe('prepareSession endpoint', () => { sandboxId: failoverSandboxId, sandboxProvider: 'cloudflare', shallow: undefined, - credentialContainment: { github: true, gitlab: false, kilocode: false }, + credentialContainment: { github: true, gitlab: false, bitbucket: false, kilocode: false }, sandboxRoute: { kind: 'shared', routeKey, @@ -880,7 +880,12 @@ describe('prepareSession endpoint', () => { sandboxId: 'dind-abcdef', sandboxProvider: 'cloudflare', shallow: false, - credentialContainment: { github: false, gitlab: false, kilocode: false }, + credentialContainment: { + github: false, + gitlab: false, + bitbucket: false, + kilocode: false, + }, devcontainerRequested: true, }, }) @@ -916,7 +921,7 @@ describe('prepareSession endpoint', () => { expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ workspace: expect.objectContaining({ - credentialContainment: { github: true, gitlab: false, kilocode: false }, + credentialContainment: { github: true, gitlab: false, bitbucket: false, kilocode: false }, }), }) ); @@ -942,7 +947,12 @@ describe('prepareSession endpoint', () => { expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ workspace: expect.objectContaining({ - credentialContainment: { github: false, gitlab: false, kilocode: false }, + credentialContainment: { + github: false, + gitlab: false, + bitbucket: false, + kilocode: false, + }, }), }) ); @@ -966,7 +976,7 @@ describe('prepareSession endpoint', () => { expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ workspace: expect.objectContaining({ - credentialContainment: { github: false, gitlab: false, kilocode: true }, + credentialContainment: { github: false, gitlab: false, bitbucket: false, kilocode: true }, }), }) ); @@ -993,7 +1003,12 @@ describe('prepareSession endpoint', () => { expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ workspace: expect.objectContaining({ - credentialContainment: { github: false, gitlab: false, kilocode: false }, + credentialContainment: { + github: false, + gitlab: false, + bitbucket: false, + kilocode: false, + }, }), }) ); @@ -1018,7 +1033,7 @@ describe('prepareSession endpoint', () => { expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ workspace: expect.objectContaining({ - credentialContainment: { github: true, gitlab: false, kilocode: false }, + credentialContainment: { github: true, gitlab: false, bitbucket: false, kilocode: false }, }), }) ); @@ -1372,7 +1387,7 @@ describe('start endpoint', () => { expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ workspace: expect.objectContaining({ - credentialContainment: { github: true, gitlab: false, kilocode: false }, + credentialContainment: { github: true, gitlab: false, bitbucket: false, kilocode: false }, }), }) ); @@ -1393,7 +1408,7 @@ describe('start endpoint', () => { expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ workspace: expect.objectContaining({ - credentialContainment: { github: false, gitlab: true, kilocode: false }, + credentialContainment: { github: false, gitlab: true, bitbucket: false, kilocode: false }, }), }) ); @@ -1418,7 +1433,12 @@ describe('start endpoint', () => { expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ workspace: expect.objectContaining({ - credentialContainment: { github: false, gitlab: false, kilocode: false }, + credentialContainment: { + github: false, + gitlab: false, + bitbucket: false, + kilocode: false, + }, }), }) ); diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index 80e31dd7fc..147b6d63f5 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -15,6 +15,7 @@ import { normalizeKilocodeModel } from './persistence/model-utils.js'; import { isTemporaryManagedBitbucketTokenFailure, issueCloudAgentGitHubSessionCapability, + issueCloudAgentBitbucketSessionCapability, issueCloudAgentGitLabSessionCapability, issueCloudAgentKiloSessionCapability, resolveCloudAgentGitHubAuthForRepo, @@ -631,6 +632,7 @@ export type ResolvedWorkspaceTokens = { githubFallbackReason?: ManagedGitHubFallbackReason; gitToken?: string; gitlabCapabilityGitUrl?: string; + bitbucketCapabilityGitUrl?: string; gitlabTokenManaged?: boolean; bitbucketTokenManaged?: boolean; gitlabInstanceUrl?: string; @@ -1742,6 +1744,7 @@ export class SessionService { const platform = repositoryPlatform(metadata); let gitToken = git?.type === 'git' ? git.token : undefined; let gitlabCapabilityGitUrl: string | undefined; + let bitbucketCapabilityGitUrl: string | undefined; let gitlabTokenManaged = git?.type === 'gitlab' ? git.gitlabTokenManaged : undefined; let bitbucketTokenManaged = git?.type === 'bitbucket' ? git.bitbucketTokenManaged : undefined; let gitlabInstanceUrl: string | undefined; @@ -1796,26 +1799,71 @@ export class SessionService { throw ExecutionError.invalidRequest('Bitbucket repositories require an organization'); } - const result = await resolveManagedBitbucketToken(env, { - userId: metadata.identity.userId, - orgId: metadata.identity.orgId, - ...(git.bitbucketIntegrationId - ? { expectedIntegrationId: git.bitbucketIntegrationId } - : {}), - workspaceUuid: git.workspaceUuid, - repositoryUuid: git.repositoryUuid, - repositoryUrl: git.url, - }); - if (!result.success) { - const reconnect = result.reason === 'reconnect_required' ? ' Reconnect Bitbucket.' : ''; - const message = `Bitbucket repository authorization failed (${result.reason}).${reconnect}`; - if (isTemporaryManagedBitbucketTokenFailure(result.reason)) { - throw ExecutionError.workspaceSetupFailed(message); + if (credentialContainment.bitbucket) { + // Contained sessions get an opaque capability instead of the raw + // workspace token; the outbound interceptor redeems it per request, so + // bitbucket.org is `git.url` (the canonical clone URL) with the + // capability supplied as the git password by the wrapper. + if (!env.GIT_TOKEN_SERVICE) { + throw ExecutionError.invalidRequest('Git token service is not configured'); } - throw ExecutionError.invalidRequest(message); + const result = await issueCloudAgentBitbucketSessionCapability(env, { + userId: metadata.identity.userId, + orgId: metadata.identity.orgId, + outboundContainerId: getOutboundContainerId(env, sandboxId, { + managedScmContainment: containmentSandboxRequired, + }), + ...(git.bitbucketIntegrationId + ? { expectedIntegrationId: git.bitbucketIntegrationId } + : {}), + workspaceUuid: git.workspaceUuid, + repositoryUuid: git.repositoryUuid, + repositoryUrl: git.url, + }); + if (!result.success) { + // Mirror the non-containment branch: fail fast (non-retryable) with an + // actionable message for permanent, user-fixable reasons; only retry + // transient ones. capability_configuration_error is a server-side + // misconfiguration, treated as transient like service_not_configured. + const reconnect = result.reason === 'reconnect_required' ? ' Reconnect Bitbucket.' : ''; + const message = `Bitbucket session capability issuance failed (${result.reason}).${reconnect}`; + if ( + result.reason === 'capability_configuration_error' || + isTemporaryManagedBitbucketTokenFailure(result.reason) + ) { + throw ExecutionError.workspaceSetupFailed(message); + } + throw ExecutionError.invalidRequest(message); + } + 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 + // the outbound path against this canonical name, so clone with it too + // (mirrors gitlabCapabilityGitUrl) or contained clones 404/mismatch. + bitbucketCapabilityGitUrl = result.value.gitUrl; + bitbucketTokenManaged = true; + } else { + const result = await resolveManagedBitbucketToken(env, { + userId: metadata.identity.userId, + orgId: metadata.identity.orgId, + ...(git.bitbucketIntegrationId + ? { expectedIntegrationId: git.bitbucketIntegrationId } + : {}), + workspaceUuid: git.workspaceUuid, + repositoryUuid: git.repositoryUuid, + repositoryUrl: git.url, + }); + if (!result.success) { + const reconnect = result.reason === 'reconnect_required' ? ' Reconnect Bitbucket.' : ''; + const message = `Bitbucket repository authorization failed (${result.reason}).${reconnect}`; + if (isTemporaryManagedBitbucketTokenFailure(result.reason)) { + throw ExecutionError.workspaceSetupFailed(message); + } + throw ExecutionError.invalidRequest(message); + } + gitToken = result.token; + bitbucketTokenManaged = true; } - gitToken = result.token; - bitbucketTokenManaged = true; } if (git?.type === 'bitbucket' && !gitToken) { @@ -1832,6 +1880,7 @@ export class SessionService { githubFallbackReason, gitToken, gitlabCapabilityGitUrl, + bitbucketCapabilityGitUrl, gitlabTokenManaged, bitbucketTokenManaged, gitlabInstanceUrl, @@ -1983,7 +2032,10 @@ export class SessionService { sessionHome, githubRepo: github?.repo, githubToken: resolvedTokens.githubToken, - gitUrl: resolvedTokens.gitlabCapabilityGitUrl ?? git?.url, + gitUrl: + resolvedTokens.gitlabCapabilityGitUrl ?? + resolvedTokens.bitbucketCapabilityGitUrl ?? + git?.url, gitToken: resolvedTokens.gitToken, gitlabTokenManaged: resolvedTokens.gitlabTokenManaged, bitbucketTokenManaged: resolvedTokens.bitbucketTokenManaged, @@ -2013,7 +2065,10 @@ export class SessionService { createdOnPlatform: metadata.identity.createdOnPlatform, callbackTarget: metadata.callback?.target, appendSystemPrompt: metadata.agent?.appendSystemPrompt, - gitUrl: resolvedTokens.gitlabCapabilityGitUrl ?? git?.url, + gitUrl: + resolvedTokens.gitlabCapabilityGitUrl ?? + resolvedTokens.bitbucketCapabilityGitUrl ?? + git?.url, gitToken: resolvedTokens.gitToken, gitlabInstanceUrl: resolvedTokens.gitlabInstanceUrl, glabIsOAuth2: resolvedTokens.glabIsOAuth2, @@ -2168,7 +2223,7 @@ export class SessionService { if (git) { return { kind: 'git', - url: tokens.gitlabCapabilityGitUrl ?? git.url, + url: tokens.gitlabCapabilityGitUrl ?? tokens.bitbucketCapabilityGitUrl ?? git.url, ...(tokens.gitToken ? { token: tokens.gitToken } : {}), ...(repositoryPlatform(metadata) ? { platform: repositoryPlatform(metadata) } : {}), ...(repositoryShallow(metadata) !== undefined @@ -2240,7 +2295,10 @@ export class SessionService { sessionHome, githubRepo: github?.repo, githubToken: resolvedTokens.githubToken, - gitUrl: resolvedTokens.gitlabCapabilityGitUrl ?? git?.url, + gitUrl: + resolvedTokens.gitlabCapabilityGitUrl ?? + resolvedTokens.bitbucketCapabilityGitUrl ?? + git?.url, gitToken: resolvedTokens.gitToken, gitlabTokenManaged: resolvedTokens.gitlabTokenManaged, bitbucketTokenManaged: resolvedTokens.bitbucketTokenManaged, @@ -2544,7 +2602,7 @@ export class SessionService { await cloneGitRepo( session, workspacePath, - tokens.gitlabCapabilityGitUrl ?? git.url, + tokens.gitlabCapabilityGitUrl ?? tokens.bitbucketCapabilityGitUrl ?? git.url, tokens.gitToken, undefined, { @@ -2661,7 +2719,7 @@ export class SessionService { await updateGitRemoteToken( session, context.workspacePath, - tokens.gitlabCapabilityGitUrl ?? git.url, + tokens.gitlabCapabilityGitUrl ?? tokens.bitbucketCapabilityGitUrl ?? git.url, tokens.gitToken, repositoryPlatform(metadata) ); diff --git a/services/cloud-agent-next/src/session/session-registration.ts b/services/cloud-agent-next/src/session/session-registration.ts index 9b4f659ad5..9f6ac210bb 100644 --- a/services/cloud-agent-next/src/session/session-registration.ts +++ b/services/cloud-agent-next/src/session/session-registration.ts @@ -165,6 +165,10 @@ async function allocateNewSession( !devcontainerRequested && input.repository.type === 'gitlab' && isOrgInList(ctx.env.GITLAB_TOKEN_CONTAINMENT_ORG_IDS, orgId), + bitbucket: + !devcontainerRequested && + input.repository.type === 'bitbucket' && + isOrgInList(ctx.env.BITBUCKET_TOKEN_CONTAINMENT_ORG_IDS, orgId), kilocode: !devcontainerRequested && isOrgInList(ctx.env.KILOCODE_TOKEN_CONTAINMENT_ORG_IDS, orgId), }; diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index ef4aa2b37a..bf0be430f8 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -353,6 +353,25 @@ type GetBitbucketTokenResult = | { success: true; token: string } | { success: false; reason: BitbucketTokenFailureReason }; +type IssueBitbucketSessionCapabilityResult = + | { success: true; capability: string; gitUrl: string } + | { success: false; reason: BitbucketTokenFailureReason | 'capability_configuration_error' }; + +type RedeemBitbucketSessionCapabilityResult = + | { success: true; headers: { authorization: string } } + | { + success: false; + reason: + | 'invalid_capability' + | 'expired_capability' + | 'capability_configuration_error' + | 'container_mismatch' + | 'invalid_upstream_url' + | 'upstream_origin_not_allowed' + | 'repository_mismatch' + | 'source_unavailable'; + }; + export type KiloSessionCapabilityTargets = { backendBaseUrl: string; providerBaseUrl: string; @@ -443,6 +462,21 @@ export type GitTokenService = { requestMethod: string; requestUrl: string; }): Promise; + issueBitbucketSessionCapability?(params: { + userId: string; + orgId: string; + outboundContainerId: string; + expectedIntegrationId?: string; + workspaceUuid: string; + repositoryUuid: string; + repositoryUrl: string; + }): Promise; + redeemBitbucketSessionCapability?(params: { + capability: string; + outboundContainerId: string; + requestMethod: string; + requestUrl: string; + }): Promise; issueKiloSessionCapability(params: { userId: string; cloudAgentSessionId: string; @@ -538,6 +572,8 @@ export type Env = { GITHUB_TOKEN_CONTAINMENT_ORG_IDS?: string; /** Comma-separated org IDs whose GitLab token uses credential containment, or `*` for all orgs */ GITLAB_TOKEN_CONTAINMENT_ORG_IDS?: string; + /** Comma-separated org IDs whose Bitbucket token uses credential containment, or `*` for all orgs */ + BITBUCKET_TOKEN_CONTAINMENT_ORG_IDS?: string; /** Comma-separated org IDs whose Kilo token uses credential containment, or `*` for all orgs */ KILOCODE_TOKEN_CONTAINMENT_ORG_IDS?: string; /** Comma-separated org IDs that receive workspace repo snapshots, or '*' for all */ diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc index c9f2bc0f62..800fd2127d 100644 --- a/services/cloud-agent-next/wrangler.jsonc +++ b/services/cloud-agent-next/wrangler.jsonc @@ -417,6 +417,7 @@ "PER_SESSION_SANDBOX_ORG_IDS": "*", "GITHUB_TOKEN_CONTAINMENT_ORG_IDS": "", "GITLAB_TOKEN_CONTAINMENT_ORG_IDS": "", + "BITBUCKET_TOKEN_CONTAINMENT_ORG_IDS": "", "KILOCODE_TOKEN_CONTAINMENT_ORG_IDS": "", "REPO_SNAPSHOT_ORG_IDS": "", "TOOL_CGROUP_ORG_IDS": "", 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 4637bbf9c8..f39d45b1b8 100644 --- a/services/git-token-service/src/bitbucket-runtime-token-resolver.ts +++ b/services/git-token-service/src/bitbucket-runtime-token-resolver.ts @@ -330,11 +330,16 @@ export async function listBitbucketRepositories( } } -export async function resolveBitbucketToken( +type BitbucketTokenFailure = Extract; + +async function resolveBitbucketAuthorizedRepository( env: CloudflareEnv, params: GetBitbucketTokenParams, dependencyOverrides?: BitbucketRuntimeTokenResolverDependencies -): Promise { +): Promise< + | { success: true; authorization: RuntimeAuthorization; repository: BitbucketRepository } + | BitbucketTokenFailure +> { if (!params.orgId) return { success: false, reason: 'invalid_request' }; const workspaceUuid = normalizeBitbucketUuid(params.workspaceUuid); const repositoryUuid = normalizeBitbucketUuid(params.repositoryUuid); @@ -383,5 +388,49 @@ export async function resolveBitbucketToken( ) { return { success: false, reason: 'repository_mismatch' }; } - return { success: true, token: authorization.token }; + return { success: true, authorization, repository }; +} + +export async function resolveBitbucketToken( + env: CloudflareEnv, + params: GetBitbucketTokenParams, + dependencyOverrides?: BitbucketRuntimeTokenResolverDependencies +): Promise { + const resolved = await resolveBitbucketAuthorizedRepository(env, params, dependencyOverrides); + if (!resolved.success) return resolved; + return { success: true, token: resolved.authorization.token }; +} + +export type BitbucketCapabilitySubject = { + integrationId: string; + workspaceUuid: string; + workspaceSlug: string; + repositoryUuid: string; + repositoryFullName: string; + token: string; +}; + +// Resolve the same authorized repository as resolveBitbucketToken, but return +// the identity needed to mint an outbound session capability (and the token, so +// the caller can bind a rotation digest). Used at capability issue time and, on +// the redeem path, to re-resolve the current token for comparison. +export async function resolveBitbucketCapabilitySubject( + env: CloudflareEnv, + params: GetBitbucketTokenParams, + dependencyOverrides?: BitbucketRuntimeTokenResolverDependencies +): Promise<{ success: true; subject: BitbucketCapabilitySubject } | BitbucketTokenFailure> { + const resolved = await resolveBitbucketAuthorizedRepository(env, params, dependencyOverrides); + if (!resolved.success) return resolved; + const { authorization, repository } = resolved; + return { + success: true, + subject: { + integrationId: authorization.integrationId, + workspaceUuid: authorization.workspace.uuid, + workspaceSlug: authorization.workspace.slug, + repositoryUuid: repository.id, + repositoryFullName: repository.fullName, + token: authorization.token, + }, + }; } diff --git a/services/git-token-service/src/bitbucket-session-capability.test.ts b/services/git-token-service/src/bitbucket-session-capability.test.ts new file mode 100644 index 0000000000..b8a14504c2 --- /dev/null +++ b/services/git-token-service/src/bitbucket-session-capability.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + BitbucketSessionCapabilityCodec, + BitbucketSessionCapabilityError, + isBitbucketSessionCapability, +} from './bitbucket-session-capability.js'; + +const encryptionKey = Buffer.alloc(32, 7).toString('base64'); +const anotherEncryptionKey = Buffer.alloc(32, 8).toString('base64'); +const subject = { + userId: 'user_1', + orgId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145b', + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + workspaceUuid: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145d', + workspaceSlug: 'acme', + repositoryUuid: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145e', + repositoryFullName: 'acme/widgets', + tokenDigest: 'f30b0bf364d41460c0119e521d2af8ae7eeacca9745981678d58b07b13c94edf', + outboundContainerId: 'outbound-container-1', +} as const; + +describe('BitbucketSessionCapabilityCodec', () => { + it('produces an opaque four-hour prefixed capability that round-trips', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-31T12:00:00.000Z')); + const codec = new BitbucketSessionCapabilityCodec(encryptionKey); + + const capability = codec.issue(subject); + + expect(capability).toMatch(/^kbb1\./); + expect(isBitbucketSessionCapability(capability)).toBe(true); + // The raw workspace/token material must not appear in the opaque capability. + expect(capability).not.toContain('user_1'); + expect(capability).not.toContain('acme/widgets'); + expect(capability).not.toContain(subject.tokenDigest); + expect(codec.decode(capability)).toEqual({ + purpose: 'bitbucket_scm_session', + version: 1, + ...subject, + issuedAt: Date.parse('2026-05-31T12:00:00.000Z'), + expiresAt: Date.parse('2026-05-31T16:00:00.000Z'), + }); + vi.useRealTimers(); + }); + + it('rejects a capability encrypted with a different key', () => { + const capability = new BitbucketSessionCapabilityCodec(encryptionKey).issue(subject); + expect(() => + new BitbucketSessionCapabilityCodec(anotherEncryptionKey).decode(capability) + ).toThrow(new BitbucketSessionCapabilityError('invalid_capability')); + }); + + it('rejects a value without the bitbucket prefix', () => { + const codec = new BitbucketSessionCapabilityCodec(encryptionKey); + expect(isBitbucketSessionCapability('kgl2.something')).toBe(false); + expect(() => codec.decode('kgl2.something')).toThrow( + new BitbucketSessionCapabilityError('invalid_capability') + ); + }); + + it('rejects an expired capability', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-31T12:00:00.000Z')); + const codec = new BitbucketSessionCapabilityCodec(encryptionKey); + const capability = codec.issue(subject); + + vi.setSystemTime(new Date('2026-05-31T16:00:00.001Z')); + expect(() => codec.decode(capability)).toThrow( + new BitbucketSessionCapabilityError('expired_capability') + ); + vi.useRealTimers(); + }); +}); diff --git a/services/git-token-service/src/bitbucket-session-capability.ts b/services/git-token-service/src/bitbucket-session-capability.ts new file mode 100644 index 0000000000..b7b202943f --- /dev/null +++ b/services/git-token-service/src/bitbucket-session-capability.ts @@ -0,0 +1,131 @@ +import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encryption'; +import { z } from 'zod'; +import { hasCanonicalEncryptedValueFormat } from './github-session-capability.js'; + +// Bitbucket session capabilities are always container-bound (there is no legacy +// unbound form to carry forward), so a single versioned, prefixed format is +// enough. The opaque capability replaces the raw workspace access token inside +// the sandbox; the outbound interceptor redeems it for the real credential. +const BITBUCKET_CAPABILITY_PREFIX = 'kbb1.'; +const CAPABILITY_PURPOSE = 'bitbucket_scm_session'; +const MAX_BITBUCKET_SCM_SESSION_CAPABILITY_LIFETIME_MS = 4 * 60 * 60 * 1000; + +const WorkspaceSlugSchema = z + .string() + .min(1) + .max(255) + .regex(/^[a-z0-9][a-z0-9_.-]*$/); +const RepositoryFullNameSchema = z + .string() + .min(3) + .max(511) + .refine(name => /^[^/\s]+\/[^/\s]+$/.test(name)); +const TokenDigestSchema = z.string().regex(/^[a-f0-9]{64}$/); + +const BitbucketSessionCapabilityClaimsSchema = z + .object({ + purpose: z.literal(CAPABILITY_PURPOSE), + version: z.literal(1), + userId: z.string().min(1), + orgId: z.uuid(), + integrationId: z.uuid(), + workspaceUuid: z.uuid(), + workspaceSlug: WorkspaceSlugSchema, + repositoryUuid: z.uuid(), + repositoryFullName: RepositoryFullNameSchema, + // A digest of the resolved token at issue time. The redeem path re-resolves + // the current token and compares digests, so a rotated token invalidates the + // capability regardless of which Bitbucket auth source backs it. + tokenDigest: TokenDigestSchema, + outboundContainerId: z.string().min(1), + issuedAt: z.number().int().nonnegative(), + expiresAt: z.number().int().positive(), + }) + .strict() + .refine(claims => claims.expiresAt > claims.issuedAt) + .refine( + claims => claims.expiresAt - claims.issuedAt <= MAX_BITBUCKET_SCM_SESSION_CAPABILITY_LIFETIME_MS + ); + +export type BitbucketSessionCapabilityClaims = z.infer< + typeof BitbucketSessionCapabilityClaimsSchema +>; +export type BitbucketSessionCapabilitySubject = Omit< + BitbucketSessionCapabilityClaims, + 'purpose' | 'version' | 'issuedAt' | 'expiresAt' +>; + +export type BitbucketSessionCapabilityFailureReason = + | 'invalid_capability' + | 'expired_capability' + | 'capability_configuration_error'; + +export class BitbucketSessionCapabilityError extends Error { + constructor(readonly reason: BitbucketSessionCapabilityFailureReason) { + super(reason); + this.name = 'BitbucketSessionCapabilityError'; + } +} + +export function isBitbucketSessionCapability(value: string): boolean { + return value.startsWith(BITBUCKET_CAPABILITY_PREFIX); +} + +export async function bitbucketTokenDigest(token: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} + +export class BitbucketSessionCapabilityCodec { + constructor(private readonly encryptionKey: string) {} + + issue(subject: BitbucketSessionCapabilitySubject): string { + const issuedAt = Date.now(); + const parsed = BitbucketSessionCapabilityClaimsSchema.safeParse({ + purpose: CAPABILITY_PURPOSE, + version: 1, + ...subject, + issuedAt, + expiresAt: issuedAt + MAX_BITBUCKET_SCM_SESSION_CAPABILITY_LIFETIME_MS, + }); + if (!parsed.success) throw new BitbucketSessionCapabilityError('invalid_capability'); + try { + return `${BITBUCKET_CAPABILITY_PREFIX}${encryptWithSymmetricKey( + JSON.stringify(parsed.data), + this.encryptionKey + )}`; + } catch { + throw new BitbucketSessionCapabilityError('capability_configuration_error'); + } + } + + decode(capability: string): BitbucketSessionCapabilityClaims { + if (!capability.startsWith(BITBUCKET_CAPABILITY_PREFIX)) { + throw new BitbucketSessionCapabilityError('invalid_capability'); + } + const encrypted = capability.slice(BITBUCKET_CAPABILITY_PREFIX.length); + if (!hasCanonicalEncryptedValueFormat(encrypted)) { + throw new BitbucketSessionCapabilityError('invalid_capability'); + } + let serialized: string; + try { + serialized = decryptWithSymmetricKey(encrypted, this.encryptionKey); + } catch { + throw new BitbucketSessionCapabilityError('invalid_capability'); + } + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + throw new BitbucketSessionCapabilityError('invalid_capability'); + } + const parsed = BitbucketSessionCapabilityClaimsSchema.safeParse(value); + if (!parsed.success) { + throw new BitbucketSessionCapabilityError('invalid_capability'); + } + if (parsed.data.expiresAt <= Date.now()) { + throw new BitbucketSessionCapabilityError('expired_capability'); + } + return parsed.data; + } +} diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 1ca66082b5..8a063100ea 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -19,6 +19,7 @@ const serviceMocks = vi.hoisted(() => ({ hasGitLabProjectCredentialCandidates: vi.fn(), listBitbucketRepositories: vi.fn(), resolveBitbucketToken: vi.fn(), + resolveBitbucketCapabilitySubject: vi.fn(), })); vi.mock('cloudflare:workers', () => ({ @@ -93,6 +94,7 @@ vi.mock('./gitlab-credential-broker-handler.js', async importOriginal => { vi.mock('./bitbucket-runtime-token-resolver.js', () => ({ listBitbucketRepositories: serviceMocks.listBitbucketRepositories, resolveBitbucketToken: serviceMocks.resolveBitbucketToken, + resolveBitbucketCapabilitySubject: serviceMocks.resolveBitbucketCapabilitySubject, })); import gitTokenServiceWorker, { GitTokenRPCEntrypoint } from './index.js'; @@ -335,6 +337,134 @@ function createService(): GitTokenRPCEntrypoint { ); } +describe('GitTokenRPCEntrypoint Bitbucket session capability', () => { + const subject = { + integrationId: '123e4567-e89b-12d3-a456-426614174022', + workspaceUuid: '123e4567-e89b-12d3-a456-426614174020', + workspaceSlug: 'acme', + repositoryUuid: '123e4567-e89b-12d3-a456-426614174021', + repositoryFullName: 'acme/widgets', + token: 'ATCT-runtime-token', + }; + const issueParams = { + userId: 'user-1', + orgId: '123e4567-e89b-12d3-a456-426614174030', + outboundContainerId: 'outbound-container-1', + workspaceUuid: subject.workspaceUuid, + repositoryUuid: subject.repositoryUuid, + repositoryUrl: 'https://bitbucket.org/acme/widgets.git', + }; + + beforeEach(() => { + serviceMocks.resolveBitbucketCapabilitySubject + .mockReset() + .mockResolvedValue({ success: true, subject }); + }); + + async function issueCapability(): Promise { + const result = await createService().issueBitbucketSessionCapability(issueParams); + if (!result.success) throw new Error(`issue failed: ${result.reason}`); + 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) { + expect(result.capability).not.toContain(subject.token); + } + }); + + it('propagates a resolution failure from issue', async () => { + serviceMocks.resolveBitbucketCapabilitySubject + .mockReset() + .mockResolvedValue({ success: false, reason: 'reconnect_required' }); + await expect(createService().issueBitbucketSessionCapability(issueParams)).resolves.toEqual({ + success: false, + reason: 'reconnect_required', + }); + }); + + it('redeems a valid capability into an injected Basic auth header', async () => { + const capability = await issueCapability(); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'POST', + requestUrl: 'https://bitbucket.org/acme/widgets.git/git-upload-pack', + }) + ).resolves.toEqual({ + success: true, + headers: { + authorization: `Basic ${Buffer.from('x-token-auth:ATCT-runtime-token').toString('base64')}`, + }, + }); + }); + + it('rejects redemption from a different container', async () => { + const capability = await issueCapability(); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'other-container', + requestMethod: 'POST', + requestUrl: 'https://bitbucket.org/acme/widgets.git/git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'container_mismatch' }); + }); + + it('rejects redemption for a different repository', async () => { + const capability = await issueCapability(); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'POST', + requestUrl: 'https://bitbucket.org/acme/other.git/git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + }); + + it('rejects redemption when the token was rotated after issue', async () => { + const capability = await issueCapability(); + serviceMocks.resolveBitbucketCapabilitySubject + .mockReset() + .mockResolvedValue({ success: true, subject: { ...subject, token: 'ATCT-rotated' } }); + 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'], + // Nested/double-encoded traversal survives the raw check and must be caught + // by the iterative-decode recheck (mirrors the GitLab %252e%252e%252f case). + ['https://bitbucket.org/acme/widgets.git/%252e%252e%252facme/other.git/git-upload-pack'], + ['https://bitbucket.org/acme/widgets.git/objects/..%252f..%252fother/git-upload-pack'], + ] as const)('rejects percent-encoded path traversal %s', async requestUrl => { + const capability = await issueCapability(); + await expect( + createService().redeemBitbucketSessionCapability({ + capability, + outboundContainerId: 'outbound-container-1', + requestMethod: 'POST', + requestUrl, + }) + ).resolves.toEqual({ success: false, reason: 'invalid_upstream_url' }); + }); +}); + describe('GitTokenRPCEntrypoint Bitbucket runtime authorization', () => { it('requires an organization before invoking the reachable V1 resolver', async () => { serviceMocks.resolveBitbucketToken.mockReset(); diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index f4f4247c35..9b3a46c4cf 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -58,11 +58,18 @@ import { } from './github-user-authorization-service.js'; import { listBitbucketRepositories, + resolveBitbucketCapabilitySubject, resolveBitbucketToken, type BitbucketRepositoryListResult, type GetBitbucketTokenParams, type GetBitbucketTokenResult, } from './bitbucket-runtime-token-resolver.js'; +import { + BitbucketSessionCapabilityCodec, + BitbucketSessionCapabilityError, + bitbucketTokenDigest, + type BitbucketSessionCapabilityFailureReason, +} from './bitbucket-session-capability.js'; import { BitbucketCodeReviewService, BitbucketDeleteWebhookRequestSchema, @@ -227,6 +234,37 @@ export type RedeemGitLabSessionCapabilityResult = } | { success: false; reason: RedeemGitLabSessionCapabilityFailureReason }; +export type IssueBitbucketSessionCapabilityParams = { + userId: string; + orgId: string; + outboundContainerId: string; + expectedIntegrationId?: string; + workspaceUuid: string; + repositoryUuid: string; + repositoryUrl: string; +}; +type BitbucketTokenFailureReason = Extract['reason']; +export type IssueBitbucketSessionCapabilityResult = + | { success: true; capability: string; gitUrl: string } + | { success: false; reason: BitbucketTokenFailureReason | 'capability_configuration_error' }; + +export type RedeemBitbucketSessionCapabilityParams = { + capability: string; + outboundContainerId: string; + requestMethod: string; + requestUrl: string; +}; +export type RedeemBitbucketSessionCapabilityFailureReason = + | BitbucketSessionCapabilityFailureReason + | 'container_mismatch' + | 'invalid_upstream_url' + | 'upstream_origin_not_allowed' + | 'repository_mismatch' + | 'source_unavailable'; +export type RedeemBitbucketSessionCapabilityResult = + | { success: true; headers: { authorization: string } } + | { success: false; reason: RedeemBitbucketSessionCapabilityFailureReason }; + export type IssueKiloSessionCapabilityParams = KiloSessionCapabilitySubject; export type IssueKiloSessionCapabilityResult = | { success: true; capability: string } @@ -444,7 +482,7 @@ function isGitLabGitAuthPath(pathname: string): boolean { ); } -function decodeGitLabPathname(pathname: string): string | null { +function decodePathnameIteratively(pathname: string): string | null { let decoded = pathname; for (let depth = 0; depth < 4; depth++) { let next: string; @@ -480,7 +518,7 @@ function validateGitLabCapabilityUpstream( if (url.origin !== base.origin) { return { failure: 'upstream_origin_not_allowed', authSurface: 'git' }; } - const decodedPathname = decodeGitLabPathname(url.pathname); + const decodedPathname = decodePathnameIteratively(url.pathname); if ( decodedPathname === null || decodedPathname.includes('\\') || @@ -506,6 +544,51 @@ function validateGitLabCapabilityUpstream( return { failure: null, authSurface }; } +function validateBitbucketCapabilityUpstream( + requestUrl: string, + repositoryFullName: string +): { failure: RedeemBitbucketSessionCapabilityFailureReason | null } { + // Bitbucket smart-HTTP repo paths are //.git with literal + // slashes and never carry an encoded slash, so reject %2f outright. This guard + // deliberately differs from validateGitLabCapabilityUpstream, which must allow + // %2f because GitLab addresses projects by encoded path (e.g. + // /api/v4/projects/group%2Fproject); do not "reconcile" the two. + if (/%2f|%5c/i.test(requestUrl) || /\/(?:(?:\.|%2e){1,2})(?:\/|$)/i.test(requestUrl)) { + return { failure: 'invalid_upstream_url' }; + } + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return { failure: 'invalid_upstream_url' }; + } + if (url.protocol !== 'https:' || url.username || url.password || url.hash) { + return { failure: 'invalid_upstream_url' }; + } + if (url.origin !== 'https://bitbucket.org') { + return { failure: 'upstream_origin_not_allowed' }; + } + // Defense in depth against nested percent-encoding: the raw check above only + // catches single-encoded traversal (%2e/%2f). Decode the pathname iteratively + // and re-check, so sequences like %252e%252e%252f — which survive the raw pass + // and only resolve into ../ once Bitbucket decodes them — are rejected here. + const decodedPathname = decodePathnameIteratively(url.pathname); + if ( + decodedPathname === null || + decodedPathname.includes('\\') || + /(?:^|\/)\.{1,2}(?:\/|$)/.test(decodedPathname) + ) { + return { failure: 'invalid_upstream_url' }; + } + // Bitbucket smart-HTTP paths live under //.git/... The full + // name was validated (single slash, no traversal) when the capability decoded. + const repoPath = `/${repositoryFullName}.git`; + if (url.pathname !== repoPath && !url.pathname.startsWith(`${repoPath}/`)) { + return { failure: 'repository_mismatch' }; + } + return { failure: null }; +} + function validateLegacyGitLabCapabilityUpstream( requestMethod: string, requestUrl: string, @@ -974,6 +1057,100 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return result.success ? { success: true, token: result.token } : result; } + async issueBitbucketSessionCapability( + params: IssueBitbucketSessionCapabilityParams + ): Promise { + const resolved = await resolveBitbucketCapabilitySubject(this.env, { + userId: params.userId, + orgId: params.orgId, + ...(params.expectedIntegrationId !== undefined + ? { expectedIntegrationId: params.expectedIntegrationId } + : {}), + workspaceUuid: params.workspaceUuid, + repositoryUuid: params.repositoryUuid, + repositoryUrl: params.repositoryUrl, + }); + if (!resolved.success) return resolved; + const { subject } = resolved; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + const capability = new BitbucketSessionCapabilityCodec(encryptionKey).issue({ + userId: params.userId, + orgId: params.orgId, + integrationId: subject.integrationId, + workspaceUuid: subject.workspaceUuid, + workspaceSlug: subject.workspaceSlug, + repositoryUuid: subject.repositoryUuid, + repositoryFullName: subject.repositoryFullName, + tokenDigest: await bitbucketTokenDigest(subject.token), + outboundContainerId: params.outboundContainerId, + }); + return { + success: true, + capability, + gitUrl: `https://bitbucket.org/${subject.repositoryFullName}.git`, + }; + } catch { + return { success: false, reason: 'capability_configuration_error' }; + } + } + + async redeemBitbucketSessionCapability( + params: RedeemBitbucketSessionCapabilityParams + ): Promise { + let claims; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + claims = new BitbucketSessionCapabilityCodec(encryptionKey).decode(params.capability); + } catch (error) { + if (error instanceof BitbucketSessionCapabilityError) { + return { success: false, reason: error.reason }; + } + return { success: false, reason: 'capability_configuration_error' }; + } + + if (claims.outboundContainerId !== params.outboundContainerId) { + return { success: false, reason: 'container_mismatch' }; + } + const upstream = validateBitbucketCapabilityUpstream( + params.requestUrl, + claims.repositoryFullName + ); + if (upstream.failure) return { success: false, reason: upstream.failure }; + + // Re-resolve the current token and confirm the workspace/repo identity and + // token digest still match what the capability was issued for. A rotated + // token or a changed integration invalidates the capability. + const resolved = await resolveBitbucketCapabilitySubject(this.env, { + userId: claims.userId, + orgId: claims.orgId, + expectedIntegrationId: claims.integrationId, + workspaceUuid: claims.workspaceUuid, + repositoryUuid: claims.repositoryUuid, + repositoryUrl: `https://bitbucket.org/${claims.repositoryFullName}.git`, + }); + if (!resolved.success) return { success: false, reason: 'source_unavailable' }; + const { subject } = resolved; + if ( + subject.integrationId !== claims.integrationId || + subject.workspaceUuid !== claims.workspaceUuid || + subject.repositoryUuid !== claims.repositoryUuid || + subject.repositoryFullName !== claims.repositoryFullName + ) { + return { success: false, reason: 'source_unavailable' }; + } + const currentDigest = await bitbucketTokenDigest(subject.token); + if (!timingSafeEqual(currentDigest, claims.tokenDigest)) { + return { success: false, reason: 'source_unavailable' }; + } + return { + success: true, + headers: { + authorization: `Basic ${Buffer.from(`x-token-auth:${subject.token}`).toString('base64')}`, + }, + }; + } + async issueGitLabSessionCapability( params: IssueGitLabSessionCapabilityParams ): Promise {