diff --git a/services/cloud-agent-next/src/execution/types.ts b/services/cloud-agent-next/src/execution/types.ts index de490346b5..69ea58c58c 100644 --- a/services/cloud-agent-next/src/execution/types.ts +++ b/services/cloud-agent-next/src/execution/types.ts @@ -196,10 +196,16 @@ export type QueueExecutionTurnCommand = { finalization?: TurnFinalization; }; +/** Transient repository credential mutation applied during message admission. */ +export type RepositoryCredentialUpdate = { + genericGitToken: string; +}; + /** Current-path submitted message before durable admission resolves identity/defaults. */ export type SubmittedSessionMessageRequest = { userId: UserId; botId?: string; + repositoryCredentialUpdate?: RepositoryCredentialUpdate; } & QueueExecutionTurnCommand; /** Already-canonical current message intent admitted without recreating its turn identity. */ diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index aed7e68e8a..b7835cb35b 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -72,6 +72,7 @@ import type { MessageDeliveryRequest, AdmitAcceptedSessionMessageRequest, LegacyRegisteredInitialAdmissionRequest, + RepositoryCredentialUpdate, MessageDeliveryResult, SessionMessageAdmissionResult, SubmittedSessionMessageRequest, @@ -155,6 +156,8 @@ const EVENT_RETENTION_MS = Limits.SESSION_TTL_MS; /** Storage key for tracking last activity timestamp */ const LAST_ACTIVITY_KEY = 'last_activity'; +const REPOSITORY_CREDENTIAL_MESSAGE_ID_KEY = 'repository_credential_message_id'; +const REPOSITORY_CREDENTIAL_UPDATE_PREFIX = 'repository_credential_update:'; const EXPLICIT_DELETION_PENDING_KEY = 'explicit_deletion_pending'; /** Kilo server idle timeout: 15 minutes */ @@ -597,6 +600,10 @@ export class CloudAgentSession extends DurableObject { this.sessionMessageQueue = createSessionMessageQueue({ storage: this.ctx.storage, getMetadata: () => this.getMetadata(), + applyRepositoryCredentialUpdate: (messageId, update, replay) => + this.applyRepositoryCredentialUpdate(messageId, update, replay), + finalizeRepositoryCredentialUpdate: messageId => + this.finalizeRepositoryCredentialUpdate(messageId), requireSessionId: () => this.requireSessionId(), validateModeAgainstRuntimeAgents, getDeliveryContext: () => this.getPendingMessageDeliveryContext(), @@ -1118,6 +1125,55 @@ export class CloudAgentSession extends DurableObject { await this.updateLastActivity(); } + private async applyRepositoryCredentialUpdate( + messageId: string, + update: RepositoryCredentialUpdate, + replay: boolean + ): Promise { + const credentialUpdateKey = `${REPOSITORY_CREDENTIAL_UPDATE_PREFIX}${messageId}`; + const stored = await this.ctx.storage.get([ + 'metadata', + REPOSITORY_CREDENTIAL_MESSAGE_ID_KEY, + credentialUpdateKey, + ]); + const storedMetadata = stored.get('metadata'); + const metadata = storedMetadata ? parseSessionMetadata(storedMetadata) : null; + if (metadata?.repository?.type !== 'git') return; + + const currentCredentialMessageId = stored.get(REPOSITORY_CREDENTIAL_MESSAGE_ID_KEY); + const credentialUpdateWasApplied = stored.get(credentialUpdateKey) === true; + if ( + currentCredentialMessageId !== undefined && + currentCredentialMessageId !== messageId && + (replay || credentialUpdateWasApplied) + ) { + return; + } + + const now = Date.now(); + const serialized = serializeSessionMetadata({ + ...metadata, + repository: { + ...metadata.repository, + token: update.genericGitToken, + }, + lifecycle: { + ...metadata.lifecycle, + version: now, + }, + }); + await this.ctx.storage.put({ + metadata: serialized, + [REPOSITORY_CREDENTIAL_MESSAGE_ID_KEY]: messageId, + [credentialUpdateKey]: true, + [LAST_ACTIVITY_KEY]: now, + }); + } + + private async finalizeRepositoryCredentialUpdate(messageId: string): Promise { + await this.ctx.storage.delete(`${REPOSITORY_CREDENTIAL_UPDATE_PREFIX}${messageId}`); + } + /** * Mark this session as interrupted. * Used to signal streaming generators to stop when interruptSession is called. diff --git a/services/cloud-agent-next/src/router.test.ts b/services/cloud-agent-next/src/router.test.ts index 8f5b6cb04f..9ade906fca 100644 --- a/services/cloud-agent-next/src/router.test.ts +++ b/services/cloud-agent-next/src/router.test.ts @@ -1975,7 +1975,7 @@ describe('legacy V2 execution response compatibility', () => { ); }); - it('sendMessageV2 accepts deprecated token fields without queueing token overrides', async () => { + it('sendMessageV2 forwards only the generic git credential update', async () => { const { caller, admitSubmittedMessage } = createLegacyExecutionCaller(); await caller.sendMessageV2({ @@ -1984,7 +1984,7 @@ describe('legacy V2 execution response compatibility', () => { mode: 'code', model: 'test-model', githubToken: 'deprecated-github-token', - gitToken: 'deprecated-git-token', + gitToken: 'fresh-generic-git-token', }); const request = admitSubmittedMessage.mock.calls[0]?.[0]; @@ -1995,10 +1995,30 @@ describe('legacy V2 execution response compatibility', () => { prompt: 'follow up', attachments: undefined, }, + repositoryCredentialUpdate: { + genericGitToken: 'fresh-generic-git-token', + }, }); + expect(request).not.toHaveProperty('githubToken'); expect(request).not.toHaveProperty('tokenOverrides'); }); + it('sendMessageV2 ignores an empty generic git credential update', async () => { + const { caller, admitSubmittedMessage } = createLegacyExecutionCaller(); + + await caller.sendMessageV2({ + cloudAgentSessionId: validSessionId, + prompt: 'follow up', + mode: 'code', + model: 'test-model', + gitToken: ' ', + }); + + expect(admitSubmittedMessage.mock.calls[0]?.[0]).not.toHaveProperty( + 'repositoryCredentialUpdate' + ); + }); + it('sendMessageV2 queues structured commands without flattening them into prompt text', async () => { const { caller, admitSubmittedMessage } = createLegacyExecutionCaller(); diff --git a/services/cloud-agent-next/src/router/handlers/session-execution.ts b/services/cloud-agent-next/src/router/handlers/session-execution.ts index ac684d57b9..2a2221aedd 100644 --- a/services/cloud-agent-next/src/router/handlers/session-execution.ts +++ b/services/cloud-agent-next/src/router/handlers/session-execution.ts @@ -126,6 +126,10 @@ export function createSessionExecutionV2Handlers() { autoCommit: input.autoCommit, condenseOnComplete: input.condenseOnComplete, } satisfies TurnFinalization, + repositoryCredentialUpdate: + input.gitToken !== undefined && input.gitToken.trim().length > 0 + ? { genericGitToken: input.gitToken } + : undefined, }; const admissionContext = { env: ctx.env, userId: ctx.userId, botId: ctx.botId }; const ack = diff --git a/services/cloud-agent-next/src/router/schemas.ts b/services/cloud-agent-next/src/router/schemas.ts index b82632e582..61dc11bb9e 100644 --- a/services/cloud-agent-next/src/router/schemas.ts +++ b/services/cloud-agent-next/src/router/schemas.ts @@ -240,7 +240,7 @@ const SendMessageV2Options = z.object({ .string() .optional() .describe( - 'Deprecated compatibility field. Accepted for older clients but ignored; provider credentials are managed by the server.' + 'Compatibility field whose non-empty values rotate credentials for generic git repositories. Managed provider credentials remain server-resolved.' ), ...AttachmentFieldsSchema, messageId: MessageIdSchema.nullish().describe('Optional message ID for correlating the request'), diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index 751e2e9326..78d6ef1211 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -677,12 +677,12 @@ describe('SessionService.prepareWorkspace', () => { ); }); - it('uses stored generic git tokens without managed provider lookup', async () => { + it('uses the refreshed stored generic git token for a cold clone', async () => { const session = createSession(false); const sandbox = createSandbox(session); const metadata = createMetadata({ gitUrl: 'https://git.example.com/acme/repo.git', - gitToken: 'generic-git-token', + gitToken: 'fresh-generic-git-token', platform: undefined, gitlabTokenManaged: undefined, }); @@ -701,7 +701,7 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://git.example.com/acme/repo.git', - 'generic-git-token', + 'fresh-generic-git-token', undefined, { platform: undefined } ); @@ -709,6 +709,42 @@ describe('SessionService.prepareWorkspace', () => { expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); }); + it('refreshes a warm generic git remote with the stored token', async () => { + const session = createSession(true); + const sandbox = createSandbox(session, true); + const metadata = createMetadata({ + gitUrl: 'https://git.example.com/acme/repo.git', + gitToken: 'fresh-generic-git-token', + platform: undefined, + gitlabTokenManaged: undefined, + workspacePath: '/workspace/user/sessions/agent_test', + sessionHome: '/home/agent_test', + branchName: 'session/agent_test', + sandboxId: 'usr-abcdef', + }); + + await new SessionService().prepareWorkspace({ + sandbox, + sandboxId: 'usr-abcdef', + userId: 'user_test', + sessionId: 'agent_test' as SessionId, + env: createEnv(), + metadata, + kilocodeModel: 'test-model', + }); + + expect(workspaceMocks.cloneGitRepo).not.toHaveBeenCalled(); + expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( + session, + '/workspace/user/sessions/agent_test', + 'https://git.example.com/acme/repo.git', + 'fresh-generic-git-token', + undefined + ); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); + }); + it('restores persisted devcontainer runtime metadata on the warm fast path', async () => { const session = createSession(true); const sandbox = createSandbox(session, true); @@ -1444,6 +1480,26 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('explicit-profile-token'); }); + it('requests warm remote refresh for generic git credentials', async () => { + const result = await buildPromptWrapperRequests( + createMetadata({ + gitUrl: 'https://git.example.com/acme/repo.git', + gitToken: 'fresh-generic-git-token', + platform: undefined, + gitlabTokenManaged: undefined, + }) + ); + + expect(result.readyRequest.repo).toMatchObject({ + kind: 'git', + url: 'https://git.example.com/acme/repo.git', + token: 'fresh-generic-git-token', + refreshRemote: true, + }); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); + }); + it('materializes OAuth bearer mode with a self-managed GitLab host', 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 837d7547e4..0b914330ec 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -1592,7 +1592,9 @@ export class SessionService { ...(repositoryShallow(metadata) !== undefined ? { shallow: repositoryShallow(metadata) } : {}), - refreshRemote: tokens.gitlabTokenManaged === true, + refreshRemote: + tokens.gitToken !== undefined && + (git.type === 'git' || tokens.gitlabTokenManaged === true), }; } @@ -1988,12 +1990,12 @@ export class SessionService { * Refresh the embedded credentials in the workspace's git remote URL on the * warm fast path. * - * GitHub App installation tokens expire after ~1h, and server-resolved GitLab - * credentials can rotate independently of a warm workspace. The URL-embedded - * credentials from the original clone go stale quickly. `GH_TOKEN` / - * `GITLAB_TOKEN` env vars don't rescue `git` itself (they only affect the - * provider CLIs / GitLab HTTP integrations), so we rewrite `origin` whenever - * the token is resolved by us. + * GitHub App installation tokens, server-resolved GitLab credentials, and + * caller-managed generic git credentials can all rotate independently of a + * warm workspace. The URL-embedded credentials from the original clone go + * stale quickly. `GH_TOKEN` / `GITLAB_TOKEN` env vars don't rescue `git` + * itself (they only affect the provider CLIs / GitLab HTTP integrations), so + * we rewrite `origin` whenever current credentials are available. */ private async refreshGitRemoteToken( session: ExecutionSession, @@ -2018,7 +2020,10 @@ export class SessionService { const git = gitRepository(metadata); if (git) { - if (tokens.gitToken !== undefined && tokens.gitlabTokenManaged === true) { + if ( + tokens.gitToken !== undefined && + (git.type === 'git' || tokens.gitlabTokenManaged === true) + ) { await updateGitRemoteToken( session, context.workspacePath, diff --git a/services/cloud-agent-next/src/session/queue-message.ts b/services/cloud-agent-next/src/session/queue-message.ts index 2a3acbe955..25c31ca22a 100644 --- a/services/cloud-agent-next/src/session/queue-message.ts +++ b/services/cloud-agent-next/src/session/queue-message.ts @@ -10,6 +10,7 @@ import { TRPCError } from '@trpc/server'; import type { QueueExecutionTurnCommand, + RepositoryCredentialUpdate, SessionMessageAdmissionResult, SubmittedSessionMessageRequest, RetryableResultCode, @@ -70,6 +71,7 @@ export function throwAdmissionError( export type QueueMessageInput = { cloudAgentSessionId: string; + repositoryCredentialUpdate?: RepositoryCredentialUpdate; } & QueueExecutionTurnCommand; export type QueueMessageContext = { @@ -153,6 +155,9 @@ export async function queueMessage( }, agent: input.agent, finalization: input.finalization, + ...(input.repositoryCredentialUpdate + ? { repositoryCredentialUpdate: input.repositoryCredentialUpdate } + : {}), }; const result = await withDORetry< diff --git a/services/cloud-agent-next/src/session/session-message-queue.ts b/services/cloud-agent-next/src/session/session-message-queue.ts index 48d652fce1..7214423d02 100644 --- a/services/cloud-agent-next/src/session/session-message-queue.ts +++ b/services/cloud-agent-next/src/session/session-message-queue.ts @@ -4,6 +4,7 @@ import type { AdmitAcceptedSessionMessageRequest, MessageDeliveryRequest, MessageDeliveryResult, + RepositoryCredentialUpdate, RetryableResultCode, SessionMessageAdmissionResult, SessionMessageIntent, @@ -131,6 +132,12 @@ export type SessionMessageQueue = { export type SessionMessageQueueDependencies = { storage: SessionMessageQueueStorage; getMetadata: () => Promise; + applyRepositoryCredentialUpdate?: ( + messageId: string, + update: RepositoryCredentialUpdate, + replay: boolean + ) => Promise; + finalizeRepositoryCredentialUpdate?: (messageId: string) => Promise; requireSessionId: () => Promise; validateModeAgainstRuntimeAgents: (metadata: SessionMetadata, mode: string) => string | null; getDeliveryContext: () => Promise; @@ -490,6 +497,8 @@ export function createSessionMessageQueue( const { storage, getMetadata, + applyRepositoryCredentialUpdate, + finalizeRepositoryCredentialUpdate, requireSessionId, validateModeAgainstRuntimeAgents, getDeliveryContext, @@ -581,7 +590,8 @@ export function createSessionMessageQueue( async function getExistingAdmissionAckForMessageId( messageId: string, - requestedIntent?: SessionMessageIntent + requestedIntent?: SessionMessageIntent, + beforeSuccessfulReplay?: () => Promise ): Promise { const pendingMessage = await getQueuedMessageByMessageId(storage, messageId); const metadata = pendingMessage ? await getMetadata() : undefined; @@ -623,16 +633,19 @@ export function createSessionMessageQueue( if (messageState) { if (messageState.status === 'queued') { + await beforeSuccessfulReplay?.(); const repairIntent = persistedIntent ?? requestedIntent; if (repairIntent) await completeQueuedAdmissionEffects(repairIntent); return buildAdmissionAck(messageId); } if (messageState.status === 'accepted') { + await beforeSuccessfulReplay?.(); return buildAdmissionAck(messageId, 'sent'); } } if (pendingMessage) { + await beforeSuccessfulReplay?.(); await repairMissingQueuedStateFromPendingMessage(pendingMessage); const repairIntent = persistedIntent ?? requestedIntent; if (repairIntent) await completeQueuedAdmissionEffects(repairIntent); @@ -706,9 +719,24 @@ export function createSessionMessageQueue( await putSessionMessageState(storage, repairedState); } - async function admitIntent(intent: SessionMessageIntent): Promise { + async function admitIntent( + intent: SessionMessageIntent, + beforeAdmissionEffects?: (replay: boolean) => Promise, + afterAdmissionPersistence?: () => Promise + ): Promise { const { turn } = intent; - const idempotentResult = await getExistingAdmissionAckForMessageId(turn.messageId, intent); + const beforeSuccessfulReplay = + beforeAdmissionEffects || afterAdmissionPersistence + ? async () => { + await beforeAdmissionEffects?.(true); + await afterAdmissionPersistence?.(); + } + : undefined; + const idempotentResult = await getExistingAdmissionAckForMessageId( + turn.messageId, + intent, + beforeSuccessfulReplay + ); if (idempotentResult) return idempotentResult; const capacityError = await checkPendingQueueCapacity(); @@ -720,9 +748,12 @@ export function createSessionMessageQueue( ? { required: true, target: callbackTarget } : undefined; + // Rotate session credentials before pending work can become visible to an existing drain alarm. + await beforeAdmissionEffects?.(false); await enqueuePendingSessionMessageIntent(storage, intent, Date.now(), callbackSnapshot); const messageState = createQueuedSessionMessageState(intent, callbackSnapshot); await putSessionMessageState(storage, messageState); + await afterAdmissionPersistence?.(); await completeQueuedAdmissionEffects(intent); return buildAdmissionAck(turn.messageId); } @@ -839,11 +870,17 @@ export function createSessionMessageQueue( requestedFinalization?.condenseOnComplete ?? metadata.finalization?.condenseOnComplete, }, }; - const existingAdmission = await getExistingAdmissionAckForMessageId(messageId, intent); - if (existingAdmission) return existingAdmission; - const capacityError = await checkPendingQueueCapacity(); - if (capacityError) return capacityError; - return await admitIntent(intent); + const credentialUpdate = request.repositoryCredentialUpdate; + const applyCredentialUpdate = + credentialUpdate && applyRepositoryCredentialUpdate + ? (replay: boolean) => + applyRepositoryCredentialUpdate(messageId, credentialUpdate, replay) + : undefined; + const finalizeCredentialUpdate = + credentialUpdate && finalizeRepositoryCredentialUpdate + ? () => finalizeRepositoryCredentialUpdate(messageId) + : undefined; + return await admitIntent(intent, applyCredentialUpdate, finalizeCredentialUpdate); } catch (error) { if (isExecutionError(error)) { if (error.retryable) { diff --git a/services/cloud-agent-next/src/workspace.test.ts b/services/cloud-agent-next/src/workspace.test.ts index d7d06dec0f..363f96af1c 100644 --- a/services/cloud-agent-next/src/workspace.test.ts +++ b/services/cloud-agent-next/src/workspace.test.ts @@ -42,6 +42,7 @@ import { LOW_DISK_THRESHOLD_MB, STALE_DIR_MIN_AGE_SECONDS, } from './workspace'; +import { shellQuote } from './kilo/utils.js'; import { SandboxCapacityInspectionError, WorkspaceCapacityAdmissionRejectedError, @@ -783,6 +784,22 @@ describe('disk space checking', () => { expect.any(Object) ); }); + + it('shell-quotes caller-controlled generic git tokens', async () => { + mockExec.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + const workspacePath = "/workspace/user's-repo"; + const token = "'$(touch /tmp/pwn)'"; + const authenticatedUrl = new URL('https://example.com/repo.git'); + authenticatedUrl.username = 'x-access-token'; + authenticatedUrl.password = token; + + await updateGitRemoteToken(fakeSession, workspacePath, 'https://example.com/repo.git', token); + + expect(mockExec).toHaveBeenCalledWith( + `cd ${shellQuote(workspacePath)} && git remote set-url origin ${shellQuote(authenticatedUrl.toString())}`, + expect.any(Object) + ); + }); }); describe('LOW_DISK_THRESHOLD_MB export', () => { diff --git a/services/cloud-agent-next/src/workspace.ts b/services/cloud-agent-next/src/workspace.ts index 4a8d34b8dd..67d89d798f 100644 --- a/services/cloud-agent-next/src/workspace.ts +++ b/services/cloud-agent-next/src/workspace.ts @@ -882,7 +882,7 @@ export async function updateGitRemoteToken( const result = await timedExec( session, - `cd '${workspacePath}' && git remote set-url origin '${newUrl.toString()}'`, + `cd ${shellQuote(workspacePath)} && git remote set-url origin ${shellQuote(newUrl.toString())}`, 'git.updateRemoteToken' ); diff --git a/services/cloud-agent-next/test/integration/session/start-execution-v2.test.ts b/services/cloud-agent-next/test/integration/session/start-execution-v2.test.ts index 8d57a8d865..afbb36fecd 100644 --- a/services/cloud-agent-next/test/integration/session/start-execution-v2.test.ts +++ b/services/cloud-agent-next/test/integration/session/start-execution-v2.test.ts @@ -397,16 +397,34 @@ describe('CloudAgentSession message admission', () => { gitToken: 'old-token', }); - const request = queueUserMessageInput({ - userId, - prompt: 'followup prompt', - messageId: 'msg_018f1e2d3c4bAbCdEfGhIjKlMn', - }); + const request = { + ...queueUserMessageInput({ + userId, + prompt: 'followup prompt', + messageId: 'msg_018f1e2d3c4bAbCdEfGhIjKlMn', + }), + repositoryCredentialUpdate: { + genericGitToken: 'fresh-token', + }, + }; const startResult = await instance.admitSubmittedMessage(request); const metadata = await instance.getMetadata(); + const credentialMarker = await instance.ctx.storage.get( + 'repository_credential_update:msg_018f1e2d3c4bAbCdEfGhIjKlMn' + ); + const credentialMessageId = await instance.ctx.storage.get( + 'repository_credential_message_id' + ); const pending = await listPendingSessionMessages(instance.ctx.storage); - return { startResult, metadata, plan: capturedPlan, pending }; + return { + startResult, + metadata, + credentialMarker, + credentialMessageId, + plan: capturedPlan, + pending, + }; }); expect(result.startResult.success).toBe(true); @@ -414,7 +432,10 @@ describe('CloudAgentSession message admission', () => { expect(result.startResult.messageId).toBe('msg_018f1e2d3c4bAbCdEfGhIjKlMn'); expect(result.startResult.outcome).toBe('queued'); - expect(result.metadata?.repository?.token).toBe('old-token'); + expect(result.metadata?.repository?.token).toBe('fresh-token'); + expect(result.metadata?.repository).not.toHaveProperty('credentialMessageId'); + expect(result.credentialMarker).toBeUndefined(); + expect(result.credentialMessageId).toBe('msg_018f1e2d3c4bAbCdEfGhIjKlMn'); expect(result.plan).toBeNull(); expect(result.pending).toHaveLength(1); expect(result.pending[0]?.messageId).toBe('msg_018f1e2d3c4bAbCdEfGhIjKlMn'); @@ -422,6 +443,64 @@ describe('CloudAgentSession message admission', () => { expect(result.pending[0]?.executionId).toBe(result.startResult.executionId); }); + it.each([ + { + provider: 'GitHub', + userId: 'user_exec_github_credentials', + sessionId: 'agent_exec_github_credentials', + githubRepo: 'acme/repo', + githubToken: 'stored-github-token', + gitUrl: undefined, + platform: 'github' as const, + }, + { + provider: 'managed GitLab', + userId: 'user_exec_gitlab_credentials', + sessionId: 'agent_exec_gitlab_credentials', + githubRepo: undefined, + githubToken: undefined, + gitUrl: 'https://gitlab.com/acme/repo.git', + platform: 'gitlab' as const, + }, + ])('ignores generic credential updates for $provider repositories', async fixture => { + const doId = env.CLOUD_AGENT_SESSION.idFromName(`${fixture.userId}:${fixture.sessionId}`); + const stub = env.CLOUD_AGENT_SESSION.get(doId); + + const result = await runInDurableObject(stub, async instance => { + await registerReadySession(instance, { + sessionId: fixture.sessionId, + userId: fixture.userId, + prompt: 'prepared prompt', + mode: 'code', + model: 'test-model', + kilocodeToken: 'token-followup', + githubRepo: fixture.githubRepo, + githubToken: fixture.githubToken, + gitUrl: fixture.gitUrl, + platform: fixture.platform, + }); + const before = await instance.getMetadata(); + const admission = await instance.admitSubmittedMessage({ + ...queueUserMessageInput({ + userId: fixture.userId, + prompt: 'followup prompt', + messageId: + fixture.platform === 'github' + ? 'msg_018f1e2d3c4bAbCdEfGhIjKlM1' + : 'msg_018f1e2d3c4bAbCdEfGhIjKlM2', + }), + repositoryCredentialUpdate: { + genericGitToken: 'caller-generic-token', + }, + }); + const after = await instance.getMetadata(); + return { admission, before, after }; + }); + + expect(result.admission.success).toBe(true); + expect(result.after?.repository).toEqual(result.before?.repository); + }); + it('flushes queued follow-up using the originally queued execution options', async () => { const userId = 'user_exec_followup_options' as const; const sessionId = 'agent_exec_followup_options' as const; @@ -928,26 +1007,125 @@ describe('CloudAgentSession message admission', () => { }) ); await instance.alarm(); - const retryResult = await instance.admitSubmittedMessage( - queueUserMessageInput({ + const retryResult = await instance.admitSubmittedMessage({ + ...queueUserMessageInput({ userId, prompt: 'accept once', messageId: 'msg_018f1e2d3c4bActRetAbCdEfGh', - }) - ); + }), + repositoryCredentialUpdate: { + genericGitToken: 'retry-token', + }, + }); + const metadata = await instance.getMetadata(); const pending = await listPendingSessionMessages(instance.ctx.storage); - return { retryResult, pending, callCount }; + return { retryResult, metadata, pending, callCount }; }); expect(result.retryResult.success).toBe(true); if (!result.retryResult.success) return; expect(result.retryResult.outcome).toBe('queued'); expect(result.retryResult.compatibilityDelivery).toBe('sent'); + expect(result.metadata?.repository?.token).toBe('retry-token'); expect(result.pending).toHaveLength(0); expect(result.callCount).toBe(1); }); - it('does not persist token overrides when model validation fails', async () => { + it('does not let an older credential retry without queue state roll back a newer token', async () => { + const userId = 'user_exec_credential_order' as const; + const sessionId = 'agent_exec_credential_order' as const; + const doId = env.CLOUD_AGENT_SESSION.idFromName(`${userId}:${sessionId}`); + const stub = env.CLOUD_AGENT_SESSION.get(doId); + + const result = await runInDurableObject(stub, async instance => { + await registerReadySession(instance, { + sessionId, + userId, + prompt: 'prepared prompt', + mode: 'code', + model: 'test-model', + kilocodeToken: 'token-followup', + gitUrl: 'https://example.com/repo.git', + gitToken: 'old-token', + }); + const firstMessageId = 'msg_018f1e2d3c4bAbCdEfGhIjKlN1'; + const firstRequest = { + ...queueUserMessageInput({ + userId, + prompt: 'first followup', + messageId: firstMessageId, + }), + repositoryCredentialUpdate: { + genericGitToken: 'first-token', + }, + }; + await (instance as any).applyRepositoryCredentialUpdate( + firstMessageId, + firstRequest.repositoryCredentialUpdate + ); + await instance.admitSubmittedMessage({ + ...queueUserMessageInput({ + userId, + prompt: 'second followup', + messageId: 'msg_018f1e2d3c4bAbCdEfGhIjKlN2', + }), + repositoryCredentialUpdate: { + genericGitToken: 'second-token', + }, + }); + const replay = await instance.admitSubmittedMessage({ + ...firstRequest, + repositoryCredentialUpdate: { + genericGitToken: 'replayed-first-token', + }, + }); + const metadata = await instance.getMetadata(); + return { replay, metadata }; + }); + + expect(result.replay.success).toBe(true); + expect(result.metadata?.repository?.token).toBe('second-token'); + }); + + it('does not rotate generic credentials for a mismatched message replay', async () => { + const userId = 'user_exec_credential_mismatch' as const; + const sessionId = 'agent_exec_credential_mismatch' as const; + const doId = env.CLOUD_AGENT_SESSION.idFromName(`${userId}:${sessionId}`); + const stub = env.CLOUD_AGENT_SESSION.get(doId); + + const result = await runInDurableObject(stub, async instance => { + await registerReadySession(instance, { + sessionId, + userId, + prompt: 'prepared prompt', + mode: 'code', + model: 'test-model', + kilocodeToken: 'token-followup', + gitUrl: 'https://example.com/repo.git', + gitToken: 'old-token', + }); + const messageId = 'msg_018f1e2d3c4bAbCdEfGhIjKlN3'; + await instance.admitSubmittedMessage({ + ...queueUserMessageInput({ userId, prompt: 'original', messageId }), + repositoryCredentialUpdate: { + genericGitToken: 'accepted-token', + }, + }); + const replay = await instance.admitSubmittedMessage({ + ...queueUserMessageInput({ userId, prompt: 'changed', messageId }), + repositoryCredentialUpdate: { + genericGitToken: 'rejected-token', + }, + }); + const metadata = await instance.getMetadata(); + return { replay, metadata }; + }); + + expect(result.replay).toMatchObject({ success: false, code: 'BAD_REQUEST' }); + expect(result.metadata?.repository?.token).toBe('accepted-token'); + }); + + it('does not rotate generic credentials when model validation fails', async () => { const userId = 'user_exec_invalid_model' as const; const sessionId = 'agent_exec_invalid_model' as const; const doId = env.CLOUD_AGENT_SESSION.idFromName(`${userId}:${sessionId}`); @@ -966,14 +1144,17 @@ describe('CloudAgentSession message admission', () => { gitToken: 'old-token', }); - const startResult = await instance.admitSubmittedMessage( - queueUserMessageInput({ + const startResult = await instance.admitSubmittedMessage({ + ...queueUserMessageInput({ userId, prompt: 'bad model', model: '', messageId: 'msg_018f1e2d3c4bInvModAbCdEfGh', - }) - ); + }), + repositoryCredentialUpdate: { + genericGitToken: 'rejected-token', + }, + }); const metadata = await instance.getMetadata(); const pending = await listPendingSessionMessages(instance.ctx.storage); return { startResult, metadata, pending }; 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 4095bd39b3..1b7ccc2551 100644 --- a/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts +++ b/services/cloud-agent-next/wrapper/src/session-bootstrap.test.ts @@ -362,6 +362,47 @@ describe('prepareWrapperBootstrapWorkspace', () => { ); }); + it('refreshes a warm generic git remote with the current credential', async () => { + const repositoryUrl = 'https://git.example.com/acme/repo.git'; + const token = 'fresh-generic-token'; + const request = makeRequest(tmpDir, { + workspace: { + workspacePath: path.join(tmpDir, 'workspace'), + sessionHome: path.join(tmpDir, 'home'), + branchName: 'main', + preferSnapshot: true, + }, + repo: { + kind: 'git', + url: repositoryUrl, + token, + refreshRemote: true, + }, + }); + await fsp.mkdir(path.join(request.workspace.workspacePath, '.git'), { recursive: true }); + const gitCalls: string[][] = []; + + const result = await prepareWrapperBootstrapWorkspace(request, undefined, { + git: async args => { + gitCalls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + runProcess: async () => { + throw new Error('setup commands should not run on warm path'); + }, + restoreSession: async () => { + throw new Error('session restore should not run on warm path'); + }, + }); + + const authenticatedRemote = new URL(repositoryUrl); + authenticatedRemote.username = 'x-access-token'; + authenticatedRemote.password = token; + + expect(result.workspaceWasWarm).toBe(true); + expect(gitCalls).toEqual([['remote', 'set-url', 'origin', authenticatedRemote.toString()]]); + }); + it('refreshes a warm GitHub remote, author, and selected CLI credential', async () => { const request = makeRequest(tmpDir, { workspace: {