From 6265c94f50483960710093000f540d4da9da6645 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:32:26 +0000 Subject: [PATCH 1/3] feat(cloud-agent-next): authenticate git via a credential helper Stop embedding managed GitHub/GitLab/Bitbucket tokens in origin. A get-only helper reads GH_TOKEN / GITLAB_TOKEN / BITBUCKET_TOKEN so setup, submodules, and LFS work, and warm refresh is an env write. --- .../src/session-service.test.ts | 137 ++++++-- .../cloud-agent-next/src/session-service.ts | 111 +++---- .../cloud-agent-next/src/workspace.test.ts | 302 +++++------------- services/cloud-agent-next/src/workspace.ts | 138 ++------ .../wrapper/src/session-bootstrap.test.ts | 167 ++++++++-- .../wrapper/src/session-bootstrap.ts | 137 +++----- 6 files changed, 447 insertions(+), 545 deletions(-) diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index 4d30880733..7bccd4d6e1 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), })); @@ -689,7 +688,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, @@ -769,7 +767,6 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://gitlab.com/acme/repo.git', - 'resolved-gitlab-token', undefined, { platform: 'gitlab' } ); @@ -994,7 +991,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') ); @@ -1011,7 +1007,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', { @@ -1041,9 +1037,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 () => { @@ -1425,7 +1423,6 @@ describe('SessionService.prepareWorkspace', () => { }); expect(workspaceMocks.cloneGitRepo).not.toHaveBeenCalled(); - expect(workspaceMocks.updateGitRemoteToken).not.toHaveBeenCalled(); expect(workspaceMocks.updateGitRemoteUrl).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', @@ -1433,7 +1430,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', { @@ -1462,10 +1459,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 () => { @@ -1507,11 +1506,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' } ); }); @@ -1539,14 +1542,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); @@ -1689,11 +1734,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' } ); }); @@ -1724,13 +1773,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 () => { @@ -1772,13 +1820,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 () => { @@ -1809,11 +1856,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' + ); + expect(workspaceMocks.updateGitAuthor).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', - 'https://github.com/acme/repo.git', - 'kgh2.default' + { name: 'kiloconnect[bot]', email: 'bot@example.com' } ); }); @@ -1860,7 +1911,6 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'acme/repo', - 'resolved-gh-token', { name: 'kiloconnect[bot]', email: 'bot@example.com' }, undefined ); @@ -3406,6 +3456,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 923b7a3502..80f2b2b565 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'; @@ -1555,8 +1554,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 @@ -1819,9 +1822,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'); } @@ -2379,11 +2381,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 @@ -2460,7 +2459,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); @@ -2621,15 +2620,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; @@ -2640,7 +2640,6 @@ export class SessionService { session, workspacePath, github.repo, - tokens.githubToken, tokens.githubGitAuthor, cloneOptions ); @@ -2687,77 +2686,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. - * - * The pinned `credential.helper` (`SYSTEM_GIT_CONFIG_ENV`) does serve `git` - * itself from `GH_TOKEN` / `GITLAB_TOKEN`, but it only rescues a remote whose - * URL lacks a password: when the URL carries both a username and a password, - * git sends that pair and never issues a `get` to the helper (verified against - * git 2.50 — on the 401 it only calls `erase`). A stale embedded pair - * therefore fails the fetch outright, so we still 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 b9cde36504..71ad2be058 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') { @@ -1663,7 +1783,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')); @@ -1712,15 +1832,13 @@ describe('prepareWrapperBootstrapWorkspace', () => { expect(result.restore).toEqual({ path: 'warm' }); expect(progress).toHaveBeenCalledWith('kilo_session', 'Warm workspace reused'); 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'), @@ -1747,12 +1865,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'], ]); }); @@ -1831,7 +1944,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'], ]); @@ -1877,9 +1990,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 55e2be37a2..8171819e3a 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts @@ -314,17 +314,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 { @@ -390,9 +408,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 { @@ -406,10 +424,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); @@ -426,9 +443,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 @@ -436,7 +452,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 => { @@ -634,46 +650,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( @@ -690,33 +672,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'); } } @@ -1218,9 +1190,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...'); @@ -1228,6 +1199,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}`); } @@ -1240,8 +1212,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); @@ -1253,7 +1223,6 @@ async function prepareWrapperBootstrapWorkspaceWithinDeadline( logToFile( `bootstrap branch preparation ready kiloSessionId=${request.kiloSessionId} branchName=${request.workspace.branchName}` ); - await sanitizeBitbucketCodeReviewRemote(request, runGit); await writeRuntimeSkills(request); From 4d95d57076b303abb5ab88d04ff6a0bc22ae980f Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 25 Aug 2026 21:30:42 +0200 Subject: [PATCH 2/3] fix(cloud-agent-next): scope GitHub credentials to bootstrap request --- .../wrapper/src/session-bootstrap.test.ts | 51 +++++++++++++++++++ .../wrapper/src/session-bootstrap.ts | 18 ++++--- 2 files changed, 63 insertions(+), 6 deletions(-) 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 71ad2be058..d97687d2c6 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts @@ -508,6 +508,57 @@ describe('prepareWrapperBootstrapWorkspace', () => { ]); }); + it('uses the current request credential after an earlier request sets GH_TOKEN', async () => { + const initialRequest = makeRequest(path.join(tmpDir, 'initial')); + initialRequest.workspace.preferSnapshot = true; + initialRequest.materialized.setupCommands = []; + initialRequest.materialized.env.GH_TOKEN = 'previous-github-token'; + await createCompleteGitWorkspace(initialRequest.workspace.workspacePath); + + const fallbackRequest = makeRequest(path.join(tmpDir, 'fallback')); + fallbackRequest.materialized.setupCommands = []; + fallbackRequest.repo = { + kind: 'git', + url: 'https://github.com/Kilo-Org/cloud.git', + token: 'current-github-pat', + platform: 'github', + }; + + const gitCalls: string[][] = []; + const deps: WrapperBootstrapDeps = { + git: async args => { + gitCalls.push(args); + if (args[0] === 'clone') { + await fsp.mkdir(path.join(fallbackRequest.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 }, + }), + }; + + await prepareWrapperBootstrapWorkspace(initialRequest, undefined, deps); + expect(process.env.GH_TOKEN).toBe('previous-github-token'); + + gitCalls.length = 0; + await prepareWrapperBootstrapWorkspace(fallbackRequest, undefined, deps); + + const expected = new URL('https://github.com/Kilo-Org/cloud.git'); + expected.username = 'x-access-token'; + expected.password = 'current-github-pat'; + expect(gitCalls.find(args => args[0] === 'clone')).toContain(expected.toString()); + expect(gitCalls.some(args => args[0] === 'remote' && args[1] === 'set-url')).toBe(false); + }); + it('retries a full clone when the server rejects the blobless filter', async () => { const request = makeRequest(tmpDir); request.materialized.env.KILO_PLATFORM = 'code-review'; diff --git a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts index 8171819e3a..0cb52b7a99 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts @@ -326,15 +326,21 @@ function canonicalGitUrl(repo: NonNullable): } } -function isHelperBackedRemote(repo: NonNullable): boolean { +function isHelperBackedRemote( + repo: NonNullable, + env: WrapperSessionReadyRequest['materialized']['env'] +): 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); + return repo.platform === 'github' && Boolean(env.GH_TOKEN); } -function cloneGitUrl(repo: NonNullable): string { +function cloneGitUrl( + repo: NonNullable, + env: WrapperSessionReadyRequest['materialized']['env'] +): string { const canonical = canonicalGitUrl(repo); - if (isHelperBackedRemote(repo) || !repo.token) return canonical; + if (isHelperBackedRemote(repo, env) || !repo.token) return canonical; try { const url = new URL(canonical); url.username = 'x-access-token'; @@ -443,7 +449,7 @@ async function cloneRepository( throw new Error('Session metadata is missing a repository source'); } - const repoUrl = cloneGitUrl(repo); + const repoUrl = cloneGitUrl(repo, request.materialized.env); const platform = repo.kind === 'git' ? repo.platform : 'github'; // 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 @@ -654,7 +660,7 @@ async function sanitizeOriginRemote( request: WrapperSessionReadyRequest, runGit: GitRunner ): Promise { - if (!request.repo || !isHelperBackedRemote(request.repo)) return; + if (!request.repo || !isHelperBackedRemote(request.repo, request.materialized.env)) return; await setOriginUrl(request, runGit, canonicalGitUrl(request.repo)); } From adf1ff5852495c2670c8b4337121d2d954f13fb2 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 26 Aug 2026 20:17:18 +0200 Subject: [PATCH 3/3] fix(cloud-agent-next): refresh repository credentials per session --- .../scripts/kilo-git-credential | 41 ++ .../src/kilo-git-credential.test.ts | 367 +++++++++++++++++- .../wrapper/src/session-bootstrap.test.ts | 339 ++++++++++++++-- .../wrapper/src/session-bootstrap.ts | 74 +++- 4 files changed, 773 insertions(+), 48 deletions(-) diff --git a/services/cloud-agent-next/scripts/kilo-git-credential b/services/cloud-agent-next/scripts/kilo-git-credential index ad37821e32..305b64b722 100755 --- a/services/cloud-agent-next/scripts/kilo-git-credential +++ b/services/cloud-agent-next/scripts/kilo-git-credential @@ -18,6 +18,47 @@ done [ "$protocol" = https ] || exit 0 +session_home="${SESSION_HOME:-${HOME:-}}" +if [ -n "$session_home" ]; then + credential_file="$session_home/.local/share/kilo/cloud-agent/git-credentials" + if [ -f "$credential_file" ]; then + stored_protocol= + stored_host= + stored_username= + stored_password= + stored_host_matches= + stored_malformed= + stored_field_count=0 + + while IFS= read -r line || [ -n "$line" ]; do + stored_field_count=$((stored_field_count + 1)) + case "$line" in + protocol=*) stored_protocol="${line#protocol=}" ;; + host=*) + stored_host="${line#host=}" + if [ "$stored_host" = "$host" ]; then + stored_host_matches=1 + fi + ;; + username=*) stored_username="${line#username=}" ;; + password=*) stored_password="${line#password=}" ;; + *) stored_malformed=1 ;; + esac + done < "$credential_file" + + if [ -n "$stored_host_matches" ]; then + [ "$stored_host" = "$host" ] || exit 0 + [ "$stored_field_count" -eq 4 ] || exit 0 + [ -z "$stored_malformed" ] || exit 0 + [ "$stored_protocol" = "$protocol" ] || exit 0 + [ -n "$stored_username" ] || exit 0 + [ -n "$stored_password" ] || exit 0 + printf 'username=%s\npassword=%s\n' "$stored_username" "$stored_password" + exit 0 + fi + fi +fi + 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 index bedd389147..32a5d6afc7 100644 --- a/services/cloud-agent-next/src/kilo-git-credential.test.ts +++ b/services/cloud-agent-next/src/kilo-git-credential.test.ts @@ -24,6 +24,43 @@ type HelperEnv = { BITBUCKET_TOKEN?: string; }; +type HelperOptions = { + home?: string; + sessionHome?: string; +}; + +type StoredCredential = { + protocol?: string; + host: string; + username?: string; + password?: string; +}; + +function createHelperHome(): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-git-credential-')); + tempDirs.push(home); + return home; +} + +function writeCredentialRecord(home: string, credential: StoredCredential): string { + const credentialFile = path.join( + home, + '.local', + 'share', + 'kilo', + 'cloud-agent', + 'git-credentials' + ); + fs.mkdirSync(path.dirname(credentialFile), { recursive: true }); + fs.writeFileSync( + credentialFile, + `${Object.entries(credential) + .map(([key, value]) => `${key}=${value}`) + .join('\n')}\n` + ); + return credentialFile; +} + function credentialInput(protocol: string, host: string): string { return `protocol=${protocol}\nhost=${host}\n\n`; } @@ -31,16 +68,17 @@ function credentialInput(protocol: string, host: string): string { function runHelper( action: string | undefined, input: string, - env: HelperEnv = {} + env: HelperEnv = {}, + options: HelperOptions = {} ): { status: number | null; stdout: string; home: string } { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-git-credential-')); - tempDirs.push(home); + const home = options.home ?? createHelperHome(); const result = spawnSync('sh', action === undefined ? [scriptPath] : [scriptPath, action], { encoding: 'utf8', input, env: { ...process.env, HOME: home, + SESSION_HOME: options.sessionHome, GH_TOKEN: undefined, GITLAB_TOKEN: undefined, GITLAB_HOST: undefined, @@ -161,6 +199,329 @@ describe('kilo-git-credential', () => { expectPassword(parsed.password, token); }); + it('prefers authoritative GitHub credentials over an overridden GH_TOKEN', () => { + const home = createHelperHome(); + const token = 'kgh2.authoritative'; + writeCredentialRecord(home, { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + password: token, + }); + + const result = runHelper( + 'get', + credentialInput('https', 'github.com'), + { GH_TOKEN: 'kgh2.profile-override' }, + { home } + ); + + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('x-access-token'); + expectPassword(parsed.password, token); + }); + + it('prefers authoritative GitLab credentials over conflicting GitLab host and token overrides', () => { + const home = createHelperHome(); + const host = 'gitlab.repository.example.com:8443'; + const token = 'kgl2.authoritative'; + writeCredentialRecord(home, { + protocol: 'https', + host, + username: 'oauth2', + password: token, + }); + + const result = runHelper( + 'get', + credentialInput('https', host), + { + GITLAB_HOST: 'gitlab.profile.example.com', + GITLAB_TOKEN: 'kgl2.profile-override', + }, + { home } + ); + + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('oauth2'); + expectPassword(parsed.password, token); + }); + + it.each([ + { + host: 'bitbucket.org', + username: 'x-token-auth', + token: 'kbb1.authoritative', + env: { BITBUCKET_TOKEN: 'kbb1.profile-override' }, + }, + { + host: 'git.example.com:8443', + username: 'x-access-token', + token: 'generic.authoritative', + env: {}, + }, + ])('returns authoritative $username credentials for $host', ({ host, username, token, env }) => { + const home = createHelperHome(); + writeCredentialRecord(home, { + protocol: 'https', + host, + username, + password: token, + }); + + const result = runHelper('get', credentialInput('https', host), env, { home }); + + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe(username); + expectPassword(parsed.password, token); + }); + + it('requires the authoritative host to match the requested host and port exactly', () => { + const home = createHelperHome(); + const host = 'gitlab.example.com:8443'; + const token = 'kgl2.authoritative-port'; + writeCredentialRecord(home, { + protocol: 'https', + host, + username: 'oauth2', + password: token, + }); + + const matching = runHelper('get', credentialInput('https', host), {}, { home }); + expect(matching.status).toBe(0); + expectPassword(parseCredential(matching.stdout).password, token); + + const differentPort = runHelper( + 'get', + credentialInput('https', 'gitlab.example.com:9443'), + {}, + { home } + ); + expect(differentPort.status).toBe(0); + expect(differentPort.stdout).toBe(''); + + const withoutPort = runHelper( + 'get', + credentialInput('https', 'gitlab.example.com'), + {}, + { + home, + } + ); + expect(withoutPort.status).toBe(0); + expect(withoutPort.stdout).toBe(''); + }); + + it('retains environment fallback for another host without exposing the authoritative credential', () => { + const home = createHelperHome(); + const repositoryToken = 'kgh2.authoritative'; + const fallbackToken = 'kgl2.environment-fallback'; + writeCredentialRecord(home, { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + password: repositoryToken, + }); + + const result = runHelper( + 'get', + credentialInput('https', 'gitlab.com'), + { GITLAB_TOKEN: fallbackToken }, + { home } + ); + + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('oauth2'); + expectPassword(parsed.password, fallbackToken); + expect(result.stdout).not.toContain(repositoryToken); + }); + + it('prefers SESSION_HOME over HOME when locating authoritative credentials', () => { + const home = createHelperHome(); + const sessionHome = createHelperHome(); + const sessionToken = 'kgh2.session-home'; + writeCredentialRecord(home, { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + password: 'kgh2.home', + }); + writeCredentialRecord(sessionHome, { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + password: sessionToken, + }); + + const result = runHelper( + 'get', + credentialInput('https', 'github.com'), + { GH_TOKEN: 'kgh2.profile-override' }, + { home, sessionHome } + ); + + expect(result.status).toBe(0); + expectPassword(parseCredential(result.stdout).password, sessionToken); + }); + + it('observes rotated authoritative credentials while the inherited token remains stale', () => { + const home = createHelperHome(); + const env = { GH_TOKEN: 'kgh2.stale-inherited' }; + const initialToken = 'kgh2.initial'; + const rotatedToken = 'kgh2.rotated'; + writeCredentialRecord(home, { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + password: initialToken, + }); + + const initial = runHelper('get', credentialInput('https', 'github.com'), env, { home }); + expect(initial.status).toBe(0); + expectPassword(parseCredential(initial.stdout).password, initialToken); + + writeCredentialRecord(home, { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + password: rotatedToken, + }); + + const rotated = runHelper('get', credentialInput('https', 'github.com'), env, { home }); + expect(rotated.status).toBe(0); + expectPassword(parseCredential(rotated.stdout).password, rotatedToken); + }); + + it.each([ + { + description: 'a missing protocol', + credential: { + host: 'github.com', + username: 'x-access-token', + password: 'kgh2.authoritative', + }, + }, + { + description: 'a non-HTTPS protocol', + credential: { + protocol: 'http', + host: 'github.com', + username: 'x-access-token', + password: 'kgh2.authoritative', + }, + }, + { + description: 'a missing username', + credential: { + protocol: 'https', + host: 'github.com', + password: 'kgh2.authoritative', + }, + }, + { + description: 'an empty username', + credential: { + protocol: 'https', + host: 'github.com', + username: '', + password: 'kgh2.authoritative', + }, + }, + { + description: 'a missing password', + credential: { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + }, + }, + { + description: 'an empty password', + credential: { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + password: '', + }, + }, + ])('fails closed for a matching credential record with $description', ({ credential }) => { + const home = createHelperHome(); + writeCredentialRecord(home, credential); + + const result = runHelper( + 'get', + credentialInput('https', 'github.com'), + { GH_TOKEN: 'kgh2.profile-override' }, + { home } + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + it('fails closed for malformed matching credential records', () => { + const home = createHelperHome(); + const credentialFile = writeCredentialRecord(home, { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + password: 'kgh2.authoritative', + }); + fs.appendFileSync(credentialFile, 'unexpected=value\n'); + + const result = runHelper( + 'get', + credentialInput('https', 'github.com'), + { GH_TOKEN: 'kgh2.profile-override' }, + { home } + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + it('parses authoritative token metacharacters literally without executing them', () => { + const home = createHelperHome(); + const marker = path.join(home, 'credential-executed'); + const token = `kgh2.$(touch ${marker});\`touch ${marker}\`;'"\\$&|=*?[]=literal`; + writeCredentialRecord(home, { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + password: token, + }); + + const result = runHelper('get', credentialInput('https', 'github.com'), {}, { home }); + + expect(result.status).toBe(0); + expectPassword(parseCredential(result.stdout).password, token); + expect(fs.existsSync(marker)).toBe(false); + }); + + it('does not return authoritative credentials for HTTP requests', () => { + const home = createHelperHome(); + writeCredentialRecord(home, { + protocol: 'https', + host: 'github.com', + username: 'x-access-token', + password: 'kgh2.authoritative', + }); + + const result = runHelper( + 'get', + credentialInput('http', 'github.com'), + { GH_TOKEN: 'kgh2.profile-override' }, + { home } + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + it('prints nothing for an unmatched host', () => { const result = runHelper('get', credentialInput('https', 'example.com'), { GH_TOKEN: 'kgh2.unused', 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 d97687d2c6..3df046ed69 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts @@ -89,6 +89,10 @@ async function createCompleteGitWorkspace(workspacePath: string): Promise await fsp.writeFile(path.join(gitPath, 'kilo-bootstrap-complete'), 'ready\n'); } +function gitCredentialsPath(sessionHome: string): string { + return path.join(sessionHome, '.local/share/kilo/cloud-agent/git-credentials'); +} + describe('prepareWrapperBootstrapWorkspace', () => { let tmpDir: string; let originalEnv: Record; @@ -99,6 +103,8 @@ describe('prepareWrapperBootstrapWorkspace', () => { HOME: process.env.HOME, KILOCODE_TOKEN: process.env.KILOCODE_TOKEN, GH_TOKEN: process.env.GH_TOKEN, + GITLAB_TOKEN: process.env.GITLAB_TOKEN, + GITLAB_HOST: process.env.GITLAB_HOST, [PNPM_STORE_ENV_VAR]: process.env[PNPM_STORE_ENV_VAR], }; }); @@ -114,8 +120,13 @@ describe('prepareWrapperBootstrapWorkspace', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('prepares a cold workspace, restores Kilo, and runs setup commands', async () => { + it('materializes authoritative credentials before preparing a cold workspace', async () => { const request = makeRequest(tmpDir); + request.materialized.env.GH_TOKEN = 'profile-github-token'; + const credentialsPath = gitCredentialsPath(request.workspace.sessionHome); + const credentialsDirectory = path.dirname(credentialsPath); + await fsp.mkdir(credentialsDirectory, { recursive: true }); + await fsp.chmod(credentialsDirectory, 0o777); const progress = mock(() => {}); const gitCalls: string[][] = []; const setupCalls: string[][] = []; @@ -125,6 +136,11 @@ describe('prepareWrapperBootstrapWorkspace', () => { git: async args => { gitCalls.push(args); if (args[0] === 'clone') { + expect(await fsp.readFile(credentialsPath, 'utf8')).toBe( + 'protocol=https\nhost=github.com\nusername=x-access-token\npassword=gh-token\n' + ); + expect((await fsp.stat(credentialsDirectory)).mode & 0o777).toBe(0o700); + expect((await fsp.stat(credentialsPath)).mode & 0o777).toBe(0o600); await fsp.mkdir(path.join(request.workspace.workspacePath, '.git'), { recursive: true }); } if (args[0] === 'rev-parse') { @@ -184,6 +200,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { ); expect(JSON.parse(authFile)).toEqual({ kilo: { type: 'api', key: 'kilo-capability' } }); expect(authFile).not.toContain('wrapper-dispatch-ticket'); + expect(process.env.GH_TOKEN).toBe('profile-github-token'); }); it('uses a blobless partial clone for GitHub/GitLab code review sessions', async () => { @@ -324,16 +341,21 @@ describe('prepareWrapperBootstrapWorkspace', () => { 'origin', 'https://bitbucket.org/acme/repo.git', ]); + expect(await fsp.readFile(gitCredentialsPath(request.workspace.sessionHome), 'utf8')).toBe( + 'protocol=https\nhost=bitbucket.org\nusername=x-token-auth\npassword=kbb1.opaque-capability\n' + ); }); - it('uses a blobless partial clone for GitLab review sessions', async () => { + it('uses authoritative credentials for blobless GitLab clones on custom hosts', async () => { const request = makeRequest(tmpDir); request.materialized.env.KILO_PLATFORM = 'code-review'; + request.materialized.env.GITLAB_TOKEN = 'profile-gitlab-token'; + request.materialized.env.GITLAB_HOST = 'profile.gitlab.example.com'; request.materialized.setupCommands = []; request.repo = { kind: 'git', - url: 'https://gitlab.com/acme/repo.git', - token: 'gl-token', + url: 'https://gitlab.example.com:8443/acme/repo.git', + token: 'kgl2.opaque-capability', platform: 'gitlab', }; request.workspace.branchName = 'feature/login'; @@ -366,16 +388,19 @@ describe('prepareWrapperBootstrapWorkspace', () => { 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'); + expect(cloneCall).toContain('https://gitlab.example.com:8443/acme/repo.git'); + expect(cloneCall?.join(' ')).not.toContain('kgl2.opaque-capability'); + expect(await fsp.readFile(gitCredentialsPath(request.workspace.sessionHome), 'utf8')).toBe( + 'protocol=https\nhost=gitlab.example.com:8443\nusername=oauth2\npassword=kgl2.opaque-capability\n' + ); + expect(process.env.GITLAB_TOKEN).toBe('profile-gitlab-token'); + expect(process.env.GITLAB_HOST).toBe('profile.gitlab.example.com'); }); it('keeps a full clone for review sessions on an unrecognized git platform', async () => { const request = makeRequest(tmpDir); request.materialized.env.KILO_PLATFORM = 'code-review'; request.materialized.setupCommands = []; - // A `git` source with no recognized platform has no lazy-fetch credential - // guarantee, so it must not use a partial clone. request.repo = { kind: 'git', url: 'https://git.example.com/acme/repo.git', token: 't' }; request.workspace.branchName = 'feature/login'; @@ -407,14 +432,19 @@ describe('prepareWrapperBootstrapWorkspace', () => { 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); + expect(cloneCall).toContain('https://git.example.com/acme/repo.git'); + expect(gitCalls).toContainEqual([ + 'remote', + 'set-url', + 'origin', + 'https://git.example.com/acme/repo.git', + ]); + expect(await fsp.readFile(gitCredentialsPath(request.workspace.sessionHome), 'utf8')).toBe( + 'protocol=https\nhost=git.example.com\nusername=x-access-token\npassword=t\n' + ); }); - it('embeds a leftover PAT when kind:git + platform:github has no GH_TOKEN', async () => { + it('authenticates GitHub-labeled git sources without GH_TOKEN or URL credentials', async () => { const request = makeRequest(tmpDir); request.materialized.setupCommands = []; request.repo = { @@ -452,12 +482,18 @@ describe('prepareWrapperBootstrapWorkspace', () => { } ); - 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); + 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', + ]); + expect(await fsp.readFile(gitCredentialsPath(request.workspace.sessionHome), 'utf8')).toBe( + 'protocol=https\nhost=github.com\nusername=x-access-token\npassword=leftover-github-pat\n' + ); }); it('clones kind:git + platform:github without embedding when GH_TOKEN is present', async () => { @@ -552,11 +588,129 @@ describe('prepareWrapperBootstrapWorkspace', () => { gitCalls.length = 0; await prepareWrapperBootstrapWorkspace(fallbackRequest, undefined, deps); - const expected = new URL('https://github.com/Kilo-Org/cloud.git'); - expected.username = 'x-access-token'; - expected.password = 'current-github-pat'; - expect(gitCalls.find(args => args[0] === 'clone')).toContain(expected.toString()); + expect(gitCalls.find(args => args[0] === 'clone')).toContain( + 'https://github.com/Kilo-Org/cloud.git' + ); + expect(gitCalls).toContainEqual([ + 'remote', + 'set-url', + 'origin', + 'https://github.com/Kilo-Org/cloud.git', + ]); + expect( + await fsp.readFile(gitCredentialsPath(fallbackRequest.workspace.sessionHome), 'utf8') + ).toBe( + 'protocol=https\nhost=github.com\nusername=x-access-token\npassword=current-github-pat\n' + ); + expect(process.env.GH_TOKEN).toBe('previous-github-token'); + }); + + it('preserves anonymous generic HTTPS clones without repository credentials', async () => { + const request = makeRequest(tmpDir); + request.materialized.setupCommands = []; + request.repo = { kind: 'git', url: 'https://git.example.com/acme/public.git' }; + const gitCalls: string[][] = []; + + await prepareWrapperBootstrapWorkspace(request, undefined, { + 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 }, + }), + }); + + expect(gitCalls[0]).toEqual([ + 'clone', + '--progress', + 'https://git.example.com/acme/public.git', + request.workspace.workspacePath, + ]); expect(gitCalls.some(args => args[0] === 'remote' && args[1] === 'set-url')).toBe(false); + expect(fs.existsSync(gitCredentialsPath(request.workspace.sessionHome))).toBe(false); + }); + + it.each([ + ['newline', 'repository-token\npassword=injected'], + ['carriage return', 'repository-token\rinjected'], + ['NUL', 'repository-token\0injected'], + ])('rejects repository credentials containing a %s before Git runs', async (_kind, token) => { + const request = makeRequest(tmpDir); + request.repo = { kind: 'github', repo: 'acme/repo', token }; + const runGit = mock(async () => ({ stdout: '', stderr: '', exitCode: 0 })); + + let readinessError: unknown; + try { + await prepareWrapperBootstrapWorkspace(request, undefined, { git: runGit }); + } catch (error) { + readinessError = error; + } + + expect(readinessError).toMatchObject({ + code: 'WORKSPACE_SETUP_FAILED', + subtype: 'workspace_setup_unknown', + message: 'Workspace setup failed', + }); + expect(runGit).not.toHaveBeenCalled(); + expect(fs.existsSync(request.workspace.workspacePath)).toBe(false); + expect(fs.existsSync(request.workspace.sessionHome)).toBe(false); + }); + + it.each(['http://git.example.com/acme/repo.git', 'ssh://git.example.com/acme/repo.git'])( + 'rejects authenticated non-HTTPS repository URL %s before Git runs', + async url => { + const request = makeRequest(tmpDir); + request.repo = { kind: 'git', url, token: 'repository-token' }; + const runGit = mock(async () => ({ stdout: '', stderr: '', exitCode: 0 })); + + let readinessError: unknown; + try { + await prepareWrapperBootstrapWorkspace(request, undefined, { git: runGit }); + } catch (error) { + readinessError = error; + } + + expect(readinessError).toMatchObject({ + code: 'WORKSPACE_SETUP_FAILED', + subtype: 'workspace_setup_unknown', + }); + expect(runGit).not.toHaveBeenCalled(); + expect(fs.existsSync(request.workspace.sessionHome)).toBe(false); + } + ); + + it('fails warm readiness and removes temporary files when credential replacement fails', async () => { + const request = makeRequest(tmpDir); + request.workspace.preferSnapshot = true; + await createCompleteGitWorkspace(request.workspace.workspacePath); + const credentialsPath = gitCredentialsPath(request.workspace.sessionHome); + await fsp.mkdir(credentialsPath, { recursive: true }); + const runGit = mock(async () => ({ stdout: '', stderr: '', exitCode: 0 })); + + let readinessError: unknown; + try { + await prepareWrapperBootstrapWorkspace(request, undefined, { git: runGit }); + } catch (error) { + readinessError = error; + } + + expect(readinessError).toMatchObject({ + code: 'WORKSPACE_SETUP_FAILED', + subtype: 'workspace_setup_unknown', + }); + expect(runGit).not.toHaveBeenCalled(); + expect(await fsp.readdir(path.dirname(credentialsPath))).toEqual(['git-credentials']); + expect(fs.existsSync(request.workspace.workspacePath)).toBe(true); }); it('retries a full clone when the server rejects the blobless filter', async () => { @@ -840,6 +994,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { }); } if (args[0] === 'fetch') { + expect(fs.existsSync(gitCredentialsPath(request.workspace.sessionHome))).toBe(true); return { stdout: '', stderr: 'exec hard timeout reached', @@ -880,7 +1035,7 @@ describe('prepareWrapperBootstrapWorkspace', () => { try { await prepareWrapperBootstrapWorkspace(request, undefined, { - workspacePreparationTimeoutMs: 20, + workspacePreparationTimeoutMs: 100, git: async (args, opts) => { if (args[0] !== 'clone') { return { stdout: '', stderr: '', exitCode: 0 }; @@ -1889,6 +2044,94 @@ describe('prepareWrapperBootstrapWorkspace', () => { ); }); + it('atomically rotates warm credentials without rerunning runtime bootstrap', async () => { + const request = makeRequest(tmpDir); + request.workspace.preferSnapshot = true; + request.materialized.setupCommands = []; + request.materialized.env.GH_TOKEN = 'profile-github-token'; + const repository = { + kind: 'github', + repo: 'acme/repo', + token: 'initial-repository-token', + } satisfies NonNullable; + request.repo = repository; + await createCompleteGitWorkspace(request.workspace.workspacePath); + const credentialsPath = gitCredentialsPath(request.workspace.sessionHome); + const observedPasswords: string[] = []; + const deps: WrapperBootstrapDeps = { + git: async () => { + const credentials = await fsp.readFile(credentialsPath, 'utf8'); + const password = credentials.split('\n').find(line => line.startsWith('password=')); + if (!password) throw new Error('Missing authoritative repository credential'); + observedPasswords.push(password); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + runProcess: async () => { + throw new Error('setup commands should not run when credentials rotate'); + }, + restoreSession: async () => { + throw new Error('Kilo sessions should not be restored when credentials rotate'); + }, + }; + + const initialResult = await prepareWrapperBootstrapWorkspace(request, undefined, deps); + const previousFile = await fsp.open(credentialsPath, 'r'); + try { + const previousInode = (await previousFile.stat()).ino; + repository.token = 'rotated-repository-token'; + + const rotatedResult = await prepareWrapperBootstrapWorkspace(request, undefined, deps); + + expect(initialResult.restore).toEqual({ path: 'warm' }); + expect(rotatedResult.restore).toEqual({ path: 'warm' }); + expect((await fsp.stat(credentialsPath)).ino).not.toBe(previousInode); + expect(await previousFile.readFile('utf8')).toBe( + 'protocol=https\nhost=github.com\nusername=x-access-token\npassword=initial-repository-token\n' + ); + expect(await fsp.readFile(credentialsPath, 'utf8')).toBe( + 'protocol=https\nhost=github.com\nusername=x-access-token\npassword=rotated-repository-token\n' + ); + expect(observedPasswords).toEqual([ + 'password=initial-repository-token', + 'password=rotated-repository-token', + ]); + expect(process.env.GH_TOKEN).toBe('profile-github-token'); + } finally { + await previousFile.close(); + } + }); + + it('removes stale credentials when a warm generic repository has no token', async () => { + const request = makeRequest(tmpDir); + request.workspace.preferSnapshot = true; + request.materialized.setupCommands = []; + request.repo = { + kind: 'git', + url: 'https://git.example.com/acme/repo.git', + token: 'stale-repository-token', + }; + await createCompleteGitWorkspace(request.workspace.workspacePath); + const gitCalls: string[][] = []; + const deps: WrapperBootstrapDeps = { + git: async args => { + gitCalls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + + await prepareWrapperBootstrapWorkspace(request, undefined, deps); + const credentialsPath = gitCredentialsPath(request.workspace.sessionHome); + expect(fs.existsSync(credentialsPath)).toBe(true); + gitCalls.length = 0; + request.repo = { kind: 'git', url: 'https://git.example.com/acme/repo.git' }; + + const result = await prepareWrapperBootstrapWorkspace(request, undefined, deps); + + expect(result.restore).toEqual({ path: 'warm' }); + expect(fs.existsSync(credentialsPath)).toBe(false); + expect(gitCalls).toEqual([]); + }); + it('strips a warm Bitbucket leftover origin to the canonical URL', async () => { const request = makeRequest(tmpDir, { workspace: { @@ -2054,6 +2297,54 @@ describe('prepareWrapperBootstrapWorkspace', () => { ]); }); + it('reconciles restored generic repositories using credentials without a clone fallback', async () => { + const request = makeRequest(tmpDir); + request.workspace.branchName = 'session/restored'; + request.workspace.preferSnapshot = true; + request.workspace.restoredFromBackup = true; + request.materialized.setupCommands = []; + request.repo = { + kind: 'git', + url: 'https://git.example.com:8443/acme/repo.git', + token: 'restored-generic-token', + }; + await createCompleteGitWorkspace(request.workspace.workspacePath); + const gitCalls: string[][] = []; + + const result = await prepareWrapperBootstrapWorkspace(request, undefined, { + git: async args => { + expect(await fsp.readFile(gitCredentialsPath(request.workspace.sessionHome), 'utf8')).toBe( + 'protocol=https\nhost=git.example.com:8443\nusername=x-access-token\npassword=restored-generic-token\n' + ); + gitCalls.push(args); + if (args[0] === 'ls-remote') { + return { stdout: 'ref: refs/heads/main\tHEAD\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + restoreSession: async () => ({ + ok: true, + downloaded: true, + imported: true, + diffs: { applied: 0, skipped: 0, total: 0 }, + }), + }); + + expect(result.workspaceWasWarm).toBe(true); + expect(result.restoredFromBackup).toBe(true); + expect(result.restore).toEqual({ + path: 'backup', + diffs: { applied: 0, skipped: 0, total: 0 }, + }); + expect(gitCalls).toEqual([ + ['remote', 'set-url', 'origin', 'https://git.example.com:8443/acme/repo.git'], + ['ls-remote', '--symref', 'origin', 'HEAD'], + ['fetch', 'origin', 'main'], + ['checkout', '-B', 'session/restored', 'FETCH_HEAD'], + ]); + expect(gitCalls.flat().join(' ')).not.toContain('restored-generic-token'); + }); + it('keeps restored workspace setup failures as ordinary setup failures', async () => { const request = makeRequest(tmpDir); request.workspace.restoredFromBackup = true; diff --git a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts index 0cb52b7a99..711af46689 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { @@ -332,23 +333,7 @@ function isHelperBackedRemote( ): boolean { if (repo.kind === 'github') return true; if (repo.platform === 'gitlab' || repo.platform === 'bitbucket') return true; - return repo.platform === 'github' && Boolean(env.GH_TOKEN); -} - -function cloneGitUrl( - repo: NonNullable, - env: WrapperSessionReadyRequest['materialized']['env'] -): string { - const canonical = canonicalGitUrl(repo); - if (isHelperBackedRemote(repo, env) || !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; - } + return Boolean(repo.token) || (repo.platform === 'github' && Boolean(env.GH_TOKEN)); } async function exists(filePath: string): Promise { @@ -368,6 +353,55 @@ function sessionAuthFilePath(sessionHome: string): string { return path.join(sessionHome, '.local/share/kilo/auth.json'); } +async function materializeRepositoryCredentials( + request: WrapperSessionReadyRequest +): Promise { + const credentialsPath = path.join( + request.workspace.sessionHome, + '.local/share/kilo/cloud-agent/git-credentials' + ); + const repo = request.repo; + if (!repo?.token) { + await fs.rm(credentialsPath, { force: true }); + return; + } + + const repositoryUrl = repo.kind === 'github' ? new URL('https://github.com') : new URL(repo.url); + if (repositoryUrl.protocol !== 'https:' || !repositoryUrl.host) { + throw new Error('Repository Git credentials require an HTTPS URL'); + } + + const username = + repo.kind === 'git' && repo.platform === 'gitlab' + ? 'oauth2' + : repo.kind === 'git' && repo.platform === 'bitbucket' + ? 'x-token-auth' + : 'x-access-token'; + const values = ['https', repositoryUrl.host, username, repo.token]; + if (values.some(value => value.includes('\r') || value.includes('\n') || value.includes('\0'))) { + throw new Error('Repository Git credentials contain invalid characters'); + } + + const credentials = `protocol=https\nhost=${repositoryUrl.host}\nusername=${username}\npassword=${repo.token}\n`; + const credentialsDirectory = path.dirname(credentialsPath); + await fs.mkdir(credentialsDirectory, { recursive: true, mode: 0o700 }); + await fs.chmod(credentialsDirectory, 0o700); + const temporaryPath = path.join(credentialsDirectory, `git-credentials.${randomUUID()}.tmp`); + + try { + const handle = await fs.open(temporaryPath, 'wx', 0o600); + try { + await handle.writeFile(credentials, 'utf8'); + } finally { + await handle.close(); + } + await fs.rename(temporaryPath, credentialsPath); + } catch (error) { + await fs.rm(temporaryPath, { force: true }).catch(() => {}); + throw error; + } +} + // The marker is removed before re-bootstrapping and written only after restore // and setup commands finish, so its presence is the sole evidence that a // workspace completed bootstrap. Anything else (a bare .git, auth.json) can be @@ -430,9 +464,6 @@ function hasBitbucketReviewCapability(request: WrapperSessionReadyRequest): bool function isBloblessReviewCloneEligible(request: WrapperSessionReadyRequest): boolean { if (!isCodeReviewSession(request)) return false; const repo = request.repo; - // 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); @@ -449,7 +480,7 @@ async function cloneRepository( throw new Error('Session metadata is missing a repository source'); } - const repoUrl = cloneGitUrl(repo, request.materialized.env); + const repoUrl = canonicalGitUrl(repo); const platform = repo.kind === 'git' ? repo.platform : 'github'; // 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 @@ -1184,6 +1215,7 @@ async function prepareWrapperBootstrapWorkspaceWithinDeadline( } await ensureWorkspaceDirectories(request); + await materializeRepositoryCredentials(request); signal.throwIfAborted(); if (workspaceNeedsBootstrap) {