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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions services/cloud-agent-next/src/execution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
56 changes: 56 additions & 0 deletions services/cloud-agent-next/src/persistence/CloudAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import type {
MessageDeliveryRequest,
AdmitAcceptedSessionMessageRequest,
LegacyRegisteredInitialAdmissionRequest,
RepositoryCredentialUpdate,
MessageDeliveryResult,
SessionMessageAdmissionResult,
SubmittedSessionMessageRequest,
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -597,6 +600,10 @@ export class CloudAgentSession extends DurableObject<WorkerEnv> {
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(),
Expand Down Expand Up @@ -1118,6 +1125,55 @@ export class CloudAgentSession extends DurableObject<WorkerEnv> {
await this.updateLastActivity();
}

private async applyRepositoryCredentialUpdate(
messageId: string,
update: RepositoryCredentialUpdate,
replay: boolean
): Promise<void> {
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<void> {
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.
Expand Down
24 changes: 22 additions & 2 deletions services/cloud-agent-next/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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];
Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
2 changes: 1 addition & 1 deletion services/cloud-agent-next/src/router/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
62 changes: 59 additions & 3 deletions services/cloud-agent-next/src/session-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -701,14 +701,50 @@ 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 }
);
expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled();
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);
Expand Down Expand Up @@ -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({
Expand Down
21 changes: 13 additions & 8 deletions services/cloud-agent-next/src/session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions services/cloud-agent-next/src/session/queue-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { TRPCError } from '@trpc/server';

import type {
QueueExecutionTurnCommand,
RepositoryCredentialUpdate,
SessionMessageAdmissionResult,
SubmittedSessionMessageRequest,
RetryableResultCode,
Expand Down Expand Up @@ -70,6 +71,7 @@ export function throwAdmissionError(

export type QueueMessageInput = {
cloudAgentSessionId: string;
repositoryCredentialUpdate?: RepositoryCredentialUpdate;
} & QueueExecutionTurnCommand;

export type QueueMessageContext = {
Expand Down Expand Up @@ -153,6 +155,9 @@ export async function queueMessage(
},
agent: input.agent,
finalization: input.finalization,
...(input.repositoryCredentialUpdate
? { repositoryCredentialUpdate: input.repositoryCredentialUpdate }
: {}),
};

const result = await withDORetry<
Expand Down
Loading