From d0573b09282f55487300b9c3d55ccea5058e7a7f Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Wed, 27 May 2026 17:44:10 +0200 Subject: [PATCH] feat: empty git repository agents --- ..._AddGitRepositorySetupModeToAgentsTable.ts | 25 + .../feature-agent-controller/README.md | 6 +- .../spec/openapi.yaml | 24 +- .../src/lib/dto/provision-server.dto.ts | 7 + .../client-agent-proxy.service.spec.ts | 25 + .../lib/services/provisioning.service.spec.ts | 27 + .../src/lib/services/provisioning.service.ts | 71 +-- .../backend/feature-agent-manager/README.md | 10 +- .../feature-agent-manager/docs/lifecycle.mmd | 13 +- .../docs/sequence-http.mmd | 12 +- .../feature-agent-manager/spec/openapi.yaml | 22 +- .../feature-agent-manager/src/index.ts | 1 + .../constants/git-repository-setup-mode.ts | 22 + .../workspace-configuration-settings.ts | 1 + .../lib/controllers/config.controller.spec.ts | 7 + .../src/lib/controllers/config.controller.ts | 1 + .../src/lib/dto/agent-response.dto.ts | 4 +- .../src/lib/dto/config-response.dto.ts | 3 + .../src/lib/dto/create-agent.dto.ts | 7 + .../src/lib/entities/agent.entity.spec.ts | 15 + .../src/lib/entities/agent.entity.ts | 5 + .../src/lib/services/agents.service.spec.ts | 144 ++++++ .../src/lib/services/agents.service.ts | 156 ++++-- .../src/lib/services/config.service.ts | 9 + .../src/lib/billing.module.ts | 10 + .../cloud-init/agent-manager.utils.spec.ts | 47 ++ .../utils/cloud-init/agent-manager.utils.ts | 31 +- .../src/lib/state/agents/agents.types.ts | 4 +- .../src/lib/state/clients/clients.types.ts | 3 + .../workspace-config.types.ts | 1 + .../src/lib/chat/chat.component.html | 474 ++++++++++-------- .../src/lib/chat/chat.component.ts | 212 ++++++-- .../file-tree/file-tree.component.ts | 20 +- .../src/lib/git-repository-display.ts | 93 ++++ .../lib/tickets/tickets-board.component.html | 10 +- .../lib/tickets/tickets-board.component.ts | 10 + .../subscriptions.component.html | 200 ++++---- .../subscriptions/subscriptions.component.ts | 39 +- 38 files changed, 1317 insertions(+), 454 deletions(-) create mode 100644 apps/backend-agent-manager/src/migrations/1779800000000_AddGitRepositorySetupModeToAgentsTable.ts create mode 100644 libs/domains/framework/backend/feature-agent-manager/src/lib/constants/git-repository-setup-mode.ts create mode 100644 libs/domains/framework/frontend/feature-agent-console/src/lib/git-repository-display.ts diff --git a/apps/backend-agent-manager/src/migrations/1779800000000_AddGitRepositorySetupModeToAgentsTable.ts b/apps/backend-agent-manager/src/migrations/1779800000000_AddGitRepositorySetupModeToAgentsTable.ts new file mode 100644 index 00000000..881fd2e7 --- /dev/null +++ b/apps/backend-agent-manager/src/migrations/1779800000000_AddGitRepositorySetupModeToAgentsTable.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Migration to add git_repository_setup_mode column to agents table. + * Supports empty (git init) vs clone workspace initialization. + */ +export class AddGitRepositorySetupModeToAgentsTable1779800000000 implements MigrationInterface { + name = 'AddGitRepositorySetupModeToAgentsTable1779800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'agents', + new TableColumn({ + name: 'git_repository_setup_mode', + type: 'varchar', + length: '16', + isNullable: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('agents', 'git_repository_setup_mode'); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/README.md b/libs/domains/framework/backend/feature-agent-controller/README.md index 8dad666e..7520745c 100644 --- a/libs/domains/framework/backend/feature-agent-controller/README.md +++ b/libs/domains/framework/backend/feature-agent-controller/README.md @@ -295,7 +295,8 @@ The provisioning endpoint accepts a `ProvisionServerDto` with the following fiel - `keycloakRealm` - Keycloak realm (optional, defaults to environment variable) - `keycloakAuthServerUrl` - Keycloak auth server URL (optional, defaults to environment variable) - `agentWsPort` - Agent WebSocket port (defaults to 8080) -- `gitRepositoryUrl` - Git repository URL for agent workspace +- `gitRepositorySetupMode` - `clone` (default) or `empty` (git init without remote) +- `gitRepositoryUrl` - Git repository URL for agent workspace (clone mode) - `gitUsername` - Git username for repository access - `gitToken` - Git token/personal access token for repository access - `gitPassword` - Git password for repository access (alternative to token) @@ -341,7 +342,8 @@ Values are securely passed from the frontend through the backend to the user dat Client responses include a `config` field that is automatically fetched from the remote agent-manager service. This configuration includes: -- `gitRepositoryUrl` - The Git repository URL configured on the agent-manager instance (if set) +- `gitRepositoryUrl` - The Git repository URL configured on the agent-manager instance (if set, clone mode) +- `gitRepositorySetupMode` - Default setup mode for new agents (`clone` or `empty`) - `agentTypes` - Array of available agent provider types registered on the agent-manager instance (e.g., `['cursor']`, `['cursor', 'opencode']`) The config field is optional and may be `undefined` if: diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml index cbc28068..92f22da9 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml @@ -4148,6 +4148,10 @@ components: type: string description: type: string + gitRepositorySetupMode: + type: string + enum: [clone, empty] + description: How to initialize the agent workspace Git repository gitRepositoryUrl: type: [string, 'null'] description: Git repository URL to clone when creating the agent @@ -4187,8 +4191,12 @@ components: properties: repositoryUrl: type: string - description: Git repository URL associated with the agent - description: Git information (only present if gitRepositoryUrl is set) + description: Git repository URL associated with the agent (clone mode only) + setupMode: + type: string + enum: [clone, empty] + description: How the workspace repository was initialized + description: Git information when the agent has an associated repository or empty local repo createdAt: type: string format: date-time @@ -4220,6 +4228,10 @@ components: gitRepositoryUrl: type: [string, 'null'] description: Git repository URL used for cloning repositories when creating agents + gitRepositorySetupMode: + type: string + enum: [clone, empty] + description: Default setup mode for new agents on this instance agentTypes: type: array items: @@ -4540,9 +4552,15 @@ components: minimum: 1 maximum: 65535 description: Optional Socket.IO port for the client's agents gateway (defaults to 8080 if not set) + gitRepositorySetupMode: + type: string + enum: [clone, empty] + description: | + Default Git workspace setup for agents on the provisioned instance. `empty` runs `git init` without a remote; + omit git URL and credentials when using empty mode. gitRepositoryUrl: type: string - description: Optional Git repository URL for agent workspace + description: Optional Git repository URL for agent workspace (clone mode) gitUsername: type: string gitToken: diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/provision-server.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/provision-server.dto.ts index 3eea6567..5c5727cc 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/provision-server.dto.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/provision-server.dto.ts @@ -1,3 +1,4 @@ +import { GitRepositorySetupMode } from '@forepath/framework/backend/feature-agent-manager'; import { AuthenticationType } from '@forepath/identity/backend'; import { Type } from 'class-transformer'; import { IsEnum, IsIn, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; @@ -56,6 +57,12 @@ export class ProvisionServerDto { @Max(65535) agentWsPort?: number; + @IsOptional() + @IsEnum(GitRepositorySetupMode, { + message: 'Git repository setup mode must be one of: clone, empty', + }) + gitRepositorySetupMode?: GitRepositorySetupMode; + @IsOptional() @IsString({ message: 'Git repository URL must be a string' }) gitRepositoryUrl?: string; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts index 8c5359b3..52efd362 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts @@ -4,6 +4,7 @@ import { ContainerType, CreateAgentDto, CreateAgentResponseDto, + GitRepositorySetupMode, UpdateAgentDto, } from '@forepath/framework/backend/feature-agent-manager'; import { AuthenticationType, ClientAgentCredentialsService, ClientEntity } from '@forepath/identity/backend'; @@ -303,6 +304,30 @@ describe('ClientAgentProxyService', () => { mockCreateAgentResponse.password, ); }); + it('should forward gitRepositorySetupMode when creating an agent', async () => { + const createDto: CreateAgentDto = { + name: 'Empty Agent', + gitRepositorySetupMode: GitRepositorySetupMode.EMPTY, + }; + + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + data: mockCreateAgentResponse, + status: 201, + statusText: 'Created', + headers: {}, + config: {} as any, + }); + + await service.createClientAgent('client-uuid', createDto); + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + data: createDto, + }), + ); + }); }); describe('updateClientAgent', () => { diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.spec.ts index 4350ea8e..aa776697 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.spec.ts @@ -1,3 +1,4 @@ +import { GitRepositorySetupMode } from '@forepath/framework/backend/feature-agent-manager'; import { AuthenticationType, ClientEntity } from '@forepath/identity/backend'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -234,6 +235,32 @@ describe('ProvisioningService', () => { ); }); + it('should emit GIT_REPOSITORY_SETUP_MODE for empty git provisioning and omit clone credentials', async () => { + const emptyGitDto: ProvisionServerDto = { + ...provisionDto, + gitRepositorySetupMode: GitRepositorySetupMode.EMPTY, + gitRepositoryUrl: 'https://github.com/user/repo.git', + gitUsername: 'user', + gitToken: 'token', + }; + + mockProvisioningProviderFactory.hasProvider.mockReturnValue(true); + mockProvisioningProviderFactory.getProvider.mockReturnValue(mockProvider); + (mockProvider.provisionServer as jest.Mock).mockResolvedValue(mockProvisionedServer); + mockClientsService.create.mockResolvedValue(mockClient); + mockProvisioningReferencesRepository.create.mockResolvedValue(mockProvisioningReference); + + await service.provisionServer(emptyGitDto); + + const provisionCall = (mockProvider.provisionServer as jest.Mock).mock.calls[0][0]; + const userData = Buffer.from(provisionCall.userData, 'base64').toString('utf-8'); + + expect(userData).toContain('GIT_REPOSITORY_SETUP_MODE: empty'); + expect(userData).not.toContain('GIT_REPOSITORY_URL:'); + expect(userData).not.toContain('GIT_USERNAME:'); + expect(userData).not.toContain('GIT_TOKEN:'); + }); + it('should use provided API key if given', async () => { const dtoWithApiKey: ProvisionServerDto = { ...provisionDto, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.ts index 8287de09..09696675 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'crypto'; +import { GitRepositorySetupMode } from '@forepath/framework/backend/feature-agent-manager'; import { AuthenticationType, UserRole } from '@forepath/identity/backend'; import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; @@ -62,7 +63,14 @@ export class ProvisioningService { authenticationType: AuthenticationType, apiKey?: string, keycloakConfig?: { clientId: string; clientSecret: string; realm?: string; authServerUrl?: string }, - gitConfig?: { repositoryUrl?: string; username?: string; token?: string; password?: string; privateKey?: string }, + gitConfig?: { + setupMode?: GitRepositorySetupMode; + repositoryUrl?: string; + username?: string; + token?: string; + password?: string; + privateKey?: string; + }, cursorApiKey?: string, agentDefaultImage?: string, autoEnrichEnabledGlobal?: string, @@ -94,24 +102,28 @@ export class ProvisioningService { // Build GIT environment variables const gitEnvVars: string[] = []; - if (gitConfig?.repositoryUrl) { - gitEnvVars.push(`GIT_REPOSITORY_URL: ${gitConfig.repositoryUrl}`); - } + if (gitConfig?.setupMode === GitRepositorySetupMode.EMPTY) { + gitEnvVars.push(`GIT_REPOSITORY_SETUP_MODE: ${GitRepositorySetupMode.EMPTY}`); + } else if (gitConfig) { + if (gitConfig.repositoryUrl) { + gitEnvVars.push(`GIT_REPOSITORY_URL: ${gitConfig.repositoryUrl}`); + } - if (gitConfig?.username) { - gitEnvVars.push(`GIT_USERNAME: ${gitConfig.username}`); - } + if (gitConfig.username) { + gitEnvVars.push(`GIT_USERNAME: ${gitConfig.username}`); + } - if (gitConfig?.token) { - gitEnvVars.push(`GIT_TOKEN: ${gitConfig.token}`); - } + if (gitConfig.token) { + gitEnvVars.push(`GIT_TOKEN: ${gitConfig.token}`); + } - if (gitConfig?.password) { - gitEnvVars.push(`GIT_PASSWORD: ${gitConfig.password}`); - } + if (gitConfig.password) { + gitEnvVars.push(`GIT_PASSWORD: ${gitConfig.password}`); + } - if (gitConfig?.privateKey) { - gitEnvVars.push(`GIT_PRIVATE_KEY: ${gitConfig.privateKey}`); + if (gitConfig.privateKey) { + gitEnvVars.push(`GIT_PRIVATE_KEY: ${gitConfig.privateKey}`); + } } // Build cursor agent environment variables (only add if provided) @@ -363,19 +375,22 @@ DOCKER_COMPOSE_EOF authServerUrl: provisionServerDto.keycloakAuthServerUrl, } : undefined, - provisionServerDto.gitRepositoryUrl || - provisionServerDto.gitUsername || - provisionServerDto.gitToken || - provisionServerDto.gitPassword || - provisionServerDto.gitPrivateKey - ? { - repositoryUrl: provisionServerDto.gitRepositoryUrl, - username: provisionServerDto.gitUsername, - token: provisionServerDto.gitToken, - password: provisionServerDto.gitPassword, - privateKey: provisionServerDto.gitPrivateKey, - } - : undefined, + provisionServerDto.gitRepositorySetupMode === GitRepositorySetupMode.EMPTY + ? { setupMode: GitRepositorySetupMode.EMPTY } + : provisionServerDto.gitRepositoryUrl || + provisionServerDto.gitUsername || + provisionServerDto.gitToken || + provisionServerDto.gitPassword || + provisionServerDto.gitPrivateKey + ? { + setupMode: GitRepositorySetupMode.CLONE, + repositoryUrl: provisionServerDto.gitRepositoryUrl, + username: provisionServerDto.gitUsername, + token: provisionServerDto.gitToken, + password: provisionServerDto.gitPassword, + privateKey: provisionServerDto.gitPrivateKey, + } + : undefined, provisionServerDto.cursorApiKey, provisionServerDto.agentDefaultImage, provisionServerDto.autoEnrichEnabledGlobal, diff --git a/libs/domains/framework/backend/feature-agent-manager/README.md b/libs/domains/framework/backend/feature-agent-manager/README.md index db03701d..b825c07d 100644 --- a/libs/domains/framework/backend/feature-agent-manager/README.md +++ b/libs/domains/framework/backend/feature-agent-manager/README.md @@ -711,9 +711,15 @@ nx test framework-backend-feature-agent-manager --coverage ### Git Repository Environment Variables -These environment variables are required for git repository cloning when creating agents: +When creating agents, the workspace Git repository is initialized in one of two modes (see `gitRepositorySetupMode` on `CreateAgentDto` or `GIT_REPOSITORY_SETUP_MODE`): -- `GIT_REPOSITORY_URL` - HTTPS or SSH URL of the git repository to clone (e.g., `https://github.com/user/repo.git` or `git@github.com:user/repo.git`) +- **`clone` (default)** — clone a remote repository (requires URL and credentials below). +- **`empty`** — run `git init` in the provider workspace path with no remote attached. + +Per-agent `gitRepositorySetupMode` on `CreateAgentDto` overrides the instance default (`GIT_REPOSITORY_SETUP_MODE`). If the field is omitted, the instance default applies (for example, a workspace provisioned or configured with `empty`). + +- `GIT_REPOSITORY_SETUP_MODE` - Optional instance default: `clone` or `empty` (defaults to `clone` when unset) +- `GIT_REPOSITORY_URL` - HTTPS or SSH URL of the git repository to clone (required for clone mode unless provided per agent) - `GIT_USERNAME` - Git username for authentication (**required for HTTPS URLs**) - `GIT_TOKEN` - Git personal access token for authentication (preferred for HTTPS, or use `GIT_PASSWORD`) - `GIT_PASSWORD` - Git password or token (alternative to `GIT_TOKEN` for HTTPS) diff --git a/libs/domains/framework/backend/feature-agent-manager/docs/lifecycle.mmd b/libs/domains/framework/backend/feature-agent-manager/docs/lifecycle.mmd index 0b256645..0e3f8332 100644 --- a/libs/domains/framework/backend/feature-agent-manager/docs/lifecycle.mmd +++ b/libs/domains/framework/backend/feature-agent-manager/docs/lifecycle.mmd @@ -18,15 +18,22 @@ sequenceDiagram Service->>Service: generatePassword() Service->>Service: hashPassword(password) Service->>Service: generateVolumePath() - Service->>Service: isSshRepository(GIT_REPOSITORY_URL) - alt HTTPS Repository + Service->>Service: resolve gitRepositorySetupMode + alt Empty repository (git init) + Service->>Docker: createContainer(name, env, volumes) + Note over Docker: GIT_REPOSITORY_SETUP_MODE=empty
no GIT_REPOSITORY_URL + Docker-->>Service: containerId + Service->>Docker: sendCommandToContainer(containerId, "git init {path}") + Docker-->>Service: repository initialized + else Clone - HTTPS Repository + Service->>Service: isSshRepository(GIT_REPOSITORY_URL) Service->>Docker: createContainer(name, env, volumes) Note over Docker: Container created with:
- GIT_REPOSITORY_URL
- GIT_USERNAME
- GIT_TOKEN/GIT_PASSWORD
- CURSOR_API_KEY
- Volume: /opt/agents/{uuid} -> /app Docker-->>Service: containerId Service->>Docker: createNetrcFile(containerId) Note over Docker: Creates $HOME/.netrc
(worker: /home/agenstra/.netrc)
machine {domain}
login {username}
password {token} Docker-->>Service: .netrc created - else SSH Repository + else Clone - SSH Repository Service->>Service: prepareSshKeyPair(GIT_PRIVATE_KEY) alt GIT_PRIVATE_KEY not set or invalid Service-->>HTTP: BadRequestException diff --git a/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http.mmd b/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http.mmd index cbc67110..7e8a39ac 100644 --- a/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http.mmd +++ b/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http.mmd @@ -15,15 +15,21 @@ sequenceDiagram Service->>Service: generatePassword() Service->>Service: hashPassword(password) Service->>Service: generateVolumePath() - Service->>Service: isSshRepository(GIT_REPOSITORY_URL) - alt HTTPS Repository + Service->>Service: resolve gitRepositorySetupMode + alt Empty repository (git init) + Service->>Docker: createContainer(name, env, volumes) + Docker-->>Service: containerId + Service->>Docker: sendCommandToContainer(containerId, "git init {path}") + Docker-->>Service: repository initialized + else Clone - HTTPS Repository + Service->>Service: isSshRepository(GIT_REPOSITORY_URL) Service->>Docker: createContainer(name, env, volumes) Note over Docker: Container created with:
- GIT_REPOSITORY_URL
- GIT_USERNAME
- GIT_TOKEN/GIT_PASSWORD
- CURSOR_API_KEY
- Volume mounted at /app Docker-->>Service: containerId Service->>Docker: createNetrcFile(containerId) Note over Docker: Creates $HOME/.netrc
(worker: /home/agenstra/.netrc) Docker-->>Service: .netrc created - else SSH Repository + else Clone - SSH Repository Service->>Service: prepareSshKeyPair(GIT_PRIVATE_KEY) alt GIT_PRIVATE_KEY not set or invalid Service-->>API: BadRequestException diff --git a/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml b/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml index 2a045e3f..de0d20c6 100644 --- a/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml @@ -1468,9 +1468,15 @@ components: type: string enum: [cursor, opencode, openclaw] description: Agent provider type (defaults to 'cursor' if not provided) + gitRepositorySetupMode: + type: string + enum: [clone, empty] + description: | + How to initialize the agent workspace Git repository. `clone` (default) clones a remote; + `empty` runs `git init` with no remote. When `empty`, do not send `gitRepositoryUrl`. gitRepositoryUrl: type: [string, 'null'] - description: Git repository URL to clone when creating the agent + description: Git repository URL to clone when creating the agent (required for clone mode unless GIT_REPOSITORY_URL is set on the instance) createVirtualWorkspace: type: boolean default: true @@ -1560,8 +1566,12 @@ components: properties: repositoryUrl: type: string - description: Git repository URL associated with the agent - description: Git information (only present if gitRepositoryUrl is set) + description: Git repository URL associated with the agent (clone mode only) + setupMode: + type: string + enum: [clone, empty] + description: How the workspace repository was initialized + description: Git information when the agent has an associated repository or empty local repo createdAt: type: string format: date-time @@ -1592,7 +1602,11 @@ components: properties: gitRepositoryUrl: type: [string, 'null'] - description: Git repository URL used for cloning repositories when creating agents + description: Git repository URL used for cloning repositories when creating agents (clone mode) + gitRepositorySetupMode: + type: string + enum: [clone, empty] + description: Default setup mode for new agents on this instance (defaults to clone) agentTypes: type: array items: diff --git a/libs/domains/framework/backend/feature-agent-manager/src/index.ts b/libs/domains/framework/backend/feature-agent-manager/src/index.ts index e05d6d14..9c9dc325 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/index.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/index.ts @@ -52,6 +52,7 @@ export * from './lib/services/docker.service'; export * from './lib/services/workspace-configuration-overrides.service'; export * from './lib/utils/agent-file-manager-context'; export * from './lib/utils/regex-filter-rule.utils'; +export * from './lib/constants/git-repository-setup-mode'; export * from './lib/constants/workspace-configuration-settings'; // Re-export PasswordService from identity for backward compatibility diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/git-repository-setup-mode.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/git-repository-setup-mode.ts new file mode 100644 index 00000000..6e035925 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/git-repository-setup-mode.ts @@ -0,0 +1,22 @@ +/** + * How the agent workspace Git repository is initialized at creation time. + */ +export enum GitRepositorySetupMode { + CLONE = 'clone', + EMPTY = 'empty', +} + +export function resolveGitRepositorySetupMode( + dtoMode?: GitRepositorySetupMode, + envMode?: string, +): GitRepositorySetupMode { + if (dtoMode === GitRepositorySetupMode.EMPTY || dtoMode === GitRepositorySetupMode.CLONE) { + return dtoMode; + } + + if (envMode === GitRepositorySetupMode.EMPTY) { + return GitRepositorySetupMode.EMPTY; + } + + return GitRepositorySetupMode.CLONE; +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/workspace-configuration-settings.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/workspace-configuration-settings.ts index 01789a3a..eaae1247 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/workspace-configuration-settings.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/workspace-configuration-settings.ts @@ -1,4 +1,5 @@ export const WORKSPACE_CONFIGURATION_SETTINGS = [ + { settingKey: 'gitRepositorySetupMode', envVarName: 'GIT_REPOSITORY_SETUP_MODE' }, { settingKey: 'gitRepositoryUrl', envVarName: 'GIT_REPOSITORY_URL' }, { settingKey: 'gitUsername', envVarName: 'GIT_USERNAME' }, { settingKey: 'gitToken', envVarName: 'GIT_TOKEN' }, diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/config.controller.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/config.controller.spec.ts index 534ace43..ab3dfcc0 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/config.controller.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/config.controller.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; import { ConfigService } from '../services/config.service'; import { ConfigController } from './config.controller'; @@ -9,6 +10,7 @@ describe('ConfigController', () => { let service: jest.Mocked; const mockConfigService = { getGitRepositoryUrl: jest.fn(), + getGitRepositorySetupMode: jest.fn(), getAvailableAgentTypes: jest.fn(), }; @@ -37,12 +39,14 @@ describe('ConfigController', () => { const agentTypes = [{ type: 'cursor', displayName: 'Cursor' }]; service.getGitRepositoryUrl.mockReturnValue(gitRepositoryUrl); + service.getGitRepositorySetupMode.mockReturnValue(GitRepositorySetupMode.CLONE); service.getAvailableAgentTypes.mockReturnValue(agentTypes); const result = await controller.getConfig(); expect(result).toEqual({ gitRepositoryUrl, + gitRepositorySetupMode: GitRepositorySetupMode.CLONE, agentTypes, }); expect(service.getGitRepositoryUrl).toHaveBeenCalled(); @@ -53,12 +57,14 @@ describe('ConfigController', () => { const agentTypes = [{ type: 'cursor', displayName: 'Cursor' }]; service.getGitRepositoryUrl.mockReturnValue(undefined); + service.getGitRepositorySetupMode.mockReturnValue(GitRepositorySetupMode.CLONE); service.getAvailableAgentTypes.mockReturnValue(agentTypes); const result = await controller.getConfig(); expect(result).toEqual({ gitRepositoryUrl: undefined, + gitRepositorySetupMode: GitRepositorySetupMode.CLONE, agentTypes, }); expect(service.getGitRepositoryUrl).toHaveBeenCalled(); @@ -73,6 +79,7 @@ describe('ConfigController', () => { ]; service.getGitRepositoryUrl.mockReturnValue(undefined); + service.getGitRepositorySetupMode.mockReturnValue(GitRepositorySetupMode.CLONE); service.getAvailableAgentTypes.mockReturnValue(agentTypes); const result = await controller.getConfig(); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/config.controller.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/config.controller.ts index d9967b28..4d817707 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/config.controller.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/config.controller.ts @@ -19,6 +19,7 @@ export class ConfigController { async getConfig(): Promise { return { gitRepositoryUrl: this.configService.getGitRepositoryUrl(), + gitRepositorySetupMode: this.configService.getGitRepositorySetupMode(), agentTypes: this.configService.getAvailableAgentTypes(), }; } diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts index 830f641c..bc511b6c 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts @@ -1,3 +1,4 @@ +import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; import { ContainerType } from '../entities/agent.entity'; /** @@ -19,7 +20,8 @@ export class AgentResponseDto { password: string; }; git?: { - repositoryUrl: string; + repositoryUrl?: string; + setupMode: GitRepositorySetupMode; }; createdAt!: Date; updatedAt!: Date; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/config-response.dto.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/config-response.dto.ts index 02fe31f7..ae149821 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/config-response.dto.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/config-response.dto.ts @@ -13,11 +13,14 @@ export class AgentTypeInfo { displayName!: string; } +import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; + /** * DTO for configuration API responses. * Contains configuration parameters exposed to clients. */ export class ConfigResponseDto { gitRepositoryUrl?: string; + gitRepositorySetupMode?: GitRepositorySetupMode; agentTypes!: AgentTypeInfo[]; } diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts index 113f2c63..b7f5b1dc 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts @@ -1,5 +1,6 @@ import { IsBoolean, IsEnum, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; import { ContainerType } from '../entities/agent.entity'; /** @@ -24,6 +25,12 @@ export class CreateAgentDto { @IsEnum(ContainerType, { message: 'Container type must be one of: generic, docker, terraform, kubernetes' }) containerType?: ContainerType = ContainerType.GENERIC; + @IsOptional() + @IsEnum(GitRepositorySetupMode, { + message: 'Git repository setup mode must be one of: clone, empty', + }) + gitRepositorySetupMode?: GitRepositorySetupMode; + @IsOptional() @IsString({ message: 'Git repository URL must be a string' }) gitRepositoryUrl?: string; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/agent.entity.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/agent.entity.spec.ts index 8e32b4ad..b87bb68f 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/agent.entity.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/agent.entity.spec.ts @@ -1,3 +1,5 @@ +import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; + import { AgentEntity } from './agent.entity'; describe('AgentEntity', () => { @@ -67,4 +69,17 @@ describe('AgentEntity', () => { expect(agent.gitRepositoryUrl).toBeNull(); }); + + it('should allow nullable gitRepositorySetupMode', () => { + const agent = new AgentEntity(); + + agent.id = 'test-uuid'; + agent.name = 'Test Agent'; + agent.hashedPassword = 'hashed-password'; + agent.gitRepositorySetupMode = GitRepositorySetupMode.EMPTY; + agent.createdAt = new Date(); + agent.updatedAt = new Date(); + + expect(agent.gitRepositorySetupMode).toBe(GitRepositorySetupMode.EMPTY); + }); }); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/agent.entity.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/agent.entity.ts index 8162a502..04bd0187 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/agent.entity.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/agent.entity.ts @@ -1,6 +1,8 @@ import { createAes256GcmTransformer } from '@forepath/shared/backend'; import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; + /** * Container type enum representing the type of container. * This is used to identify what type of content the container contains. @@ -79,6 +81,9 @@ export class AgentEntity { @Column({ type: 'varchar', length: 255, nullable: true, name: 'git_repository_url' }) gitRepositoryUrl?: string; + @Column({ type: 'varchar', length: 16, nullable: true, name: 'git_repository_setup_mode' }) + gitRepositorySetupMode?: GitRepositorySetupMode; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts index 234923f8..ccbe5b1f 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts @@ -3,6 +3,7 @@ import { BadRequestException, Logger } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import * as sshpk from 'sshpk'; +import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; import { CreateAgentDto } from '../dto/create-agent.dto'; import { UpdateAgentDto } from '../dto/update-agent.dto'; import { AgentEntity, ContainerType } from '../entities/agent.entity'; @@ -134,6 +135,7 @@ describe('AgentsService', () => { delete process.env.GIT_TOKEN; delete process.env.GIT_PASSWORD; delete process.env.GIT_REPOSITORY_URL; + delete process.env.GIT_REPOSITORY_SETUP_MODE; delete process.env.GIT_PRIVATE_KEY; delete process.env.CURSOR_API_KEY; }); @@ -151,6 +153,7 @@ describe('AgentsService', () => { // Tests may delete optional provider methods; restore defaults for isolation mockAgentProvider.getBasePath = jest.fn().mockReturnValue('/app'); mockAgentProvider.getConfigBasePath = jest.fn().mockReturnValue('~/.cursor'); + delete (mockAgentProvider as { getRepositoryPath?: () => string }).getRepositoryPath; }); it('should create new agent with auto-generated password and container', async () => { @@ -896,6 +899,147 @@ describe('AgentsService', () => { ); }); + it('should initialize empty repository with git init when mode is empty', async () => { + delete process.env.GIT_REPOSITORY_URL; + delete process.env.GIT_REPOSITORY_SETUP_MODE; + + const createDto: CreateAgentDto = { + name: 'Empty Agent', + gitRepositorySetupMode: GitRepositorySetupMode.EMPTY, + createVirtualWorkspace: false, + createSshConnection: false, + }; + const hashedPassword = 'hashed-password'; + const containerId = 'container-id-empty'; + const createdAgent = { + ...mockAgent, + name: createDto.name, + hashedPassword, + containerId, + gitRepositorySetupMode: GitRepositorySetupMode.EMPTY, + }; + + mockAgentProvider.getVirtualWorkspaceDockerImage.mockReturnValueOnce(undefined); + mockAgentProvider.getSshConnectionDockerImage.mockReturnValueOnce(undefined); + mockRepository.findByName.mockResolvedValue(null); + passwordService.hashPassword.mockResolvedValue(hashedPassword); + dockerService.createContainer.mockResolvedValue(containerId); + dockerService.sendCommandToContainer.mockResolvedValue(undefined); + repository.create.mockResolvedValue(createdAgent); + + const result = await service.create(createDto); + + expect(result.git).toEqual({ setupMode: GitRepositorySetupMode.EMPTY }); + expect(dockerService.createContainer).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + GIT_REPOSITORY_SETUP_MODE: GitRepositorySetupMode.EMPTY, + }), + }), + ); + expect(dockerService.createContainer).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.not.objectContaining({ + GIT_REPOSITORY_URL: expect.anything(), + }), + }), + ); + expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( + containerId, + expect.stringMatching(/sh -c "git init -- '\/app'"/), + ); + expect(dockerService.sendCommandToContainer).not.toHaveBeenCalledWith( + containerId, + expect.stringMatching(/git clone/), + ); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + gitRepositorySetupMode: GitRepositorySetupMode.EMPTY, + gitRepositoryUrl: undefined, + }), + ); + }); + + it('should throw BadRequestException when gitRepositoryUrl is set in empty mode', async () => { + const createDto: CreateAgentDto = { + name: 'Invalid Empty Agent', + gitRepositorySetupMode: GitRepositorySetupMode.EMPTY, + gitRepositoryUrl: 'https://github.com/user/repo.git', + }; + + mockRepository.findByName.mockResolvedValue(null); + passwordService.hashPassword.mockResolvedValue('hashed-password'); + + await expect(service.create(createDto)).rejects.toThrow(BadRequestException); + await expect(service.create(createDto)).rejects.toThrow( + 'Git repository URL must not be set when git repository setup mode is empty', + ); + }); + + it('should use GIT_REPOSITORY_SETUP_MODE env default for empty repository', async () => { + delete process.env.GIT_REPOSITORY_URL; + process.env.GIT_REPOSITORY_SETUP_MODE = GitRepositorySetupMode.EMPTY; + + const createDto: CreateAgentDto = { + name: 'Empty Agent Env Default', + createVirtualWorkspace: false, + createSshConnection: false, + }; + + mockAgentProvider.getVirtualWorkspaceDockerImage.mockReturnValueOnce(undefined); + mockAgentProvider.getSshConnectionDockerImage.mockReturnValueOnce(undefined); + mockRepository.findByName.mockResolvedValue(null); + passwordService.hashPassword.mockResolvedValue('hashed-password'); + dockerService.createContainer.mockResolvedValue('container-id-empty-env'); + dockerService.sendCommandToContainer.mockResolvedValue(undefined); + repository.create.mockResolvedValue({ + ...mockAgent, + name: createDto.name, + gitRepositorySetupMode: GitRepositorySetupMode.EMPTY, + }); + + await service.create(createDto); + + expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( + 'container-id-empty-env', + expect.stringMatching(/git init/), + ); + expect(dockerService.sendCommandToContainer).not.toHaveBeenCalledWith( + 'container-id-empty-env', + expect.stringMatching(/git clone/), + ); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + gitRepositorySetupMode: GitRepositorySetupMode.EMPTY, + }), + ); + }); + + it('should clean up container when git init fails', async () => { + const createDto: CreateAgentDto = { + name: 'Empty Agent Failure', + gitRepositorySetupMode: GitRepositorySetupMode.EMPTY, + createVirtualWorkspace: false, + createSshConnection: false, + }; + const containerId = 'container-id-init-fail'; + + mockAgentProvider.getVirtualWorkspaceDockerImage.mockReturnValueOnce(undefined); + mockAgentProvider.getSshConnectionDockerImage.mockReturnValueOnce(undefined); + mockRepository.findByName.mockResolvedValue(null); + passwordService.hashPassword.mockResolvedValue('hashed-password'); + dockerService.createContainer.mockResolvedValue(containerId); + dockerService.sendCommandToContainer + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Git init failed')); + dockerService.deleteContainer.mockResolvedValue(undefined); + + await expect(service.create(createDto)).rejects.toThrow('Git init failed'); + expect(dockerService.deleteContainer).toHaveBeenCalledWith(containerId); + expect(repository.create).not.toHaveBeenCalled(); + }); + it('should clean up container when SSH configuration fails', async () => { const createDto: CreateAgentDto = { name: 'SSH Agent', diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.ts index a9769d03..57ab74d4 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.ts @@ -5,12 +5,14 @@ import { BadRequestException, forwardRef, Inject, Injectable, Logger, OnApplicat import * as sshpk from 'sshpk'; import { v4 as uuidv4 } from 'uuid'; +import { GitRepositorySetupMode, resolveGitRepositorySetupMode } from '../constants/git-repository-setup-mode'; import { AgentResponseDto } from '../dto/agent-response.dto'; import { CreateAgentResponseDto } from '../dto/create-agent-response.dto'; import { CreateAgentDto } from '../dto/create-agent.dto'; import { UpdateAgentDto } from '../dto/update-agent.dto'; import { AgentEntity, ContainerType } from '../entities/agent.entity'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; +import { AgentProvider } from '../providers/agent-provider.interface'; import { AgentProviderModels } from '../providers/agent-provider.interface'; import { AgentsRepository } from '../repositories/agents.repository'; import { expandProviderPathTildeInContainer } from '../utils/provider-container-path.utils'; @@ -279,6 +281,78 @@ export class AgentsService implements OnApplicationBootstrap { await this.dockerService.sendCommandToContainer(containerId, `chmod 600 ${escapedPath}`); } + /** + * Build Git-related container environment variables for agent/VNC containers. + */ + private buildGitContainerEnv( + setupMode: GitRepositorySetupMode, + repositoryUrl?: string, + ): Record { + if (setupMode === GitRepositorySetupMode.EMPTY) { + return { + GIT_REPOSITORY_SETUP_MODE: GitRepositorySetupMode.EMPTY, + }; + } + + return { + GIT_REPOSITORY_URL: repositoryUrl, + GIT_USERNAME: process.env.GIT_USERNAME, + GIT_TOKEN: process.env.GIT_TOKEN, + GIT_PASSWORD: process.env.GIT_PASSWORD, + GIT_PRIVATE_KEY: process.env.GIT_PRIVATE_KEY, + }; + } + + /** + * Resolve the repository path used for clone or git init inside the agent container. + */ + private getRepositoryPath(provider: AgentProvider, basePath: string): string { + return provider.getRepositoryPath ? basePath + provider.getRepositoryPath() : basePath; + } + + /** + * Initialize the agent workspace repository (clone remote or git init locally). + */ + private async setupAgentRepository( + containerId: string, + agentType: string, + setupMode: GitRepositorySetupMode, + repositoryUrl: string | undefined, + provider: AgentProvider, + basePath: string, + ): Promise { + const repositoryPath = this.getRepositoryPath(provider, basePath); + const escapedRepositoryPath = this.escapeForShell(repositoryPath); + + if (setupMode === GitRepositorySetupMode.EMPTY) { + await this.ensureProviderConfigBaseDirectoryExists(containerId, agentType); + await this.dockerService.sendCommandToContainer(containerId, `sh -c "git init -- ${escapedRepositoryPath}"`); + + return; + } + + if (!repositoryUrl) { + throw new BadRequestException( + 'Git repository URL not configured. Please set GIT_REPOSITORY_URL or provide a gitRepositoryUrl in the createAgentDto.', + ); + } + + if (this.isSshRepository(repositoryUrl)) { + await this.configureSshAccess(containerId, repositoryUrl, process.env.GIT_PRIVATE_KEY); + } else { + await this.createNetrcFile(containerId, repositoryUrl); + } + + await this.ensureProviderConfigBaseDirectoryExists(containerId, agentType); + + const escapedUrl = this.escapeForShell(repositoryUrl); + + await this.dockerService.sendCommandToContainer( + containerId, + `sh -c "git clone ${escapedUrl} ${escapedRepositoryPath}"`, + ); + } + /** * Create a new agent with auto-generated password. * @param createAgentDto - Data transfer object for creating an agent @@ -299,15 +373,26 @@ export class AgentsService implements OnApplicationBootstrap { const hashedPassword = await this.passwordService.hashPassword(generatedPassword); // Define a folder name for the agent const agentVolumePath = `/opt/agents/${uuidv4()}`; - const repositoryUrl = createAgentDto.gitRepositoryUrl || process.env.GIT_REPOSITORY_URL; + const gitRepositorySetupMode = resolveGitRepositorySetupMode( + createAgentDto.gitRepositorySetupMode, + process.env.GIT_REPOSITORY_SETUP_MODE, + ); - if (!repositoryUrl) { + if (gitRepositorySetupMode === GitRepositorySetupMode.EMPTY && createAgentDto.gitRepositoryUrl?.trim()) { + throw new BadRequestException('Git repository URL must not be set when git repository setup mode is empty'); + } + + const repositoryUrl = + gitRepositorySetupMode === GitRepositorySetupMode.CLONE + ? createAgentDto.gitRepositoryUrl || process.env.GIT_REPOSITORY_URL + : undefined; + + if (gitRepositorySetupMode === GitRepositorySetupMode.CLONE && !repositoryUrl) { throw new BadRequestException( 'Git repository URL not configured. Please set GIT_REPOSITORY_URL or provide a gitRepositoryUrl in the createAgentDto.', ); } - const sshRepository = this.isSshRepository(repositoryUrl); // Determine agent type (default to 'cursor' for backward compatibility) const agentType = createAgentDto.agentType || 'cursor'; // Get the provider for this agent type to retrieve the Docker image @@ -326,11 +411,7 @@ export class AgentsService implements OnApplicationBootstrap { env: { AGENT_NAME: createAgentDto.name, CURSOR_API_KEY: process.env.CURSOR_API_KEY, - GIT_REPOSITORY_URL: repositoryUrl, - GIT_USERNAME: process.env.GIT_USERNAME, - GIT_TOKEN: process.env.GIT_TOKEN, - GIT_PASSWORD: process.env.GIT_PASSWORD, - GIT_PRIVATE_KEY: process.env.GIT_PRIVATE_KEY, + ...this.buildGitContainerEnv(gitRepositorySetupMode, repositoryUrl), ...(provider.getEnvironmentVariables ? provider.getEnvironmentVariables() : {}), }, volumes: [ @@ -348,23 +429,13 @@ export class AgentsService implements OnApplicationBootstrap { }); try { - if (sshRepository) { - await this.configureSshAccess(containerId, repositoryUrl, process.env.GIT_PRIVATE_KEY); - } else { - // Create .netrc file for git authentication - await this.createNetrcFile(containerId, repositoryUrl); - } - - await this.ensureProviderConfigBaseDirectoryExists(containerId, agentType); - - const escapedUrl = this.escapeForShell(repositoryUrl); - const repositoryPath = provider.getRepositoryPath ? basePath + provider.getRepositoryPath() : basePath; - const escapedRepositoryPath = this.escapeForShell(repositoryPath); - - // Clone the repository to the agent volume - await this.dockerService.sendCommandToContainer( + await this.setupAgentRepository( containerId, - `sh -c "git clone ${escapedUrl} ${escapedRepositoryPath}"`, + agentType, + gitRepositorySetupMode, + repositoryUrl, + provider, + basePath, ); // Create SSH connection container @@ -435,11 +506,7 @@ export class AgentsService implements OnApplicationBootstrap { env: { AGENT_NAME: createAgentDto.name, CURSOR_API_KEY: process.env.CURSOR_API_KEY, - GIT_REPOSITORY_URL: repositoryUrl, - GIT_USERNAME: process.env.GIT_USERNAME, - GIT_TOKEN: process.env.GIT_TOKEN, - GIT_PASSWORD: process.env.GIT_PASSWORD, - GIT_PRIVATE_KEY: process.env.GIT_PRIVATE_KEY, + ...this.buildGitContainerEnv(gitRepositorySetupMode, repositoryUrl), VNC_PASSWORD: virtualWorkspacePassword, }, volumes: [ @@ -505,7 +572,12 @@ export class AgentsService implements OnApplicationBootstrap { sshHostPort: sshConnection.hostPort, sshPassword: sshConnection.password, }), - gitRepositoryUrl: createAgentDto.gitRepositoryUrl, + gitRepositoryUrl: + gitRepositorySetupMode === GitRepositorySetupMode.CLONE ? createAgentDto.gitRepositoryUrl : undefined, + gitRepositorySetupMode: + gitRepositorySetupMode === GitRepositorySetupMode.EMPTY + ? GitRepositorySetupMode.EMPTY + : createAgentDto.gitRepositorySetupMode, }); // Create deployment configuration if provided @@ -862,6 +934,24 @@ export class AgentsService implements OnApplicationBootstrap { return await this.passwordService.verifyPassword(password, agent.hashedPassword); } + /** + * Map agent Git metadata for API responses. + */ + private mapAgentGit(agent: AgentEntity): AgentResponseDto['git'] | undefined { + if (agent.gitRepositorySetupMode === GitRepositorySetupMode.EMPTY) { + return { setupMode: GitRepositorySetupMode.EMPTY }; + } + + if (agent.gitRepositoryUrl) { + return { + repositoryUrl: agent.gitRepositoryUrl, + setupMode: GitRepositorySetupMode.CLONE, + }; + } + + return undefined; + } + /** * Map agent entity to response DTO. * Excludes sensitive information like password hash. @@ -887,11 +977,7 @@ export class AgentsService implements OnApplicationBootstrap { password: agent.sshPassword, } : undefined, - git: agent.gitRepositoryUrl - ? { - repositoryUrl: agent.gitRepositoryUrl, - } - : undefined, + git: this.mapAgentGit(agent), createdAt: agent.createdAt, updatedAt: agent.updatedAt, }; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/config.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/config.service.ts index 2472acff..b06981d1 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/config.service.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/config.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { GitRepositorySetupMode, resolveGitRepositorySetupMode } from '../constants/git-repository-setup-mode'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; /** @@ -18,6 +19,14 @@ export class ConfigService { return process.env.GIT_REPOSITORY_URL; } + /** + * Get the Git repository setup mode from environment variables. + * @returns Resolved setup mode (defaults to clone when unset) + */ + getGitRepositorySetupMode(): GitRepositorySetupMode { + return resolveGitRepositorySetupMode(undefined, process.env.GIT_REPOSITORY_SETUP_MODE); + } + /** * Get the list of available agent provider types with display names. * @returns Array of agent type information objects diff --git a/libs/domains/framework/backend/feature-billing-manager/src/lib/billing.module.ts b/libs/domains/framework/backend/feature-billing-manager/src/lib/billing.module.ts index 22f9fa15..91c31918 100644 --- a/libs/domains/framework/backend/feature-billing-manager/src/lib/billing.module.ts +++ b/libs/domains/framework/backend/feature-billing-manager/src/lib/billing.module.ts @@ -144,6 +144,11 @@ const HETZNER_CONFIG_SCHEMA: Record = { type: 'object', description: 'Optional Git configuration for manager instances (GIT_* env vars)', properties: { + setupMode: { + type: 'string', + description: 'Repository setup mode: clone from remote or empty local repository (git init)', + enum: ['clone', 'empty'], + }, repositoryUrl: { type: 'string', description: 'Git repository URL' }, username: { type: 'string', description: 'Git username (HTTPS)' }, token: { type: 'string', description: 'Git token (e.g. PAT)' }, @@ -220,6 +225,11 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { type: 'object', description: 'Optional Git configuration for manager instances (GIT_* env vars)', properties: { + setupMode: { + type: 'string', + description: 'Repository setup mode: clone from remote or empty local repository (git init)', + enum: ['clone', 'empty'], + }, repositoryUrl: { type: 'string', description: 'Git repository URL' }, username: { type: 'string', description: 'Git username (HTTPS)' }, token: { type: 'string', description: 'Git token (e.g. PAT)' }, diff --git a/libs/domains/framework/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-manager.utils.spec.ts b/libs/domains/framework/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-manager.utils.spec.ts index 21e9f2f1..b9e05233 100644 --- a/libs/domains/framework/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-manager.utils.spec.ts +++ b/libs/domains/framework/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-manager.utils.spec.ts @@ -100,6 +100,20 @@ describe('agent-manager.utils', () => { expect(config.backend.git?.token).toBe('pat'); expect(config.backend.git?.commitAuthorName).toBe('Bot'); expect(config.backend.git?.commitAuthorEmail).toBe('bot@example.com'); + expect(config.backend.git?.setupMode).toBe('clone'); + }); + + it('includes git setupMode empty when request specifies empty repository', () => { + const config = buildAgentManagerCloudInitConfigFromRequest( + { + authenticationMethod: 'api-key', + git: { setupMode: 'empty' }, + }, + 'host1', + ); + + expect(config.backend.git?.setupMode).toBe('empty'); + expect(config.backend.git?.repositoryUrl).toBe(''); }); it('omits backend.git when request has no git', () => { @@ -281,6 +295,39 @@ describe('agent-manager.utils', () => { expect(script).toContain('GIT_COMMIT_AUTHOR_EMAIL: agent@example.com'); }); + it('emits only GIT_REPOSITORY_SETUP_MODE when git setupMode is empty', () => { + const config: AgentManagerCloudInitConfig = { + ssh: { publicKey: '' }, + host: { hostname: 'test', fqdn: 'test.spirde.com' }, + proxy: { httpPort: 80, httpsPort: 443, websocketPort: 8443 }, + backend: { + host: '0.0.0.0', + port: 3000, + websocketPort: 8080, + nodeEnv: 'production', + database: { + host: 'postgres', + port: 5432, + username: 'postgres', + password: 'postgres', + database: 'postgres', + }, + authentication: { authenticationMethod: 'api-key', staticApiKey: 'key' }, + encryption: { encryptionKey: 'enc', jwtSecret: 'jwt' }, + smtp: { host: 'mailhog', port: 1025, user: '', password: '', from: 'noreply@localhost' }, + cors: { origin: 'https://test.spirde.com' }, + git: { setupMode: 'empty' }, + }, + }; + const b64 = buildAgentManagerCloudInitUserData(config); + const script = Buffer.from(b64, 'base64').toString('utf-8'); + + expect(script).toContain('GIT_REPOSITORY_SETUP_MODE: empty'); + expect(script).not.toContain('GIT_REPOSITORY_URL'); + expect(script).not.toContain('GIT_USERNAME'); + expect(script).not.toContain('GIT_TOKEN'); + }); + it('omits GIT_* env vars when config.backend.git is absent', () => { const config: AgentManagerCloudInitConfig = { ssh: { publicKey: '' }, diff --git a/libs/domains/framework/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-manager.utils.ts b/libs/domains/framework/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-manager.utils.ts index 1e83249b..5bfcc5c7 100644 --- a/libs/domains/framework/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-manager.utils.ts +++ b/libs/domains/framework/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-manager.utils.ts @@ -59,6 +59,7 @@ export interface AgentManagerCloudInitConfig { }; /** Git configuration for agent-manager (optional). Passed as GIT_* env vars. */ git?: { + setupMode?: 'clone' | 'empty'; repositoryUrl?: string; username?: string; token?: string; @@ -149,6 +150,7 @@ export function buildAgentManagerCloudInitConfigFromRequest( cors: { origin: '*' }, ...(git !== undefined && { git: { + setupMode: (git.setupMode as string) === 'empty' ? 'empty' : 'clone', repositoryUrl: (git.repositoryUrl as string) ?? '', username: (git.username as string) ?? '', token: (git.token as string) ?? '', @@ -194,17 +196,24 @@ export function buildAgentManagerCloudInitUserData(config: AgentManagerCloudInit ...(config.backend?.cursorApiKey?.trim() ? [`CURSOR_API_KEY: ${config.backend.cursorApiKey.trim()}`] : []), ...(config.backend?.git ? [ - ...(config.backend.git.repositoryUrl ? [`GIT_REPOSITORY_URL: ${config.backend.git.repositoryUrl}`] : []), - ...(config.backend.git.username ? [`GIT_USERNAME: ${config.backend.git.username}`] : []), - ...(config.backend.git.token ? [`GIT_TOKEN: ${config.backend.git.token}`] : []), - ...(config.backend.git.password ? [`GIT_PASSWORD: ${config.backend.git.password}`] : []), - ...(config.backend.git.privateKey ? [`GIT_PRIVATE_KEY: ${config.backend.git.privateKey}`] : []), - ...(config.backend.git.commitAuthorName - ? [`GIT_COMMIT_AUTHOR_NAME: ${config.backend.git.commitAuthorName}`] - : []), - ...(config.backend.git.commitAuthorEmail - ? [`GIT_COMMIT_AUTHOR_EMAIL: ${config.backend.git.commitAuthorEmail}`] - : []), + ...(config.backend.git.setupMode === 'empty' + ? [`GIT_REPOSITORY_SETUP_MODE: empty`] + : [ + ...(config.backend.git.setupMode === 'clone' ? [`GIT_REPOSITORY_SETUP_MODE: clone`] : []), + ...(config.backend.git.repositoryUrl + ? [`GIT_REPOSITORY_URL: ${config.backend.git.repositoryUrl}`] + : []), + ...(config.backend.git.username ? [`GIT_USERNAME: ${config.backend.git.username}`] : []), + ...(config.backend.git.token ? [`GIT_TOKEN: ${config.backend.git.token}`] : []), + ...(config.backend.git.password ? [`GIT_PASSWORD: ${config.backend.git.password}`] : []), + ...(config.backend.git.privateKey ? [`GIT_PRIVATE_KEY: ${config.backend.git.privateKey}`] : []), + ...(config.backend.git.commitAuthorName + ? [`GIT_COMMIT_AUTHOR_NAME: ${config.backend.git.commitAuthorName}`] + : []), + ...(config.backend.git.commitAuthorEmail + ? [`GIT_COMMIT_AUTHOR_EMAIL: ${config.backend.git.commitAuthorEmail}`] + : []), + ]), ] : []), ]); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts index ae45d7b9..8523e8ee 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts @@ -14,7 +14,8 @@ export interface AgentResponseDto { password: string; }; git?: { - repositoryUrl: string; + repositoryUrl?: string; + setupMode: 'clone' | 'empty'; }; createdAt: string; updatedAt: string; @@ -25,6 +26,7 @@ export interface CreateAgentDto { description?: string; agentType?: string; containerType?: ContainerType; + gitRepositorySetupMode?: 'clone' | 'empty'; gitRepositoryUrl?: string; createVirtualWorkspace?: boolean; createSshConnection?: boolean; diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts index f5941769..47a8bc08 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts @@ -8,6 +8,7 @@ export interface AgentTypeInfo { export interface ConfigResponseDto { gitRepositoryUrl?: string | null; + gitRepositorySetupMode?: 'clone' | 'empty'; agentTypes: AgentTypeInfo[]; } @@ -37,6 +38,7 @@ export interface CreateClientDto { keycloakRealm?: string; keycloakAuthServerUrl?: string; agentWsPort?: number; + gitRepositorySetupMode?: 'clone' | 'empty'; gitRepositoryUrl?: string; gitUsername?: string; gitToken?: string; @@ -101,6 +103,7 @@ export interface ProvisionServerDto { keycloakRealm?: string; keycloakAuthServerUrl?: string; agentWsPort?: number; + gitRepositorySetupMode?: 'clone' | 'empty'; gitRepositoryUrl?: string; gitUsername?: string; gitToken?: string; diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.types.ts index f1c3f7aa..461dab99 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.types.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.types.ts @@ -1,4 +1,5 @@ export type WorkspaceConfigurationSettingKey = + | 'gitRepositorySetupMode' | 'gitRepositoryUrl' | 'gitUsername' | 'gitToken' diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html index a92fdf76..9103af7a 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html @@ -203,7 +203,7 @@
Workspaces
{{ getHostname(client.endpoint) }} - @if (parseGitRepository(client.config?.gitRepositoryUrl); as repo) { + @if (getClientGitRepositoryLabel(client); as repo) { {{ repo }} @@ -402,21 +402,13 @@
Environments
{{ getAgentTypeDisplayName(agent.agentType, activeClient) }}
- @if (!agent.git?.repositoryUrl) { - @if (parseGitRepository(activeClient.config?.gitRepositoryUrl); as repo) { - - - {{ repo }} - - } + @if (getAgentGitRepositoryLabel(agent, activeClient); as repo) { + + + {{ repo }} + } } - @if (agent.git?.repositoryUrl) { - - - {{ parseGitRepository(agent.git?.repositoryUrl) }} - - } @if (agent.vnc?.port) { @@ -1699,10 +1691,9 @@
Additional Configuration
- - Repository setup + + Choose whether to clone an existing repository or start with a new local Git repository.
-
- - - Git username for repository access -
+ @if (isClientGitCloneMode()) { +
+ + + Git repository URL for agent workspace +
-
- - - Git token/personal access token for repository access -
+
+ + + Git username for repository access +
-
- - - Git password for repository access (alternative to token) -
- -
- - - SSH private key for cloning repositories via SSH (alternative to username/token) +
+ + + Git token/personal access token for repository access +
+ +
+ + + Git password for repository access (alternative to token) +
+
+ + + SSH private key for cloning repositories via SSH (alternative to username/token) +
+ }
} @@ -2626,22 +2637,48 @@