diff --git a/services/cloud-agent-next/Dockerfile b/services/cloud-agent-next/Dockerfile index 3f38ad59d4..90b2a7b5df 100644 --- a/services/cloud-agent-next/Dockerfile +++ b/services/cloud-agent-next/Dockerfile @@ -45,6 +45,10 @@ RUN GLAB_VERSION="1.93.0" \ && dpkg -i /tmp/glab.deb \ && rm /tmp/glab.deb +COPY scripts/kilo-git-credential /opt/kilo-cloud/kilo-git-credential +RUN chmod +x /opt/kilo-cloud/kilo-git-credential \ + && ln -sf /opt/kilo-cloud/kilo-git-credential /usr/local/bin/kilo-git-credential + # Generate locales to suppress setlocale warnings RUN apt-get update && apt-get install -y --no-install-recommends locales && \ sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ diff --git a/services/cloud-agent-next/Dockerfile.dev b/services/cloud-agent-next/Dockerfile.dev index 013a75bfab..103d32850c 100644 --- a/services/cloud-agent-next/Dockerfile.dev +++ b/services/cloud-agent-next/Dockerfile.dev @@ -55,6 +55,10 @@ RUN GLAB_VERSION="1.93.0" \ && dpkg -i /tmp/glab.deb \ && rm /tmp/glab.deb +COPY scripts/kilo-git-credential /opt/kilo-cloud/kilo-git-credential +RUN chmod +x /opt/kilo-cloud/kilo-git-credential \ + && ln -sf /opt/kilo-cloud/kilo-git-credential /usr/local/bin/kilo-git-credential + # Install pnpm and kilocode RUN npm install -g pnpm @kilocode/cli@${KILOCODE_CLI_VERSION} diff --git a/services/cloud-agent-next/Dockerfile.dind b/services/cloud-agent-next/Dockerfile.dind index e7e46d80f8..0c82e64d32 100644 --- a/services/cloud-agent-next/Dockerfile.dind +++ b/services/cloud-agent-next/Dockerfile.dind @@ -52,6 +52,10 @@ RUN GLAB_VERSION="1.93.0" \ && chmod +x /usr/local/bin/glab \ && rm -rf /tmp/glab.tar.gz /tmp/bin +COPY scripts/kilo-git-credential /opt/kilo-cloud/kilo-git-credential +RUN chmod +x /opt/kilo-cloud/kilo-git-credential \ + && ln -sf /opt/kilo-cloud/kilo-git-credential /usr/local/bin/kilo-git-credential + # Tools used by the outer sandbox. Kilo itself is still installed globally for # the existing wrapper path; the platform package bundle under /opt/kilo-agent # is intended for mounting or copying into inner dev containers. diff --git a/services/cloud-agent-next/scripts/kilo-git-credential b/services/cloud-agent-next/scripts/kilo-git-credential new file mode 100755 index 0000000000..9d80e57d99 --- /dev/null +++ b/services/cloud-agent-next/scripts/kilo-git-credential @@ -0,0 +1,49 @@ +#!/bin/sh +set -eu + +case "${1:-}" in +get) ;; +*) exit 0 ;; +esac + +protocol= +host= +while IFS= read -r line || [ -n "$line" ]; do + [ -z "$line" ] && break + case "$line" in + protocol=*) protocol="${line#protocol=}" ;; + host=*) host="${line#host=}" ;; + esac +done + +[ "$protocol" = https ] || exit 0 + +host="${host%%:*}" +username= +password= + +case "$host" in +github.com) + username=x-access-token + password="${GH_TOKEN:-}" + ;; +bitbucket.org) + username=x-token-auth + password="${BITBUCKET_TOKEN:-}" + ;; +*) + gitlab_host="${GITLAB_HOST:-gitlab.com}" + gitlab_host="${gitlab_host#https://}" + gitlab_host="${gitlab_host#http://}" + gitlab_host="${gitlab_host%%/*}" + gitlab_host="${gitlab_host%%:*}" + if [ "$host" = "$gitlab_host" ]; then + username=oauth2 + password="${GITLAB_TOKEN:-}" + fi + ;; +esac + +[ -n "$password" ] || exit 0 + +printf 'username=%s\npassword=%s\n' "$username" "$password" diff --git a/services/cloud-agent-next/src/kilo-git-credential.test.ts b/services/cloud-agent-next/src/kilo-git-credential.test.ts new file mode 100644 index 0000000000..53010d5be6 --- /dev/null +++ b/services/cloud-agent-next/src/kilo-git-credential.test.ts @@ -0,0 +1,191 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const scriptPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../scripts/kilo-git-credential' +); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +type HelperEnv = { + GH_TOKEN?: string; + GITLAB_TOKEN?: string; + GITLAB_HOST?: string; + BITBUCKET_TOKEN?: string; +}; + +function credentialInput(protocol: string, host: string): string { + return `protocol=${protocol}\nhost=${host}\n\n`; +} + +function runHelper( + action: string | undefined, + input: string, + env: HelperEnv = {} +): { status: number | null; stdout: string; home: string } { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-git-credential-')); + tempDirs.push(home); + const result = spawnSync('sh', action === undefined ? [scriptPath] : [scriptPath, action], { + encoding: 'utf8', + input, + env: { + ...process.env, + HOME: home, + GH_TOKEN: undefined, + GITLAB_TOKEN: undefined, + GITLAB_HOST: undefined, + BITBUCKET_TOKEN: undefined, + ...env, + }, + }); + return { status: result.status, stdout: result.stdout, home }; +} + +function parseCredential(stdout: string): { + username: string | undefined; + password: string | undefined; +} { + let username: string | undefined; + let password: string | undefined; + for (const line of stdout.split('\n')) { + if (line.startsWith('username=')) { + username = line.slice('username='.length); + } else if (line.startsWith('password=')) { + password = line.slice('password='.length); + } + } + return { username, password }; +} + +function expectPassword(actual: string | undefined, expected: string): void { + if (actual !== expected) { + throw new Error('password did not match the provided token'); + } +} + +describe('kilo-git-credential', () => { + it('returns GitHub credentials including a capability token', () => { + const token = 'kgh2.cap'; + const result = runHelper('get', credentialInput('https', 'github.com'), { GH_TOKEN: token }); + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('x-access-token'); + expectPassword(parsed.password, token); + }); + + it('returns GitLab credentials for gitlab.com', () => { + const token = 'kgl2.cap'; + const result = runHelper('get', credentialInput('https', 'gitlab.com'), { + GITLAB_TOKEN: token, + }); + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('oauth2'); + expectPassword(parsed.password, token); + }); + + it('returns GitLab credentials for a custom GITLAB_HOST and ignores gitlab.com', () => { + const token = 'kgl2.custom'; + const env = { GITLAB_TOKEN: token, GITLAB_HOST: 'gitlab.example.com' }; + const custom = runHelper('get', credentialInput('https', 'gitlab.example.com'), env); + expect(custom.status).toBe(0); + const parsed = parseCredential(custom.stdout); + expect(parsed.username).toBe('oauth2'); + expectPassword(parsed.password, token); + + const defaultHost = runHelper('get', credentialInput('https', 'gitlab.com'), env); + expect(defaultHost.status).toBe(0); + expect(defaultHost.stdout).toBe(''); + }); + + it('matches GITLAB_HOST and requested host when either includes a port', () => { + const token = 'kgl2.port'; + const env = { GITLAB_TOKEN: token, GITLAB_HOST: 'gitlab.example.com:8443' }; + const requestedWithPort = runHelper( + 'get', + credentialInput('https', 'gitlab.example.com:8443'), + env + ); + expect(requestedWithPort.status).toBe(0); + const parsedRequested = parseCredential(requestedWithPort.stdout); + expect(parsedRequested.username).toBe('oauth2'); + expectPassword(parsedRequested.password, token); + + const requestedWithoutPort = runHelper( + 'get', + credentialInput('https', 'gitlab.example.com'), + env + ); + expect(requestedWithoutPort.status).toBe(0); + const parsedBare = parseCredential(requestedWithoutPort.stdout); + expect(parsedBare.username).toBe('oauth2'); + expectPassword(parsedBare.password, token); + }); + + it('accepts GITLAB_HOST with a scheme and path', () => { + const token = 'kgl2.scheme'; + const result = runHelper('get', credentialInput('https', 'gitlab.example.com'), { + GITLAB_TOKEN: token, + GITLAB_HOST: 'https://gitlab.example.com/gitlab', + }); + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('oauth2'); + expectPassword(parsed.password, token); + }); + + it('returns Bitbucket credentials', () => { + const token = 'kbb1.cap'; + const result = runHelper('get', credentialInput('https', 'bitbucket.org'), { + BITBUCKET_TOKEN: token, + }); + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('x-token-auth'); + expectPassword(parsed.password, token); + }); + + it('prints nothing for an unmatched host', () => { + const result = runHelper('get', credentialInput('https', 'example.com'), { + GH_TOKEN: 'kgh2.unused', + GITLAB_TOKEN: 'kgl2.unused', + BITBUCKET_TOKEN: 'kbb1.unused', + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + it('prints nothing when the matching https token is missing', () => { + const result = runHelper('get', credentialInput('https', 'github.com')); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + it('prints nothing for http', () => { + const result = runHelper('get', credentialInput('http', 'github.com'), { + GH_TOKEN: 'kgh2.unused', + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + it.each(['store', 'erase', 'unknown'] as const)('%s exits 0 without writing files', action => { + const result = runHelper(action, credentialInput('https', 'github.com'), { + GH_TOKEN: 'kgh2.unused', + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(fs.existsSync(path.join(result.home, '.git-credentials'))).toBe(false); + expect(fs.readdirSync(result.home)).toEqual([]); + }); +}); diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index 50039e560e..5e5492a0ab 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -30,7 +30,6 @@ const workspaceMocks = vi.hoisted(() => ({ sessionHome: '/home/agent_test', }), updateGitAuthor: vi.fn().mockResolvedValue(undefined), - updateGitRemoteToken: vi.fn().mockResolvedValue(undefined), updateGitRemoteUrl: vi.fn().mockResolvedValue(undefined), })); @@ -124,6 +123,53 @@ describe('SessionService.buildRuntimeEnv', () => { expect(runtimeEnv.HOME).toBe('/home/agent_test'); expect(runtimeEnv.SESSION_HOME).toBe('/home/agent_test'); expect(runtimeEnv[PNPM_STORE_ENV_VAR]).toBe(PNPM_STORE_DIR); + expect(runtimeEnv.GIT_CONFIG_COUNT).toBe('2'); + expect(runtimeEnv.GIT_CONFIG_KEY_0).toBe('credential.helper'); + expect(runtimeEnv.GIT_CONFIG_VALUE_0).toBe('/opt/kilo-cloud/kilo-git-credential'); + expect(runtimeEnv.GIT_CONFIG_KEY_1).toBe('credential.useHttpPath'); + expect(runtimeEnv.GIT_CONFIG_VALUE_1).toBe('false'); + expect(runtimeEnv.GIT_TERMINAL_PROMPT).toBe('0'); + expect(runtimeEnv.GIT_CONFIG_NOSYSTEM).toBeUndefined(); + expect(runtimeEnv.GIT_CONFIG_GLOBAL).toBeUndefined(); + expect(runtimeEnv.GIT_OPTIONAL_LOCKS).toBeUndefined(); + }); + + it('wins over a profile that tries to override the git credential helper', () => { + const service = new SessionService(); + const context = service.buildContext({ + sandboxId: 'usr-test', + userId: 'user_test', + sessionId: 'agent_test', + envVars: { + GIT_CONFIG_COUNT: '99', + GIT_CONFIG_KEY_0: 'user.email', + GIT_CONFIG_VALUE_0: 'attacker@example.com', + GIT_CONFIG_KEY_1: 'credential.helper', + GIT_CONFIG_VALUE_1: '/tmp/evil-helper', + GIT_CONFIG_KEY_2: 'credential.helper', + GIT_CONFIG_VALUE_2: '/tmp/second-evil-helper', + GIT_CONFIG_GLOBAL: '/tmp/evil.gitconfig', + GIT_CONFIG_NOSYSTEM: '1', + GIT_TERMINAL_PROMPT: '1', + }, + }); + + const runtimeEnv = service.buildRuntimeEnv({ + context, + env: createEnv(), + kiloCapability: 'kilo-token', + }); + + expect(runtimeEnv.GIT_CONFIG_COUNT).toBe('2'); + expect(runtimeEnv.GIT_CONFIG_KEY_0).toBe('credential.helper'); + expect(runtimeEnv.GIT_CONFIG_VALUE_0).toBe('/opt/kilo-cloud/kilo-git-credential'); + expect(runtimeEnv.GIT_CONFIG_KEY_1).toBe('credential.useHttpPath'); + expect(runtimeEnv.GIT_CONFIG_VALUE_1).toBe('false'); + expect(runtimeEnv.GIT_TERMINAL_PROMPT).toBe('0'); + expect(runtimeEnv.GIT_CONFIG_KEY_2).toBeUndefined(); + expect(runtimeEnv.GIT_CONFIG_VALUE_2).toBeUndefined(); + expect(runtimeEnv.GIT_CONFIG_GLOBAL).toBeUndefined(); + expect(runtimeEnv.GIT_CONFIG_NOSYSTEM).toBeUndefined(); }); }); @@ -616,7 +662,6 @@ describe('SessionService.prepareWorkspace', () => { sessionHome: '/home/agent_test', }); workspaceMocks.updateGitAuthor.mockResolvedValue(undefined); - workspaceMocks.updateGitRemoteToken.mockResolvedValue(undefined); workspaceMocks.updateGitRemoteUrl.mockResolvedValue(undefined); tokenMocks.resolveCloudAgentGitHubAuthForRepo.mockResolvedValue({ success: true, @@ -696,7 +741,6 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://gitlab.com/acme/repo.git', - 'resolved-gitlab-token', undefined, { platform: 'gitlab' } ); @@ -743,7 +787,6 @@ describe('SessionService.prepareWorkspace', () => { workspacePath, 'https://bitbucket.org/acme-team/widgets.git' ); - expect(workspaceMocks.updateGitRemoteToken).not.toHaveBeenCalled(); const branchCallIndex = session.exec.mock.calls.findIndex( ([command]) => typeof command === 'string' && command.includes('git checkout -b') ); @@ -760,7 +803,7 @@ describe('SessionService.prepareWorkspace', () => { ); }); - it('preserves the capability origin (no strip) for a contained cold Bitbucket review', async () => { + it('strips the capability origin for a contained cold Bitbucket review', async () => { const session = createSession(false); const sandbox = createSandbox(session); const metadata = createBitbucketMetadata(true, '123e4567-e89b-12d3-a456-426614174030', { @@ -790,9 +833,11 @@ describe('SessionService.prepareWorkspace', () => { 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(); + expect(workspaceMocks.updateGitRemoteUrl).toHaveBeenCalledWith( + session, + '/workspace/user/sessions/agent_test', + 'https://bitbucket.org/acme-team/widgets.git' + ); }); it('writes the opaque Kilo capability to the sandbox auth file, never the raw token', async () => { @@ -1174,7 +1219,6 @@ describe('SessionService.prepareWorkspace', () => { }); expect(workspaceMocks.cloneGitRepo).not.toHaveBeenCalled(); - expect(workspaceMocks.updateGitRemoteToken).not.toHaveBeenCalled(); expect(workspaceMocks.updateGitRemoteUrl).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', @@ -1182,7 +1226,7 @@ describe('SessionService.prepareWorkspace', () => { ); }); - it('preserves the capability origin (no strip, no refresh) for a contained warm Bitbucket review', async () => { + it('strips the capability origin 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', { @@ -1211,10 +1255,12 @@ describe('SessionService.prepareWorkspace', () => { }); 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(); + expect(workspaceMocks.updateGitRemoteUrl).toHaveBeenCalledWith( + session, + '/workspace/user/sessions/agent_test', + 'https://bitbucket.org/acme-team/widgets.git' + ); + expect(workspaceMocks.updateGitAuthor).not.toHaveBeenCalled(); }); it('refreshes prepared GitHub workspace metadata with a managed capability', async () => { @@ -1256,11 +1302,15 @@ describe('SessionService.prepareWorkspace', () => { } ); expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); - expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( + expect(workspaceMocks.updateGitRemoteUrl).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', - 'https://github.com/acme/repo.git', - 'kgh2.default' + 'https://github.com/acme/repo.git' + ); + expect(workspaceMocks.updateGitAuthor).toHaveBeenCalledWith( + session, + '/workspace/user/sessions/agent_test', + { name: 'kiloconnect[bot]', email: 'bot@example.com' } ); }); @@ -1288,14 +1338,56 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://git.example.com/acme/repo.git', - 'generic-git-token', undefined, - { platform: undefined } + { platform: undefined, token: 'generic-git-token' } ); + expect(workspaceMocks.updateGitRemoteUrl).not.toHaveBeenCalled(); expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); }); + it('clones type:git + platform:github without embedding the leftover PAT and writes GH_TOKEN', async () => { + const session = createSession(false); + const sandbox = createSandbox(session); + const metadata = createMetadata({ + gitUrl: 'https://github.com/Kilo-Org/cloud.git', + gitToken: 'leftover-github-pat', + platform: 'github', + gitlabTokenManaged: undefined, + }); + + const result = await new SessionService().prepareWorkspace({ + sandbox, + sandboxId: 'ses-abcdef', + userId: 'user_test', + sessionId: 'agent_test' as SessionId, + env: createEnv(), + metadata, + kilocodeModel: 'test-model', + }); + + expect(metadata.repository).toMatchObject({ + type: 'git', + url: 'https://github.com/Kilo-Org/cloud.git', + platform: 'github', + }); + expect(workspaceMocks.cloneGitRepo).toHaveBeenCalledWith( + session, + '/workspace/user/sessions/agent_test', + 'https://github.com/Kilo-Org/cloud.git', + undefined, + { platform: 'github' } + ); + expect(workspaceMocks.updateGitRemoteUrl).toHaveBeenCalledWith( + session, + '/workspace/user/sessions/agent_test', + 'https://github.com/Kilo-Org/cloud.git' + ); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); + expect(result.ready.gitToken).toBe('leftover-github-pat'); + expect(result.runtimeEnv.GH_TOKEN).toBe('leftover-github-pat'); + }); + it('restores persisted devcontainer runtime metadata on the warm fast path', async () => { const session = createSession(true); const sandbox = createSandbox(session, true); @@ -1438,11 +1530,15 @@ describe('SessionService.prepareWorkspace', () => { expect(getTokenMock).not.toHaveBeenCalled(); expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalled(); expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); - expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( + expect(workspaceMocks.updateGitRemoteUrl).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', - 'https://github.com/acme/repo.git', - 'kgh2.default' + 'https://github.com/acme/repo.git' + ); + expect(workspaceMocks.updateGitAuthor).toHaveBeenCalledWith( + session, + '/workspace/user/sessions/agent_test', + { name: 'kiloconnect[bot]', email: 'bot@example.com' } ); }); @@ -1473,13 +1569,12 @@ describe('SessionService.prepareWorkspace', () => { expect(workspaceMocks.cloneGitRepo).not.toHaveBeenCalled(); expect(tokenMocks.resolveManagedGitLabToken).toHaveBeenCalled(); expect(tokenMocks.issueCloudAgentGitLabSessionCapability).not.toHaveBeenCalled(); - expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( + expect(workspaceMocks.updateGitRemoteUrl).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', - 'https://gitlab.com/acme/repo.git', - 'resolved-gitlab-token', - 'gitlab' + 'https://gitlab.com/acme/repo.git' ); + expect(workspaceMocks.updateGitAuthor).not.toHaveBeenCalled(); }); it('refreshes a warm GitLab code-review remote with a contained project capability', async () => { @@ -1521,13 +1616,12 @@ describe('SessionService.prepareWorkspace', () => { } ); expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); - expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( + expect(workspaceMocks.updateGitRemoteUrl).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', - 'https://gitlab.com/acme/repo.git', - 'kgl2.project', - 'gitlab' + 'https://gitlab.com/acme/repo.git' ); + expect(workspaceMocks.updateGitAuthor).not.toHaveBeenCalled(); }); it('refreshes a prepared warm GitHub remote through managed capability authentication', async () => { @@ -1558,11 +1652,15 @@ describe('SessionService.prepareWorkspace', () => { expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalled(); expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); - expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( + expect(workspaceMocks.updateGitRemoteUrl).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', - 'https://github.com/acme/repo.git', - 'kgh2.default' + 'https://github.com/acme/repo.git' + ); + expect(workspaceMocks.updateGitAuthor).toHaveBeenCalledWith( + session, + '/workspace/user/sessions/agent_test', + { name: 'kiloconnect[bot]', email: 'bot@example.com' } ); }); @@ -1609,7 +1707,6 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'acme/repo', - 'resolved-gh-token', { name: 'kiloconnect[bot]', email: 'bot@example.com' }, undefined ); @@ -3052,14 +3149,17 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { `bb comments create 42 --input - < ${inputPath}` ) ).toBe('allow'); + expect(result.readyRequest.materialized.env).toMatchObject({ + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '/opt/kilo-cloud/kilo-git-credential', + GIT_CONFIG_KEY_1: 'credential.useHttpPath', + GIT_CONFIG_VALUE_1: 'false', + GIT_TERMINAL_PROMPT: '0', + }); for (const key of [ 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_GLOBAL', - 'GIT_CONFIG_COUNT', - 'GIT_CONFIG_KEY_0', - 'GIT_CONFIG_VALUE_0', - 'GIT_CONFIG_KEY_1', - 'GIT_CONFIG_VALUE_1', 'GIT_CONFIG_KEY_2', 'GIT_CONFIG_VALUE_2', 'GIT_OPTIONAL_LOCKS', @@ -3138,6 +3238,25 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(materialized.PATH).toBe('/user/bin'); }); + it('writes GH_TOKEN for type:git + platform:github leftover PATs', async () => { + const result = await buildPromptWrapperRequests( + createMetadata({ + gitUrl: 'https://github.com/Kilo-Org/cloud.git', + gitToken: 'leftover-github-pat', + platform: 'github', + gitlabTokenManaged: undefined, + }) + ); + + expect(result.readyRequest.repo).toMatchObject({ + kind: 'git', + url: 'https://github.com/Kilo-Org/cloud.git', + token: 'leftover-github-pat', + platform: 'github', + }); + expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('leftover-github-pat'); + }); + it('does not use OAuth bearer mode for inferred legacy GitLab tokens', async () => { const result = await buildPromptWrapperRequests( createMetadata({ diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index b31d0b79a6..7f42eecf66 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -35,7 +35,6 @@ import { manageBranch, setupWorkspace, updateGitAuthor, - updateGitRemoteToken, updateGitRemoteUrl, } from './workspace.js'; import { logger, WithLogTags } from './logger.js'; @@ -1362,6 +1361,12 @@ export class SessionService { SESSION_ID: sessionId, SESSION_HOME: sessionHome, [PNPM_STORE_ENV_VAR]: PNPM_STORE_DIR, + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '/opt/kilo-cloud/kilo-git-credential', + GIT_CONFIG_KEY_1: 'credential.useHttpPath', + GIT_CONFIG_VALUE_1: 'false', + GIT_TERMINAL_PROMPT: '0', // Opaque Kilo capability — redeemed for the real credential at the outbound interceptor KILOCODE_TOKEN: kiloCapability, // Backend auth surface (session restore/import). @@ -1373,6 +1378,19 @@ export class SessionService { KILOCODE_FEATURE: createdOnPlatform ?? 'cloud-agent', }; + const reservedGitConfigKeys = new Set([ + 'GIT_CONFIG_COUNT', + 'GIT_CONFIG_KEY_0', + 'GIT_CONFIG_VALUE_0', + 'GIT_CONFIG_KEY_1', + 'GIT_CONFIG_VALUE_1', + ]); + for (const key of Object.keys(envVars)) { + if (key.startsWith('GIT_CONFIG_') && !reservedGitConfigKeys.has(key)) { + delete envVars[key]; + } + } + const providerOptions: Record = { apiKey: kiloCapability, kilocodeToken: kiloCapability, @@ -1541,8 +1559,12 @@ export class SessionService { envVars.OPENCODE_CONFIG_CONTENT = configJson; envVars.KILO_CONFIG_CONTENT = configJson; // Set GH_TOKEN for GitHub repos only, respecting user overrides - if (githubToken && githubRepo && !baseEnvVars.GH_TOKEN) { - envVars.GH_TOKEN = githubToken; + if (!baseEnvVars.GH_TOKEN) { + if (githubToken && githubRepo) { + envVars.GH_TOKEN = githubToken; + } else if (platform === 'github' && gitToken) { + envVars.GH_TOKEN = gitToken; + } } // Determine effective platform: use explicit platform param, or infer from gitUrl as fallback @@ -1805,9 +1827,8 @@ export class SessionService { 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. + // workspace token. The helper emits BITBUCKET_TOKEN as Basic auth and + // the outbound interceptor redeems it per request. if (!env.GIT_TOKEN_SERVICE) { throw ExecutionError.invalidRequest('Git token service is not configured'); } @@ -2363,11 +2384,8 @@ export class SessionService { kiloProviderBaseUrl, kiloSessionIngestBaseUrl ); - if ( - !(await this.sanitizeBitbucketCodeReviewRemote(session, context.workspacePath, metadata)) - ) { - await this.refreshGitRemoteToken(session, context, metadata, resolvedTokens); - } + await this.sanitizeGitRemote(session, context.workspacePath, metadata, resolvedTokens); + await this.refreshGitAuthor(session, context, metadata, resolvedTokens); const detectedDevcontainer = metadata.workspace?.devcontainerRequested && !metadata.devcontainer @@ -2444,7 +2462,7 @@ export class SessionService { onProgress?.('branch', 'Setting up branch…'); await this.prepareBranch(session, workspacePath, branchName, metadata); - await this.sanitizeBitbucketCodeReviewRemote(session, workspacePath, metadata); + await this.sanitizeGitRemote(session, workspacePath, metadata, resolvedTokens); await writeAuthFile(sandbox, sessionHome, kiloCapability); await writeGlobalRules(sandbox, sessionHome, sessionId); @@ -2603,15 +2621,16 @@ export class SessionService { const cloneOptions = repositoryShallow(metadata) ? { shallow: true } : undefined; const git = gitRepository(metadata); if (git) { + const platform = repositoryPlatform(metadata); await cloneGitRepo( session, workspacePath, tokens.gitlabCapabilityGitUrl ?? tokens.bitbucketCapabilityGitUrl ?? git.url, - tokens.gitToken, undefined, { ...cloneOptions, - platform: repositoryPlatform(metadata), + platform, + ...(platform === undefined && tokens.gitToken ? { token: tokens.gitToken } : {}), } ); return; @@ -2622,7 +2641,6 @@ export class SessionService { session, workspacePath, github.repo, - tokens.githubToken, tokens.githubGitAuthor, cloneOptions ); @@ -2669,72 +2687,43 @@ export class SessionService { } } - private async sanitizeBitbucketCodeReviewRemote( + private async sanitizeGitRemote( session: ExecutionSession, workspacePath: string, - metadata: CloudAgentSessionState - ): Promise { - const git = gitRepository(metadata); - if (metadata.identity.createdOnPlatform !== 'code-review' || git?.type !== 'bitbucket') { - return false; + metadata: CloudAgentSessionState, + tokens: ResolvedWorkspaceTokens + ): Promise { + const github = githubRepository(metadata); + if (github) { + await updateGitRemoteUrl(session, workspacePath, `https://github.com/${github.repo}.git`); + return; } - // 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; + const git = gitRepository(metadata); + if (!git) return; + const platform = repositoryPlatform(metadata); + if (platform !== 'github' && platform !== 'gitlab' && platform !== 'bitbucket') { + return; } - await updateGitRemoteUrl(session, workspacePath, git.url); - return true; + await updateGitRemoteUrl( + session, + workspacePath, + tokens.gitlabCapabilityGitUrl ?? tokens.bitbucketCapabilityGitUrl ?? git.url + ); } /** - * Refresh the embedded credentials in the workspace's git remote URL on the - * warm fast path. - * - * GitHub App installation tokens expire after ~1h, and server-resolved GitLab - * credentials can rotate independently of a warm workspace. The URL-embedded - * credentials from the original clone go stale quickly. `GH_TOKEN` / - * `GITLAB_TOKEN` env vars don't rescue `git` itself (they only affect the - * provider CLIs / GitLab HTTP integrations), so we rewrite `origin` whenever - * the token is resolved by us. + * Refresh GitHub author identity on the warm fast path. Origin is kept + * credential-free by `sanitizeGitRemote`; git auth comes from the helper + * via GH_TOKEN / GITLAB_TOKEN / BITBUCKET_TOKEN. */ - private async refreshGitRemoteToken( + private async refreshGitAuthor( session: ExecutionSession, context: SessionContext, metadata: CloudAgentSessionState, tokens: ResolvedWorkspaceTokens ): Promise { - const github = githubRepository(metadata); - if (github) { - if (tokens.githubToken !== undefined && tokens.githubInstallationId !== undefined) { - await updateGitRemoteToken( - session, - context.workspacePath, - `https://github.com/${github.repo}.git`, - tokens.githubToken - ); - if (tokens.githubGitAuthor) { - await updateGitAuthor(session, context.workspacePath, tokens.githubGitAuthor); - } - } - } - - const git = gitRepository(metadata); - if (git) { - if ( - tokens.gitToken !== undefined && - (tokens.gitlabTokenManaged === true || tokens.bitbucketTokenManaged === true) - ) { - await updateGitRemoteToken( - session, - context.workspacePath, - tokens.gitlabCapabilityGitUrl ?? tokens.bitbucketCapabilityGitUrl ?? git.url, - tokens.gitToken, - repositoryPlatform(metadata) - ); - } + if (githubRepository(metadata) && tokens.githubGitAuthor) { + await updateGitAuthor(session, context.workspacePath, tokens.githubGitAuthor); } } diff --git a/services/cloud-agent-next/src/workspace.test.ts b/services/cloud-agent-next/src/workspace.test.ts index 0b37097295..690157777f 100644 --- a/services/cloud-agent-next/src/workspace.test.ts +++ b/services/cloud-agent-next/src/workspace.test.ts @@ -33,7 +33,6 @@ import { cloneGitHubRepo, cloneGitRepo, updateGitAuthor, - updateGitRemoteToken, updateGitRemoteUrl, checkDiskSpace, checkDiskAndCleanBeforeSetup, @@ -500,176 +499,105 @@ describe('disk space checking', () => { describe('cloneGitHubRepo', () => { it('should clone repository (disk space check is separate)', async () => { mockExec - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // git config user.name - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // git config user.email - - // Mock gitCheckout to succeed - mockGitCheckout.mockResolvedValue({ - success: true, - exitCode: 0, - }); + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); await cloneGitHubRepo(fakeSession, '/workspace', 'org/repo'); - // Verify clone was called - expect(mockGitCheckout).toHaveBeenCalled(); + expect(String(mockExec.mock.calls[0]?.[0])).toBe( + "'git' 'clone' '--progress' 'https://github.com/org/repo.git' '/workspace'" + ); + expect(mockGitCheckout).not.toHaveBeenCalled(); }); }); describe('cloneGitRepo', () => { it('should clone repository (disk space check is separate)', async () => { mockExec - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // git config user.name - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // git config user.email - - // Mock gitCheckout to succeed - mockGitCheckout.mockResolvedValue({ - success: true, - exitCode: 0, - }); + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); await cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git'); - // Verify clone was called - expect(mockGitCheckout).toHaveBeenCalled(); + expect(String(mockExec.mock.calls[0]?.[0])).toBe( + "'git' 'clone' '--progress' 'https://example.com/repo.git' '/workspace'" + ); + expect(mockGitCheckout).not.toHaveBeenCalled(); }); - it('should include token in URL when provided', async () => { + it('embeds a generic token only when the host has no helper rule', async () => { mockExec - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // git config user.name - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // git config user.email + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); - // Mock gitCheckout to succeed - mockGitCheckout.mockResolvedValue({ - success: true, - exitCode: 0, + await cloneGitRepo(fakeSession, '/workspace', 'https://git.example.com/repo.git', undefined, { + token: 'generic-git-token', }); - await cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git', 'test-token'); - - // Verify gitCheckout was called with URL containing token - expect(mockGitCheckout).toHaveBeenCalledWith( - expect.stringContaining('x-access-token:test-token'), - expect.any(Object) - ); + const expected = new URL('https://git.example.com/repo.git'); + expected.username = 'x-access-token'; + expected.password = 'generic-git-token'; + expect(String(mockExec.mock.calls[0]?.[0])).toContain(expected.toString()); }); - it('should use oauth2 username for gitlab platform', async () => { + it('clones the canonical URL without embedding a token', async () => { mockExec - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // git config user.name - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // git config user.email - - mockGitCheckout.mockResolvedValue({ - success: true, - exitCode: 0, - }); + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); - await cloneGitRepo( - fakeSession, - '/workspace', - 'https://gitlab.com/repo.git', - 'test-token', - undefined, - { - platform: 'gitlab', - } - ); + await cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git'); - expect(mockGitCheckout).toHaveBeenCalledWith( - expect.stringContaining('oauth2:test-token'), - expect.any(Object) + const command = String(mockExec.mock.calls[0]?.[0]); + expect(command).toBe( + "'git' 'clone' '--progress' 'https://example.com/repo.git' '/workspace'" ); + expect(command).not.toContain('@'); + expect(mockGitCheckout).not.toHaveBeenCalled(); }); - it('should use x-token-auth username for bitbucket platform', async () => { + it('clones a GitLab URL without embedding the token', async () => { mockExec + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); - mockGitCheckout.mockResolvedValue({ success: true, exitCode: 0 }); - await cloneGitRepo( - fakeSession, - '/workspace', - 'https://bitbucket.org/acme/repo.git', - 'test-token', - undefined, - { platform: 'bitbucket' } - ); + await cloneGitRepo(fakeSession, '/workspace', 'https://gitlab.com/repo.git', undefined, { + platform: 'gitlab', + token: 'gitlab-token', + }); - expect(mockGitCheckout).toHaveBeenCalledWith( - expect.stringContaining('x-token-auth:test-token'), - expect.any(Object) + expect(String(mockExec.mock.calls[0]?.[0])).toBe( + "'git' 'clone' '--progress' 'https://gitlab.com/repo.git' '/workspace'" ); }); - it('should use x-access-token username for github platform', async () => { + it('clones a Bitbucket URL without embedding the token', async () => { mockExec - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // git config user.name - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // git config user.email - - mockGitCheckout.mockResolvedValue({ - success: true, - exitCode: 0, - }); + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); await cloneGitRepo( fakeSession, '/workspace', - 'https://example.com/repo.git', - 'test-token', + 'https://bitbucket.org/acme/repo.git', undefined, - { - platform: 'github', - } - ); - - expect(mockGitCheckout).toHaveBeenCalledWith( - expect.stringContaining('x-access-token:test-token'), - expect.any(Object) + { platform: 'bitbucket', token: 'bitbucket-token' } ); - }); - - it('should use x-access-token username when platform is undefined', async () => { - mockExec - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // git config user.name - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // git config user.email - - mockGitCheckout.mockResolvedValue({ - success: true, - exitCode: 0, - }); - - await cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git', 'test-token'); - - expect(mockGitCheckout).toHaveBeenCalledWith( - expect.stringContaining('x-access-token:test-token'), - expect.any(Object) - ); - }); - - it('logs sdk timeout when gitCheckout rejects with clone timeout', async () => { - mockGitCheckout.mockRejectedValueOnce(new Error('Git clone timed out after 120000ms')); - await expect( - cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git') - ).rejects.toThrow('Failed to clone repository from https://example.com/repo.git'); - - expect(mockTimeoutWithTags).toHaveBeenCalledWith({ logTag: 'sandbox-operation-timeout' }); - expect(mockTimeoutWithFields).toHaveBeenCalledWith( - expect.objectContaining({ - operation: 'git.clone', - timeoutMs: 120000, - timeoutLayer: 'sdk', - error: 'Git clone timed out after 120000ms', - }) + expect(String(mockExec.mock.calls[0]?.[0])).toBe( + "'git' 'clone' '--progress' 'https://bitbucket.org/acme/repo.git' '/workspace'" ); - expect(mockTimeoutWarn).toHaveBeenCalledWith('Sandbox operation timed out'); }); it('preserves sandbox 500 errors for recovery handling', async () => { const error = new Error('HTTP error! status: 500'); Object.assign(error, { name: 'SandboxError' }); - mockGitCheckout.mockRejectedValueOnce(error); + mockExec.mockRejectedValueOnce(error); await expect( cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git') @@ -677,34 +605,24 @@ describe('disk space checking', () => { }); it('throws GitRepositoryNotFoundError when git stderr says repository not found', async () => { - mockGitCheckout.mockRejectedValueOnce( - new Error( - "remote: Repository not found.\nfatal: repository 'https://example.com/repo' not found" - ) - ); - - const promise = cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git'); - await expect(promise).rejects.toBeInstanceOf(GitRepositoryNotFoundError); - await expect(promise).rejects.toThrow('Repository not found: https://example.com/repo.git'); - }); - - it('throws GitRepositoryNotFoundError when stderr field on the SDK error contains the pattern', async () => { - const sdkError = Object.assign(new Error('Git checkout failed'), { - name: 'GitCheckoutError', - stderr: "remote: Repository not found.\nfatal: repository '...' not found", + mockExec.mockResolvedValueOnce({ + exitCode: 128, + stdout: '', + stderr: + "remote: Repository not found.\nfatal: repository 'https://example.com/repo' not found", }); - mockGitCheckout.mockRejectedValueOnce(sdkError); const promise = cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git'); await expect(promise).rejects.toBeInstanceOf(GitRepositoryNotFoundError); + await expect(promise).rejects.toThrow('Repository not found: https://example.com/repo.git'); }); it('throws GitCloneFailedError for LFS smudge failures (not repo-not-found)', async () => { - mockGitCheckout.mockRejectedValueOnce( - new Error( - "error: external filter 'git-lfs filter-process' failed: smudge filter lfs failed" - ) - ); + mockExec.mockResolvedValueOnce({ + exitCode: 128, + stdout: '', + stderr: "error: external filter 'git-lfs filter-process' failed: smudge filter lfs failed", + }); const promise = cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git'); await expect(promise).rejects.toBeInstanceOf(GitCloneFailedError); @@ -713,17 +631,17 @@ describe('disk space checking', () => { ); }); - it('throws GitCloneFailedError when gitCheckout returns success=false', async () => { - mockGitCheckout.mockResolvedValue({ success: false, exitCode: 128 }); - - const promise = cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git'); - await expect(promise).rejects.toBeInstanceOf(GitCloneFailedError); - }); - it('sanitizes tokens out of GitCloneFailedError reason', async () => { - mockGitCheckout.mockRejectedValueOnce( - new Error('clone failed at https://x-access-token:secret123@example.com/repo.git') - ); + mockExec.mockResolvedValueOnce({ + exitCode: 128, + stdout: '', + stderr: `clone failed at ${(() => { + const url = new URL('https://example.com/repo.git'); + url.username = 'x-access-token'; + url.password = 'secret123'; + return url.toString(); + })()}`, + }); try { await cloneGitRepo(fakeSession, '/workspace', 'https://example.com/repo.git'); @@ -763,84 +681,24 @@ describe('disk space checking', () => { it('replaces a tokenized origin with the credential-free canonical URL', async () => { mockExec.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); - await updateGitRemoteUrl( - fakeSession, - '/workspace', - 'https://x-token-auth:managed-token@bitbucket.org/acme/repo.git' - ); + const leftover = new URL('https://bitbucket.org/acme/repo.git'); + leftover.username = 'x-token-auth'; + leftover.password = 'managed-token'; + await updateGitRemoteUrl(fakeSession, '/workspace', leftover.toString()); const command = String(mockExec.mock.calls[0]?.[0]); expect(command).toContain("git remote set-url origin 'https://bitbucket.org/acme/repo.git'"); expect(command).not.toContain('managed-token'); expect(command).not.toContain('@bitbucket.org'); }); - }); - describe('updateGitRemoteToken', () => { - it('should use oauth2 username for gitlab platform', async () => { + it('leaves SCP-style remotes unchanged', async () => { mockExec.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); - await updateGitRemoteToken( - fakeSession, - '/workspace', - 'https://gitlab.com/repo.git', - 'new-token', - 'gitlab' - ); - - expect(mockExec).toHaveBeenCalledWith( - expect.stringContaining('oauth2:new-token'), - expect.any(Object) - ); - }); - - it('should use x-token-auth username for bitbucket platform', async () => { - mockExec.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); - - await updateGitRemoteToken( - fakeSession, - '/workspace', - 'https://bitbucket.org/acme/repo.git', - 'new-token', - 'bitbucket' - ); - - expect(mockExec).toHaveBeenCalledWith( - expect.stringContaining('x-token-auth:new-token'), - expect.any(Object) - ); - }); - - it('should use x-access-token username for github platform', async () => { - mockExec.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); - - await updateGitRemoteToken( - fakeSession, - '/workspace', - 'https://example.com/repo.git', - 'new-token', - 'github' - ); - - expect(mockExec).toHaveBeenCalledWith( - expect.stringContaining('x-access-token:new-token'), - expect.any(Object) - ); - }); - - it('should use x-access-token username when platform is undefined', async () => { - mockExec.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); - - await updateGitRemoteToken( - fakeSession, - '/workspace', - 'https://example.com/repo.git', - 'new-token' - ); + await updateGitRemoteUrl(fakeSession, '/workspace', 'git@gitlab.com:acme/repo.git'); - expect(mockExec).toHaveBeenCalledWith( - expect.stringContaining('x-access-token:new-token'), - expect.any(Object) + expect(String(mockExec.mock.calls[0]?.[0])).toContain( + "git remote set-url origin 'git@gitlab.com:acme/repo.git'" ); }); }); diff --git a/services/cloud-agent-next/src/workspace.ts b/services/cloud-agent-next/src/workspace.ts index a5adb8ab3c..2105bd924c 100644 --- a/services/cloud-agent-next/src/workspace.ts +++ b/services/cloud-agent-next/src/workspace.ts @@ -11,11 +11,8 @@ import { FAST_SANDBOX_COMMAND_TIMEOUT_MS, GIT_CLONE_TIMEOUT_MS, GIT_COMMAND_TIMEOUT_MS, - logSandboxOperationTimeout, timedExec, - withSandboxOperationTimeoutLog, } from './sandbox-timeout-logging.js'; -import { withTimeout } from '@kilocode/worker-utils'; import { isSandboxInternalServerError } from './sandbox-recovery.js'; import { shellQuote } from './kilo/utils.js'; import { @@ -777,65 +774,49 @@ export async function cloneGitHubRepo( session: ExecutionSession, workspacePath: string, githubRepo: string, - githubToken?: string, gitAuthor?: GitAuthorConfig, options?: { shallow?: boolean } ): Promise { const gitUrl = `https://github.com/${githubRepo}.git`; - await cloneGitRepo(session, workspacePath, gitUrl, githubToken, gitAuthor, options); + await cloneGitRepo(session, workspacePath, gitUrl, gitAuthor, options); } export async function cloneGitRepo( session: ExecutionSession, workspacePath: string, gitUrl: string, - gitToken?: string, gitAuthor?: GitAuthorConfig, - options?: { shallow?: boolean; platform?: ManagedGitPlatform } + options?: { shallow?: boolean; platform?: ManagedGitPlatform; token?: string } ): Promise { - // Build URL with token if available (for private repos) - // GitLab OAuth tokens require username 'oauth2'; all other providers use 'x-access-token' - let repoUrl = gitUrl; - if (gitToken) { + let cloneUrl = gitUrl; + if (options?.token && options.platform === undefined) { const url = new URL(gitUrl); - url.username = gitCredentialUsername(options?.platform); - url.password = gitToken; - repoUrl = url.toString(); + url.username = gitCredentialUsername(undefined); + url.password = options.token; + cloneUrl = url.toString(); } - const sanitizedGitUrl = sanitizeGitUrlForLogging(gitUrl); const shallow = options?.shallow ?? false; logger.setTags({ gitUrl: sanitizedGitUrl, workspacePath, shallow }); logger.info('Cloning generic git repository'); try { - // SDK clone timeout terminates the subprocess; the outer timeout bounds the request. - const result = await withTimeout( - withSandboxOperationTimeoutLog( - session.gitCheckout(repoUrl, { - targetDir: workspacePath, - cloneTimeoutMs: GIT_CLONE_TIMEOUT_MS, - // Use depth: 1 for shallow clones (faster, less disk space) - ...(shallow && { depth: 1 }), - }), - { - operation: 'git.clone', - timeoutMs: GIT_CLONE_TIMEOUT_MS, - timeoutLayer: 'sdk', - } - ), - GIT_CLONE_TIMEOUT_MS + FAST_SANDBOX_COMMAND_TIMEOUT_MS, - `Git clone request timed out after ${(GIT_CLONE_TIMEOUT_MS + FAST_SANDBOX_COMMAND_TIMEOUT_MS) / 1000} seconds for ${sanitizedGitUrl}`, - () => - logSandboxOperationTimeout({ - operation: 'git.clone', - timeoutMs: GIT_CLONE_TIMEOUT_MS + FAST_SANDBOX_COMMAND_TIMEOUT_MS, - timeoutLayer: 'outer', - }) - ); + // Clone through the session shell so GIT_CONFIG_* / token env reach the + // credential helper. The sandbox gitCheckout API only posts the URL and + // does not document session-env inheritance. + const cloneArgs = ['git', 'clone', '--progress']; + if (shallow) { + cloneArgs.push('--depth', '1'); + } + cloneArgs.push(cloneUrl, workspacePath); + const result = await timedExec(session, cloneArgs.map(shellQuote).join(' '), 'git.clone', { + timeoutMs: GIT_CLONE_TIMEOUT_MS, + }); - if (!result.success) { - throw new Error(`gitCheckout failed with exit code ${result.exitCode ?? 'unknown'}`); + if (result.exitCode !== 0) { + throw new Error( + `git clone failed with exit code ${result.exitCode ?? 'unknown'}: ${result.stderr || result.stdout}` + ); } await updateGitAuthor( @@ -862,27 +843,19 @@ export async function cloneGitRepo( // message, including patterns like: // "remote: Repository not found." // "fatal: repository '...' not found" - // We also pull stderr from a typed GitCheckoutError if present. const stderr = extractStderr(err); const haystack = `${errorMessage}\n${stderr}`; if (REPO_NOT_FOUND_PATTERN.test(haystack)) { throw new GitRepositoryNotFoundError(sanitizedGitUrl); } - // All other failures (LFS, network, timeouts, etc.) — wrap as a - // typed clone-failure error. Defense-in-depth: tokens shouldn't reach - // this point (the SDK strips them and `sanitizedGitUrl` has been - // masked), but we still run `sanitizeGitOutput` in case a future code - // path inlines an authenticated URL into the error message. throw new GitCloneFailedError(sanitizedGitUrl, sanitizeGitOutput(errorMessage)); } } export type RestoreWorkspaceOptions = { githubRepo?: string; - githubToken?: string; gitUrl?: string; - gitToken?: string; gitAuthor?: GitAuthorConfig; lastSeenBranch?: string; platform?: ManagedGitPlatform; @@ -895,17 +868,11 @@ export async function restoreWorkspace( options: RestoreWorkspaceOptions ): Promise { if (options.gitUrl) { - await cloneGitRepo(session, workspacePath, options.gitUrl, options.gitToken, undefined, { + await cloneGitRepo(session, workspacePath, options.gitUrl, undefined, { platform: options.platform, }); } else if (options.githubRepo) { - await cloneGitHubRepo( - session, - workspacePath, - options.githubRepo, - options.githubToken, - options.gitAuthor - ); + await cloneGitHubRepo(session, workspacePath, options.githubRepo, options.gitAuthor); } else { throw new Error('No repository source provided for workspace restore'); } @@ -941,12 +908,18 @@ export async function updateGitRemoteUrl( workspacePath: string, gitUrl: string ): Promise { - const canonicalUrl = new URL(gitUrl); - canonicalUrl.username = ''; - canonicalUrl.password = ''; + let originUrl = gitUrl; + try { + const canonicalUrl = new URL(gitUrl); + canonicalUrl.username = ''; + canonicalUrl.password = ''; + originUrl = canonicalUrl.toString(); + } catch { + // SCP-style remotes are already credential-free; leave them unchanged. + } const result = await timedExec( session, - `git remote set-url origin ${shellQuote(canonicalUrl.toString())}`, + `git remote set-url origin ${shellQuote(originUrl)}`, 'git.updateRemoteUrl', { cwd: workspacePath } ); @@ -955,49 +928,6 @@ export async function updateGitRemoteUrl( } } -/** - * Update the git remote origin URL to include a new token. - * This is needed when the git token changes and we need to push/pull. - * - * @param session - Execution session - * @param workspacePath - Path to the git repository - * @param gitUrl - Full git URL (e.g., https://github.com/org/repo.git) - * @param gitToken - New git token for authentication - * @param platform - Git platform; GitLab requires 'oauth2' as the username - */ -export async function updateGitRemoteToken( - session: ExecutionSession, - workspacePath: string, - gitUrl: string, - gitToken: string, - platform?: ManagedGitPlatform -): Promise { - const newUrl = new URL(gitUrl); - newUrl.username = gitCredentialUsername(platform); - newUrl.password = gitToken; - - const sanitizedGitUrl = sanitizeGitUrlForLogging(gitUrl); - logger.setTags({ workspacePath, gitUrl: sanitizedGitUrl }); - logger.info('Updating git remote URL with new token'); - - const result = await timedExec( - session, - `cd '${workspacePath}' && git remote set-url origin '${newUrl.toString()}'`, - 'git.updateRemoteToken' - ); - - if (result.exitCode !== 0) { - // Log actual error for debugging (sanitized via structured logging) - logger.error('Git remote update failed', { - exitCode: result.exitCode, - }); - // Throw generic error to avoid leaking token in response - throw new Error(`Failed to update git remote URL`); - } - - logger.info('Successfully updated git remote URL'); -} - async function gitFetch(session: ExecutionSession, workspacePath: string): Promise { const result = await timedExec(session, `cd ${workspacePath} && git fetch origin`, 'git.fetch', { timeoutMs: GIT_COMMAND_TIMEOUT_MS, 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 97451a4c43..068a18056d 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts @@ -154,7 +154,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { expect(gitCalls[0]).toEqual([ 'clone', '--progress', - 'https://x-access-token:gh-token@github.com/acme/repo.git', + 'https://github.com/acme/repo.git', request.workspace.workspacePath, ]); expect(gitCalls.some(args => args.join(' ') === 'checkout --progress -b main')).toBe(true); @@ -262,15 +262,21 @@ describe('prepareWrapperBootstrapWorkspace', () => { } ); - // Bitbucket's origin is credential-stripped after bootstrap, so a deferred - // blob could never be lazily fetched — it keeps a normal full clone. + // Raw-token Bitbucket is not blobless-eligible; only capability-backed + // sessions qualify. Origin is still stripped to a credential-free URL. const cloneCall = gitCalls.find(args => args[0] === 'clone'); + expect(cloneCall).toContain('https://bitbucket.org/acme/repo.git'); + expect(cloneCall?.join(' ')).not.toContain('bb-token'); expect(cloneCall).not.toContain('--filter=blob:none'); - // The raw-token origin is stripped to a credential-free URL. - expect(gitCalls.some(args => args[0] === 'remote' && args[1] === 'set-url')).toBe(true); + expect(gitCalls).toContainEqual([ + 'remote', + 'set-url', + 'origin', + 'https://bitbucket.org/acme/repo.git', + ]); }); - it('uses a blobless clone and keeps the capability origin for a contained Bitbucket review session', async () => { + it('uses a blobless clone and strips origin to canonical for a contained Bitbucket review session', async () => { const request = makeRequest(tmpDir); request.materialized.env.KILO_PLATFORM = 'code-review'; request.materialized.setupCommands = []; @@ -308,10 +314,16 @@ describe('prepareWrapperBootstrapWorkspace', () => { } ); - // A capability origin stays authenticated through the outbound interceptor, - // so the clone is blobless and the origin is NOT stripped. - expect(gitCalls.find(args => args[0] === 'clone')).toContain('--filter=blob:none'); - expect(gitCalls.some(args => args[0] === 'remote' && args[1] === 'set-url')).toBe(false); + const cloneCall = gitCalls.find(args => args[0] === 'clone'); + expect(cloneCall).toContain('--filter=blob:none'); + expect(cloneCall).toContain('https://bitbucket.org/acme/repo.git'); + expect(cloneCall?.join(' ')).not.toContain('kbb1.'); + expect(gitCalls).toContainEqual([ + 'remote', + 'set-url', + 'origin', + 'https://bitbucket.org/acme/repo.git', + ]); }); it('uses a blobless partial clone for GitLab review sessions', async () => { @@ -352,7 +364,10 @@ describe('prepareWrapperBootstrapWorkspace', () => { } ); - expect(gitCalls.find(args => args[0] === 'clone')).toContain('--filter=blob:none'); + const cloneCall = gitCalls.find(args => args[0] === 'clone'); + expect(cloneCall).toContain('--filter=blob:none'); + expect(cloneCall).toContain('https://gitlab.com/acme/repo.git'); + expect(cloneCall?.join(' ')).not.toContain('gl-token'); }); it('keeps a full clone for review sessions on an unrecognized git platform', async () => { @@ -390,7 +405,107 @@ describe('prepareWrapperBootstrapWorkspace', () => { } ); - expect(gitCalls.find(args => args[0] === 'clone')).not.toContain('--filter=blob:none'); + const cloneCall = gitCalls.find(args => args[0] === 'clone'); + expect(cloneCall).not.toContain('--filter=blob:none'); + const expected = new URL('https://git.example.com/acme/repo.git'); + expected.username = 'x-access-token'; + expected.password = 't'; + expect(cloneCall).toContain(expected.toString()); + expect(gitCalls.some(args => args[0] === 'remote' && args[1] === 'set-url')).toBe(false); + }); + + it('embeds a leftover PAT when kind:git + platform:github has no GH_TOKEN', async () => { + const request = makeRequest(tmpDir); + request.materialized.setupCommands = []; + request.repo = { + kind: 'git', + url: 'https://github.com/Kilo-Org/cloud.git', + token: 'leftover-github-pat', + platform: 'github', + }; + delete request.materialized.env.GH_TOKEN; + delete process.env.GH_TOKEN; + + const gitCalls: string[][] = []; + await prepareWrapperBootstrapWorkspace( + request, + mock(() => {}), + { + git: async args => { + gitCalls.push(args); + if (args[0] === 'clone') { + await fsp.mkdir(path.join(request.workspace.workspacePath, '.git'), { + recursive: true, + }); + } + if (args[0] === 'rev-parse') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + restoreSession: async () => ({ + ok: true, + downloaded: false, + imported: true, + diffs: { applied: 0, skipped: 0, total: 0 }, + }), + } + ); + + const expected = new URL('https://github.com/Kilo-Org/cloud.git'); + expected.username = 'x-access-token'; + expected.password = 'leftover-github-pat'; + const cloneCall = gitCalls.find(args => args[0] === 'clone'); + expect(cloneCall).toContain(expected.toString()); + expect(gitCalls.some(args => args[0] === 'remote' && args[1] === 'set-url')).toBe(false); + }); + + it('clones kind:git + platform:github without embedding when GH_TOKEN is present', async () => { + const request = makeRequest(tmpDir); + request.materialized.setupCommands = []; + request.materialized.env.GH_TOKEN = 'leftover-github-pat'; + request.repo = { + kind: 'git', + url: 'https://github.com/Kilo-Org/cloud.git', + token: 'leftover-github-pat', + platform: 'github', + }; + + const gitCalls: string[][] = []; + await prepareWrapperBootstrapWorkspace( + request, + mock(() => {}), + { + git: async args => { + gitCalls.push(args); + if (args[0] === 'clone') { + await fsp.mkdir(path.join(request.workspace.workspacePath, '.git'), { + recursive: true, + }); + } + if (args[0] === 'rev-parse') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + restoreSession: async () => ({ + ok: true, + downloaded: false, + imported: true, + diffs: { applied: 0, skipped: 0, total: 0 }, + }), + } + ); + + const cloneCall = gitCalls.find(args => args[0] === 'clone'); + expect(cloneCall).toContain('https://github.com/Kilo-Org/cloud.git'); + expect(cloneCall?.join(' ')).not.toContain('leftover-github-pat'); + expect(gitCalls).toContainEqual([ + 'remote', + 'set-url', + 'origin', + 'https://github.com/Kilo-Org/cloud.git', + ]); }); it('retries a full clone when the server rejects the blobless filter', async () => { @@ -626,7 +741,12 @@ describe('prepareWrapperBootstrapWorkspace', () => { await fsp.mkdir(path.join(request.workspace.workspacePath, '.git'), { recursive: true }); opts?.onOutput?.( 'stderr', - 'remote: https://x-access-token:gh-token@github.com/acme/repo.git Receiving objects: 42% (42/100)\n' + `remote: ${(() => { + const url = new URL('https://github.com/acme/repo.git'); + url.username = 'x-access-token'; + url.password = 'gh-token'; + return url.toString(); + })()} Receiving objects: 42% (42/100)\n` ); } if (args[0] === 'rev-parse') { @@ -1479,7 +1599,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { }, }); - expect(gitCalls[0]).toContain('https://x-token-auth:managed-token@bitbucket.org/acme/repo.git'); + expect(gitCalls[0]).toContain('https://bitbucket.org/acme/repo.git'); const sanitizedRemote = 'git:remote set-url origin https://bitbucket.org/acme/repo.git'; expect(events).toContain(sanitizedRemote); expect(events.indexOf(sanitizedRemote)).toBeLessThan(events.indexOf('restore')); @@ -1526,15 +1646,13 @@ describe('prepareWrapperBootstrapWorkspace', () => { expect(result.workspaceWasWarm).toBe(true); expect(progress).toHaveBeenCalledWith('kilo_server', 'Starting Kilo...'); - expect(gitCalls).toEqual([ - ['remote', 'set-url', 'origin', 'https://oauth2:gitlab-token@gitlab.com/acme/repo.git'], - ]); + expect(gitCalls).toEqual([['remote', 'set-url', 'origin', 'https://gitlab.com/acme/repo.git']]); expect(await fsp.readFile(rulesPath, 'utf8')).toBe( buildCloudAgentRules(request.agentSessionId) ); }); - it('refreshes a warm Bitbucket remote with x-token-auth', async () => { + it('strips a warm Bitbucket leftover origin to the canonical URL', async () => { const request = makeRequest(tmpDir, { workspace: { workspacePath: path.join(tmpDir, 'workspace'), @@ -1561,12 +1679,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { }); expect(gitCalls).toEqual([ - [ - 'remote', - 'set-url', - 'origin', - 'https://x-token-auth:bitbucket-token@bitbucket.org/acme/repo.git', - ], + ['remote', 'set-url', 'origin', 'https://bitbucket.org/acme/repo.git'], ]); }); @@ -1645,7 +1758,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { expect(process.env.GH_TOKEN).toBe('user-token'); expect(gitCalls).toEqual([ - ['remote', 'set-url', 'origin', 'https://x-access-token:user-token@github.com/acme/repo.git'], + ['remote', 'set-url', 'origin', 'https://github.com/acme/repo.git'], ['config', 'user.name', 'octocat'], ['config', 'user.email', '1+octocat@users.noreply.github.com'], ]); @@ -1691,9 +1804,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { }), }); - expect(events).toContain( - 'git:remote set-url origin https://x-access-token:gh-token@github.com/acme/repo.git' - ); + expect(events).toContain('git:remote set-url origin https://github.com/acme/repo.git'); const fetchIndex = events.indexOf('git:fetch origin feature/source'); const checkoutIndex = events.indexOf('git:checkout -B session/new FETCH_HEAD'); const firstSetupIndex = events.indexOf('process:sh -lc prepare one'); diff --git a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts index 258f480803..9a825665c8 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts @@ -317,17 +317,35 @@ export function workspaceBootstrapErrorCode( : 'WORKSPACE_SETUP_FAILED'; } -function authenticatedUrl( - gitUrl: string, - token: string | undefined, - platform: 'github' | 'gitlab' | 'bitbucket' | undefined -): string { - if (!token) return gitUrl; - const url = new URL(gitUrl); - url.username = - platform === 'gitlab' ? 'oauth2' : platform === 'bitbucket' ? 'x-token-auth' : 'x-access-token'; - url.password = token; - return url.toString(); +function canonicalGitUrl(repo: NonNullable): string { + const raw = repo.kind === 'github' ? `https://github.com/${repo.repo}.git` : repo.url; + try { + const url = new URL(raw); + url.username = ''; + url.password = ''; + return url.toString(); + } catch { + return raw; + } +} + +function isHelperBackedRemote(repo: NonNullable): boolean { + if (repo.kind === 'github') return true; + if (repo.platform === 'gitlab' || repo.platform === 'bitbucket') return true; + return repo.platform === 'github' && Boolean(process.env.GH_TOKEN); +} + +function cloneGitUrl(repo: NonNullable): string { + const canonical = canonicalGitUrl(repo); + if (isHelperBackedRemote(repo) || !repo.token) return canonical; + try { + const url = new URL(canonical); + url.username = 'x-access-token'; + url.password = repo.token; + return url.toString(); + } catch { + return canonical; + } } async function exists(filePath: string): Promise { @@ -393,9 +411,9 @@ function isBitbucketReviewSession( } // Wire-format prefix of a Bitbucket outbound session capability (see the -// git-token-service BitbucketSessionCapabilityCodec). A capability in the origin -// stays authenticated through the outbound interceptor, unlike a raw token which -// is stripped after bootstrap. +// git-token-service BitbucketSessionCapabilityCodec). Presence of a capability +// qualifies the session for blobless clone; the credential helper authenticates +// later lazy fetches. const BITBUCKET_CAPABILITY_PREFIX = 'kbb1.'; function hasBitbucketReviewCapability(request: WrapperSessionReadyRequest): boolean { @@ -409,10 +427,9 @@ function hasBitbucketReviewCapability(request: WrapperSessionReadyRequest): bool function isBloblessReviewCloneEligible(request: WrapperSessionReadyRequest): boolean { if (!isCodeReviewSession(request)) return false; const repo = request.repo; - // GitHub/GitLab keep working credentials via outbound injection. Bitbucket - // keeps them only when the session uses an outbound capability (a raw-token - // origin is credential-stripped after bootstrap). Other/unknown git remotes - // have no such guarantee, so they keep a full clone. + // GitHub/GitLab and capability-backed Bitbucket authenticate lazy blob + // fetches via the credential helper. Other/unknown git remotes have no such + // guarantee, so they keep a full clone. if (repo?.kind === 'github') return true; if (repo?.kind === 'git' && repo.platform === 'gitlab') return true; return hasBitbucketReviewCapability(request); @@ -429,9 +446,8 @@ async function cloneRepository( throw new Error('Session metadata is missing a repository source'); } - const gitUrl = repo.kind === 'github' ? `https://github.com/${repo.repo}.git` : repo.url; + const repoUrl = cloneGitUrl(repo); const platform = repo.kind === 'git' ? repo.platform : 'github'; - const repoUrl = authenticatedUrl(gitUrl, repo.token, platform); // Code review reads changed files from the working tree and gets the PR diff // from the provider API or a local `git diff ..HEAD`. It needs the full // commit graph but not every historical file blob, so a blobless partial clone @@ -439,7 +455,7 @@ async function cloneRepository( // which on large repositories otherwise exceeds the clone timeout. Full history // is retained, so incremental diffs and merge-base still work. See // isBloblessReviewCloneEligible for which sessions qualify (GitHub, GitLab, and - // capability-backed Bitbucket, whose origin stays authenticated for lazy fetch). + // capability-backed Bitbucket). The credential helper authenticates lazy fetch. const useBlobless = isBloblessReviewCloneEligible(request); const runClone = async (blobless: boolean): Promise => { @@ -637,46 +653,12 @@ async function prepareBranch( } } -async function sanitizeBitbucketCodeReviewRemote( +async function sanitizeOriginRemote( request: WrapperSessionReadyRequest, runGit: GitRunner -): Promise { - if (!isBitbucketReviewSession(request)) { - return false; - } - // A capability origin stays authenticated through the outbound interceptor and - // is safe to expose (scoped to one repo, useless outside this container), so it - // must stay in place for a blobless clone's later lazy blob fetches. Only a raw - // workspace token needs stripping. Either way this is a handled code-review - // remote (return true), so callers do not refresh a token over it. - if (hasBitbucketReviewCapability(request)) { - return true; - } - const canonicalUrl = new URL(request.repo.url); - canonicalUrl.username = ''; - canonicalUrl.password = ''; - const result = await runGit(['remote', 'set-url', 'origin', canonicalUrl.toString()], { - cwd: request.workspace.workspacePath, - timeoutMs: SHORT_GIT_COMMAND_TIMEOUT_MS, - }); - if (result.exitCode !== 0) { - throw new Error('Failed to update git remote URL'); - } - return true; -} - -function repositoryUrls(request: WrapperSessionReadyRequest): { - canonical: string; - authenticated: string; -} | null { - const repo = request.repo; - if (!repo) return null; - const canonical = repo.kind === 'github' ? `https://github.com/${repo.repo}.git` : repo.url; - const platform = repo.kind === 'git' ? repo.platform : 'github'; - return { - canonical, - authenticated: authenticatedUrl(canonical, repo.token, platform), - }; +): Promise { + if (!request.repo || !isHelperBackedRemote(request.repo)) return; + await setOriginUrl(request, runGit, canonicalGitUrl(request.repo)); } async function setOriginUrl( @@ -693,33 +675,23 @@ async function setOriginUrl( } } -async function refreshGitRemoteToken( +async function refreshGitAuthor( request: WrapperSessionReadyRequest, runGit: GitRunner ): Promise { const repo = request.repo; - const urls = repositoryUrls(request); - if (!repo?.refreshRemote || !repo.token || !urls) return; + if (repo?.kind !== 'github' || !repo.gitAuthor) return; - const result = await runGit(['remote', 'set-url', 'origin', urls.authenticated], { + const nameResult = await runGit(['config', 'user.name', repo.gitAuthor.name], { cwd: request.workspace.workspacePath, timeoutMs: SHORT_GIT_COMMAND_TIMEOUT_MS, }); - if (result.exitCode !== 0) { - throw new Error('Failed to update git remote URL'); - } - if (repo.kind === 'github' && repo.gitAuthor) { - const nameResult = await runGit(['config', 'user.name', repo.gitAuthor.name], { - cwd: request.workspace.workspacePath, - timeoutMs: SHORT_GIT_COMMAND_TIMEOUT_MS, - }); - const emailResult = await runGit(['config', 'user.email', repo.gitAuthor.email], { - cwd: request.workspace.workspacePath, - timeoutMs: SHORT_GIT_COMMAND_TIMEOUT_MS, - }); - if (nameResult.exitCode !== 0 || emailResult.exitCode !== 0) { - throw new Error('Failed to configure git author identity'); - } + const emailResult = await runGit(['config', 'user.email', repo.gitAuthor.email], { + cwd: request.workspace.workspacePath, + timeoutMs: SHORT_GIT_COMMAND_TIMEOUT_MS, + }); + if (nameResult.exitCode !== 0 || emailResult.exitCode !== 0) { + throw new Error('Failed to configure git author identity'); } } @@ -1206,9 +1178,8 @@ async function prepareWrapperBootstrapWorkspaceWithinDeadline( logToFile( `bootstrap warm workspace refreshing remote kiloSessionId=${request.kiloSessionId}` ); - if (workspaceNeedsBootstrap || !(await sanitizeBitbucketCodeReviewRemote(request, runGit))) { - await refreshGitRemoteToken(request, runGit); - } + await sanitizeOriginRemote(request, runGit); + await refreshGitAuthor(request, runGit); logToFile(`bootstrap warm workspace remote ready kiloSessionId=${request.kiloSessionId}`); } else { progress?.('cloning', 'Cloning repository...'); @@ -1216,6 +1187,7 @@ async function prepareWrapperBootstrapWorkspaceWithinDeadline( `bootstrap cold workspace cloning repository kiloSessionId=${request.kiloSessionId}` ); cloneTelemetry = await cloneRepository(request, runGit, progress, signal); + await sanitizeOriginRemote(request, runGit); logToFile(`bootstrap cold workspace clone ready kiloSessionId=${request.kiloSessionId}`); } @@ -1228,8 +1200,6 @@ async function prepareWrapperBootstrapWorkspaceWithinDeadline( ); if (restoredFromBackup) { try { - const urls = repositoryUrls(request); - if (urls) await setOriginUrl(request, runGit, urls.authenticated); await reconcileRestoredWorkspace(request, runGit, progress); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -1241,7 +1211,6 @@ async function prepareWrapperBootstrapWorkspaceWithinDeadline( logToFile( `bootstrap branch preparation ready kiloSessionId=${request.kiloSessionId} branchName=${request.workspace.branchName}` ); - await sanitizeBitbucketCodeReviewRemote(request, runGit); await writeRuntimeSkills(request);