Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ const CredentialContainmentSchema = z
.object({
github: z.boolean(),
gitlab: z.boolean(),
bitbucket: z.boolean().optional(),
kilocode: z.boolean(),
})
.strip();
Expand Down Expand Up @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions services/cloud-agent-next/src/sandbox-outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>
): 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);
});
});
123 changes: 118 additions & 5 deletions services/cloud-agent-next/src/sandbox-outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitTokenService, 'redeemGitHubSessionCapability'>;
type GitLabTokenRedemptionBinding = Pick<GitTokenService, 'redeemGitLabSessionCapability'>;
type KiloTokenRedemptionBinding = Pick<GitTokenService, 'redeemKiloSessionCapability'>;
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 }
Expand All @@ -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'
Expand Down Expand Up @@ -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';
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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 } };
Expand All @@ -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;
}
Expand All @@ -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' };
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -667,6 +701,82 @@ async function handleManagedGitLabOutbound(
return response;
}

async function handleManagedBitbucketOutbound(
request: Request,
env: Cloudflare.Env,
capability: { capability: string },
outboundContainerId: string
): Promise<Response> {
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<BitbucketTokenRedemptionBinding['redeemBitbucketSessionCapability']>
>;
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,
Expand Down Expand Up @@ -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);
}

Expand Down
38 changes: 38 additions & 0 deletions services/cloud-agent-next/src/services/git-token-service-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading