diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index 19102340a8..50039e560e 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -47,6 +47,7 @@ vi.mock('./workspace.js', () => ({ const tokenMocks = vi.hoisted(() => ({ issueCloudAgentGitHubSessionCapability: vi.fn(), issueCloudAgentGitLabSessionCapability: vi.fn(), + issueCloudAgentBitbucketSessionCapability: vi.fn(), resolveCloudAgentGitHubAuthForRepo: vi.fn(), resolveManagedBitbucketToken: vi.fn(), resolveManagedGitLabToken: vi.fn(), @@ -452,7 +453,8 @@ function createGitLabCodeReviewMetadata(): CloudAgentSessionState { function createBitbucketMetadata( isCodeReview: boolean, - orgId: string | null = '123e4567-e89b-12d3-a456-426614174030' + orgId: string | null = '123e4567-e89b-12d3-a456-426614174030', + credentialContainment?: CredentialContainment ): CloudAgentSessionState { return parseSessionMetadata({ metadataSchemaVersion: 2, @@ -462,6 +464,7 @@ function createBitbucketMetadata( ...(orgId ? { orgId } : {}), createdOnPlatform: isCodeReview ? 'code-review' : 'cloud-agent-web', }, + ...(credentialContainment ? { workspace: { credentialContainment } } : {}), auth: { kilocodeToken: 'kilo-token', kiloSessionId: 'kilo-session', @@ -757,6 +760,41 @@ describe('SessionService.prepareWorkspace', () => { ); }); + it('preserves the capability origin (no strip) for a contained cold Bitbucket review', async () => { + const session = createSession(false); + const sandbox = createSandbox(session); + const metadata = createBitbucketMetadata(true, '123e4567-e89b-12d3-a456-426614174030', { + github: false, + gitlab: false, + bitbucket: true, + kilocode: false, + }); + tokenMocks.issueCloudAgentBitbucketSessionCapability.mockResolvedValue({ + success: true, + value: { + capability: 'kbb1.opaque-capability', + gitUrl: 'https://bitbucket.org/acme-team/widgets.git', + }, + }); + + await new SessionService().prepareWorkspace({ + sandbox, + sandboxId: 'usr-abcdef', + orgId: '123e4567-e89b-12d3-a456-426614174030', + userId: 'user_test', + sessionId: 'agent_test' as SessionId, + env: createEnv(), + metadata, + kilocodeModel: 'test-model', + }); + + expect(tokenMocks.issueCloudAgentBitbucketSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveManagedBitbucketToken).not.toHaveBeenCalled(); + // A kbb1. capability origin stays authenticated through the outbound + // interceptor, so it must NOT be stripped (unlike a raw-token session). + expect(workspaceMocks.updateGitRemoteUrl).not.toHaveBeenCalled(); + }); + it('writes the opaque Kilo capability to the sandbox auth file, never the raw token', async () => { const session = createSession(false); const writeFile = vi.fn().mockResolvedValue(undefined); @@ -1144,6 +1182,41 @@ describe('SessionService.prepareWorkspace', () => { ); }); + it('preserves the capability origin (no strip, no refresh) for a contained warm Bitbucket review', async () => { + const session = createSession(true); + const sandbox = createSandbox(session, true); + const metadata = createBitbucketMetadata(true, '123e4567-e89b-12d3-a456-426614174030', { + github: false, + gitlab: false, + bitbucket: true, + kilocode: false, + }); + tokenMocks.issueCloudAgentBitbucketSessionCapability.mockResolvedValue({ + success: true, + value: { + capability: 'kbb1.opaque-capability', + gitUrl: 'https://bitbucket.org/acme-team/widgets.git', + }, + }); + + await new SessionService().prepareWorkspace({ + sandbox, + sandboxId: 'usr-abcdef', + orgId: '123e4567-e89b-12d3-a456-426614174030', + userId: 'user_test', + sessionId: 'agent_test' as SessionId, + env: createEnv(), + metadata, + kilocodeModel: 'test-model', + }); + + expect(workspaceMocks.cloneGitRepo).not.toHaveBeenCalled(); + // A capability origin is preserved: no strip, and the warm-resume token + // refresh is skipped because sanitize reports the remote as handled. + expect(workspaceMocks.updateGitRemoteUrl).not.toHaveBeenCalled(); + expect(workspaceMocks.updateGitRemoteToken).not.toHaveBeenCalled(); + }); + it('refreshes prepared GitHub workspace metadata with a managed capability', async () => { const session = createSession(true); const sandbox = createSandbox(session, true); diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index 147b6d63f5..c56d7cf463 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -2674,6 +2674,13 @@ export class SessionService { if (metadata.identity.createdOnPlatform !== 'code-review' || git?.type !== 'bitbucket') { return false; } + // A contained session's origin carries a kbb1. capability that stays + // authenticated through the outbound interceptor, so it must stay in place for + // a blobless clone's later lazy blob fetches (mirrors the wrapper's + // sanitizeBitbucketCodeReviewRemote). Only a raw workspace token is stripped. + if (getEffectiveCredentialContainment(metadata).bitbucket) { + return true; + } await updateGitRemoteUrl(session, workspacePath, git.url); return true; }