diff --git a/apps/api/.env.example b/apps/api/.env.example index 0d511d869..c007fc52b 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -16,6 +16,7 @@ BASE_DOMAIN=workspaces.example.com # GITLAB_HOST=https://gitlab.com # GITLAB_CLIENT_ID= # GITLAB_CLIENT_SECRET= +# GITLAB_API_TIMEOUT_MS=30000 # Timeout for GitLab API calls (default: 30000) # Cloudflare credentials (for DNS, Origin CA certificate issuance, and optional managed registry cache) # Requires Account → SSL and Certificates → Edit for per-node Origin CA certificates. diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index f8ee453ba..f453186d5 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -10,7 +10,11 @@ import type { Env } from './env'; import { createModuleLogger } from './lib/logger'; import { readResponseJson } from './lib/runtime-validation'; import { getBetterAuthSecret } from './lib/secrets'; -import { getGitHubOAuthConfig, getGitLabOAuthConfig, getGoogleLoginOAuthConfig } from './services/platform-config'; +import { + getGitHubOAuthConfig, + getGitLabOAuthConfig, + getGoogleLoginOAuthConfig, +} from './services/platform-config'; import { isSignupApprovalRequired } from './services/signup-approval'; const log = createModuleLogger('auth'); @@ -176,7 +180,9 @@ export function selectPrimaryGitHubEmail( primary: Boolean(entry.primary), verified: Boolean(entry.verified), })) - .filter((entry): entry is { email: string; primary: boolean; verified: boolean } => Boolean(entry.email)); + .filter((entry): entry is { email: string; primary: boolean; verified: boolean } => + Boolean(entry.email) + ); const verifiedPrimary = normalizedEmails.find((entry) => entry.primary && entry.verified); if (verifiedPrimary) { @@ -239,7 +245,11 @@ export async function createAuth(env: Env) { headers: githubApiHeaders(accessToken), }); if (emailsRes.ok) { - const emailsData = await readResponseJson(emailsRes, v.array(githubEmailSchema), 'github.user_emails'); + const emailsData = await readResponseJson( + emailsRes, + v.array(githubEmailSchema), + 'github.user_emails' + ); email = selectPrimaryGitHubEmail(email, emailsData); } else { const errorBody = await emailsRes.text(); @@ -250,11 +260,16 @@ export async function createAuth(env: Env) { responseBody: errorBody, }); } else { - log.error('github_emails_fetch_failed', { status: emailsRes.status, responseBody: errorBody }); + log.error('github_emails_fetch_failed', { + status: emailsRes.status, + responseBody: errorBody, + }); } } } catch (err) { - log.error('github_emails_fetch_exception', { error: err instanceof Error ? err.message : String(err) }); + log.error('github_emails_fetch_exception', { + error: err instanceof Error ? err.message : String(err), + }); } // Last resort: use GitHub noreply email @@ -299,6 +314,7 @@ export async function createAuth(env: Env) { clientId: gitlabOAuth.clientId, clientSecret: gitlabOAuth.clientSecret, issuer: gitlabOAuth.host, + scope: ['read_user', 'api', 'read_repository', 'write_repository'], overrideUserInfoOnSignIn: true, }; } diff --git a/apps/api/src/db/migrations/0092_project_gitlab_repositories.sql b/apps/api/src/db/migrations/0092_project_gitlab_repositories.sql new file mode 100644 index 000000000..86ee5b152 --- /dev/null +++ b/apps/api/src/db/migrations/0092_project_gitlab_repositories.sql @@ -0,0 +1,27 @@ +-- Per-project GitLab repository metadata. +-- +-- GitLab uses user OAuth tokens rather than GitHub App installation tokens, so +-- project metadata is stored in a provider sidecar table while projects keeps +-- its historical non-null installation_id sentinel for non-GitHub providers. +CREATE TABLE IF NOT EXISTS project_gitlab_repositories ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + host TEXT NOT NULL, + gitlab_project_id INTEGER NOT NULL, + path_with_namespace TEXT NOT NULL, + web_url TEXT, + http_url_to_repo TEXT NOT NULL, + default_branch TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_project_gitlab_repos_project +ON project_gitlab_repositories(project_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_project_gitlab_repos_user_host_project +ON project_gitlab_repositories(user_id, host, gitlab_project_id); + +CREATE INDEX IF NOT EXISTS idx_project_gitlab_repos_project_user +ON project_gitlab_repositories(project_id, user_id); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 4d62949e2..081122121 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -644,6 +644,43 @@ export const projectGithubRepositories = sqliteTable( }) ); +export const projectGitlabRepositories = sqliteTable( + 'project_gitlab_repositories', + { + id: text('id').primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + host: text('host').notNull(), + gitlabProjectId: integer('gitlab_project_id').notNull(), + pathWithNamespace: text('path_with_namespace').notNull(), + webUrl: text('web_url'), + httpUrlToRepo: text('http_url_to_repo').notNull(), + defaultBranch: text('default_branch').notNull(), + createdAt: text('created_at') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + projectUnique: uniqueIndex('idx_project_gitlab_repos_project').on(table.projectId), + userHostProjectUnique: uniqueIndex('idx_project_gitlab_repos_user_host_project').on( + table.userId, + table.host, + table.gitlabProjectId + ), + projectUserIdx: index('idx_project_gitlab_repos_project_user').on( + table.projectId, + table.userId + ), + }) +); + // ============================================================================= // Project Deployment Credentials (GCP OIDC for Defang deployments) // Note: This table stores GCP WIF configuration (project IDs, service account diff --git a/apps/api/src/durable-objects/github-user-access-token-lock.ts b/apps/api/src/durable-objects/github-user-access-token-lock.ts index 02095f6ab..82ffbec73 100644 --- a/apps/api/src/durable-objects/github-user-access-token-lock.ts +++ b/apps/api/src/durable-objects/github-user-access-token-lock.ts @@ -1,81 +1,12 @@ -import { DurableObject } from 'cloudflare:workers'; -import * as v from 'valibot'; - -import { createAuth } from '../auth'; -import type { Env } from '../env'; -import { log } from '../lib/logger'; -import { readResponseJson } from '../lib/runtime-validation'; - -const requestSchema = v.object({ - userId: v.string(), - flow: v.string(), - headers: v.array(v.tuple([v.string(), v.string()])), -}); +import { UserAccessTokenLock } from './user-access-token-lock'; /** * Per-user mutex around BetterAuth GitHub access-token lookup/refresh. * - * BetterAuth performs the account read, upstream refresh, and account update - * inside `getAccessToken`. GitHub refresh tokens are single-use, so SAM must - * serialize that whole call per user. The read happens inside this DO lock - * because reading before acquiring the lock would let overlapping callers race - * with the same stale refresh token. + * GitHub refresh tokens are single-use, so SAM must serialize the whole + * read → refresh → persist cycle per user. See `UserAccessTokenLock` for the + * shared lock implementation. */ -export class GitHubUserAccessTokenLock extends DurableObject { - private refreshLock: Promise = Promise.resolve(); - - private withRefreshLock(fn: () => Promise): Promise { - const run = this.refreshLock.then(() => fn()); - this.refreshLock = run.then( - () => undefined, - () => undefined - ); - return run; - } - - async fetch(request: Request): Promise { - if (request.method !== 'POST') { - return Response.json({ error: 'method_not_allowed' }, { status: 405 }); - } - - let payload: v.InferOutput; - try { - payload = await readResponseJson( - new Response(await request.text(), { - headers: { 'Content-Type': request.headers.get('Content-Type') ?? 'application/json' }, - }), - requestSchema, - 'github.user_access_token_lock.request' - ); - } catch { - return Response.json({ error: 'invalid_request' }, { status: 400 }); - } - - return this.withRefreshLock(() => this.getAccessToken(payload)); - } - - private async getAccessToken(payload: v.InferOutput): Promise { - try { - const auth = await createAuth(this.env); - const token = await auth.api.getAccessToken({ - headers: new Headers(payload.headers), - body: { providerId: 'github', userId: payload.userId }, - }); - - return Response.json({ - accessToken: token.accessToken ?? null, - accessTokenExpiresAt: token.accessTokenExpiresAt - ? new Date(token.accessTokenExpiresAt).toISOString() - : null, - scopes: token.scopes ?? [], - }); - } catch (err) { - log.warn('github.user_access_token_lock.unavailable', { - flow: payload.flow, - userId: payload.userId, - error: err instanceof Error ? err.message : String(err), - }); - return Response.json({ error: 'token_unavailable' }, { status: 401 }); - } - } +export class GitHubUserAccessTokenLock extends UserAccessTokenLock { + protected readonly providerId = 'github'; } diff --git a/apps/api/src/durable-objects/gitlab-user-access-token-lock.ts b/apps/api/src/durable-objects/gitlab-user-access-token-lock.ts new file mode 100644 index 000000000..d33d538f0 --- /dev/null +++ b/apps/api/src/durable-objects/gitlab-user-access-token-lock.ts @@ -0,0 +1,18 @@ +import { UserAccessTokenLock } from './user-access-token-lock'; + +/** + * Per-user mutex around BetterAuth GitLab access-token lookup/refresh. + * + * GitLab refresh tokens are single-use and rotating, so a concurrent replay of + * a consumed refresh token revokes the entire token family upstream. The + * shared lock in `UserAccessTokenLock` serializes the whole + * read → refresh → persist cycle per user. + * + * Refresh-aware rate limiting is tracked in + * tasks/backlog/2026-07-12-gitlab-token-lock-rate-limit.md — most calls are + * cached reads for git credential fetches, so a naive per-call limit would + * throttle legitimate git operations. + */ +export class GitLabUserAccessTokenLock extends UserAccessTokenLock { + protected readonly providerId = 'gitlab'; +} diff --git a/apps/api/src/durable-objects/task-runner/workspace-steps.ts b/apps/api/src/durable-objects/task-runner/workspace-steps.ts index 2a1d4b06f..45f165d5d 100644 --- a/apps/api/src/durable-objects/task-runner/workspace-steps.ts +++ b/apps/api/src/durable-objects/task-runner/workspace-steps.ts @@ -249,6 +249,15 @@ export async function ensureBranchExistsOnRemote( return; } + const projectRepo = await loadTaskRunnerProjectRepo(state, rc); + if (projectRepo?.repoProvider === 'artifacts') { + return; + } + if (projectRepo?.repoProvider === 'gitlab') { + await ensureGitLabBranchExistsOnRemote(state, rc, defaultBranch); + return; + } + // Parse owner/repo from repository string (format: "owner/repo") const repoParts = state.config.repository.split('/'); if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) { @@ -303,6 +312,61 @@ export async function ensureBranchExistsOnRemote( } } +type TaskRunnerProjectRepo = { + repoProvider: string | null; +}; + +async function loadTaskRunnerProjectRepo( + state: TaskRunnerState, + rc: TaskRunnerContext, +): Promise { + return rc.env.DATABASE.prepare( + `SELECT repo_provider AS repoProvider FROM projects WHERE id = ?` + ).bind(state.projectId).first(); +} + +async function ensureGitLabBranchExistsOnRemote( + state: TaskRunnerState, + rc: TaskRunnerContext, + defaultBranch: string, +): Promise { + try { + const { drizzle } = await import('drizzle-orm/d1'); + const schema = await import('../../db/schema'); + const { ensureGitLabBranchExists, getProjectGitLabRepository } = await import('../../services/gitlab'); + const metadata = await getProjectGitLabRepository( + drizzle(rc.env.DATABASE, { schema }), + state.projectId + ); + if (!metadata) { + log.warn('task_runner_do.ensure_branch.gitlab_metadata_missing', { + taskId: state.taskId, + projectId: state.projectId, + }); + return; + } + const created = await ensureGitLabBranchExists({ + env: rc.env, + userId: state.userId, + projectId: metadata.gitlabProjectId, + branch: state.config.branch, + ref: defaultBranch, + }); + if (created) { + log.info('task_runner_do.ensure_branch.gitlab_ok', { + taskId: state.taskId, + branch: state.config.branch, + }); + } + } catch (err) { + log.warn('task_runner_do.ensure_branch.gitlab_error', { + taskId: state.taskId, + branch: state.config.branch, + error: err instanceof Error ? err.message : String(err), + }); + } +} + type TaskRunnerGitHubInstallation = { installationId: string; externalInstallationId: string | null; @@ -328,11 +392,16 @@ async function createWorkspaceOnVmAgent( const { signCallbackToken } = await import('../../services/jwt'); const { createWorkspaceOnNode } = await import('../../services/node-agent'); const callbackToken = await signCallbackToken(workspaceId, rc.env); + const gitSource = await resolveWorkspaceGitSource(state, rc); const response = await createWorkspaceOnNode(nodeId, rc.env, state.userId, { workspaceId, repository: state.config.repository, branch: state.config.branch, + repoProvider: gitSource.repoProvider, + cloneUrl: gitSource.cloneUrl, + repositoryHost: gitSource.repositoryHost, + repositoryPath: gitSource.repositoryPath, callbackToken, gitUserName: state.config.userName, gitUserEmail: state.config.userEmail, @@ -350,6 +419,48 @@ async function createWorkspaceOnVmAgent( } } +async function resolveWorkspaceGitSource( + state: TaskRunnerState, + rc: TaskRunnerContext, +): Promise<{ + repoProvider: 'github' | 'artifacts' | 'gitlab'; + cloneUrl: string | null; + repositoryHost: string | null; + repositoryPath: string | null; +}> { + const projectRepo = await loadTaskRunnerProjectRepo(state, rc); + const repoProvider = projectRepo?.repoProvider === 'gitlab' + ? 'gitlab' + : projectRepo?.repoProvider === 'artifacts' + ? 'artifacts' + : 'github'; + + if (repoProvider !== 'gitlab') { + return { repoProvider, cloneUrl: null, repositoryHost: null, repositoryPath: null }; + } + + const { drizzle } = await import('drizzle-orm/d1'); + const schema = await import('../../db/schema'); + const { getProjectGitLabRepository } = await import('../../services/gitlab'); + const metadata = await getProjectGitLabRepository( + drizzle(rc.env.DATABASE, { schema }), + state.projectId + ); + if (!metadata) { + throw Object.assign( + new Error(`GitLab repository metadata is missing for project ${state.projectId}`), + { permanent: true } + ); + } + + return { + repoProvider, + cloneUrl: metadata.httpUrlToRepo, + repositoryHost: metadata.host, + repositoryPath: metadata.pathWithNamespace, + }; +} + function isWorkspaceDispatchAck(response: unknown, workspaceId: string): boolean { if (!response || typeof response !== 'object') { return false; diff --git a/apps/api/src/durable-objects/user-access-token-lock.ts b/apps/api/src/durable-objects/user-access-token-lock.ts new file mode 100644 index 000000000..312fd2793 --- /dev/null +++ b/apps/api/src/durable-objects/user-access-token-lock.ts @@ -0,0 +1,90 @@ +import { DurableObject } from 'cloudflare:workers'; +import * as v from 'valibot'; + +import { createAuth } from '../auth'; +import type { Env } from '../env'; +import { log } from '../lib/logger'; +import { readResponseJson } from '../lib/runtime-validation'; + +const requestSchema = v.object({ + userId: v.string(), + flow: v.string(), + headers: v.array(v.tuple([v.string(), v.string()])), +}); + +type TokenLockPayload = v.InferOutput; + +/** + * Shared per-user mutex around BetterAuth OAuth access-token lookup/refresh. + * + * BetterAuth performs the account read, upstream refresh, and account update + * inside `getAccessToken`. Providers with single-use (GitHub) or single-use + * rotating (GitLab) refresh tokens require SAM to serialize that whole call + * per user — a concurrent replay of a consumed refresh token fails upstream + * and, for GitLab, revokes the entire token family. The read happens inside + * the DO lock because reading before acquiring the lock would let overlapping + * callers race with the same stale refresh token. + * + * Subclasses only supply the BetterAuth provider id; the exported subclass + * names are bound in wrangler.toml and must not change. + */ +export abstract class UserAccessTokenLock extends DurableObject { + protected abstract readonly providerId: string; + + private refreshLock: Promise = Promise.resolve(); + + private withRefreshLock(fn: () => Promise): Promise { + const run = this.refreshLock.then(() => fn()); + this.refreshLock = run.then( + () => undefined, + () => undefined + ); + return run; + } + + async fetch(request: Request): Promise { + if (request.method !== 'POST') { + return Response.json({ error: 'method_not_allowed' }, { status: 405 }); + } + + let payload: TokenLockPayload; + try { + payload = await readResponseJson( + new Response(await request.text(), { + headers: { 'Content-Type': request.headers.get('Content-Type') ?? 'application/json' }, + }), + requestSchema, + `${this.providerId}.user_access_token_lock.request` + ); + } catch { + return Response.json({ error: 'invalid_request' }, { status: 400 }); + } + + return this.withRefreshLock(() => this.getAccessToken(payload)); + } + + private async getAccessToken(payload: TokenLockPayload): Promise { + try { + const auth = await createAuth(this.env); + const token = await auth.api.getAccessToken({ + headers: new Headers(payload.headers), + body: { providerId: this.providerId, userId: payload.userId }, + }); + + return Response.json({ + accessToken: token.accessToken ?? null, + accessTokenExpiresAt: token.accessTokenExpiresAt + ? new Date(token.accessTokenExpiresAt).toISOString() + : null, + scopes: token.scopes ?? [], + }); + } catch (err) { + log.warn(`${this.providerId}.user_access_token_lock.unavailable`, { + flow: payload.flow, + userId: payload.userId, + error: err instanceof Error ? err.message : String(err), + }); + return Response.json({ error: 'token_unavailable' }, { status: 401 }); + } + } +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index f62002737..3cfc1eb65 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -54,6 +54,7 @@ export interface Env { NOTIFICATION: DurableObjectNamespace; CODEX_REFRESH_LOCK: DurableObjectNamespace; GITHUB_USER_ACCESS_TOKEN_LOCK: DurableObjectNamespace; + GITLAB_USER_ACCESS_TOKEN_LOCK?: DurableObjectNamespace; TRIAL_COUNTER: DurableObjectNamespace; TRIAL_EVENT_BUS: DurableObjectNamespace; TRIAL_ORCHESTRATOR: DurableObjectNamespace; @@ -77,6 +78,7 @@ export interface Env { GITLAB_HOST?: string; // Optional GitLab OAuth host fallback, e.g. https://gitlab.com GITLAB_CLIENT_ID?: string; GITLAB_CLIENT_SECRET?: string; + GITLAB_API_TIMEOUT_MS?: string; // Timeout for GitLab API calls in ms (default: 30000) CF_API_TOKEN: string; CF_ZONE_ID: string; CF_ACCOUNT_ID: string; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 879ae19d5..94f5a2a85 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,6 +4,7 @@ export { AiTokenBudgetCounter } from './durable-objects/ai-token-budget-counter' // Sandbox SDK DO class — retained for experimental toolbox/diagnostics use only. export { CodexRefreshLock } from './durable-objects/codex-refresh-lock'; export { GitHubUserAccessTokenLock } from './durable-objects/github-user-access-token-lock'; +export { GitLabUserAccessTokenLock } from './durable-objects/gitlab-user-access-token-lock'; export { NodeLifecycle } from './durable-objects/node-lifecycle'; export { NotificationService } from './durable-objects/notification'; export { ProjectAgent } from './durable-objects/project-agent'; @@ -81,6 +82,7 @@ import { deploymentVolumeRoutes } from './routes/deployment-volumes'; import { deviceFlowRoutes } from './routes/device-flow'; import { gcpRoutes } from './routes/gcp'; import { githubRoutes } from './routes/github'; +import { gitlabRoutes } from './routes/gitlab'; import { googleAuthRoutes } from './routes/google-auth'; import { knowledgeRoutes } from './routes/knowledge'; import { libraryRoutes } from './routes/library'; @@ -703,6 +705,7 @@ app.route('/api/credentials', credentialsRoutes); app.route('/api/cc', ccRoutes); app.route('/api/providers', providersRoutes); app.route('/api/github', githubRoutes); +app.route('/api/gitlab', gitlabRoutes); // Callback JWT routes — MUST be before session-auth node routes. app.route('/api/nodes', deployReleaseCallbackRoute); // Deploy node fetches signed release payload. app.route('/api/nodes', deploymentReleaseEventsCallbackRoute); // Deploy node reports apply events. diff --git a/apps/api/src/routes/gitlab.ts b/apps/api/src/routes/gitlab.ts new file mode 100644 index 000000000..f4b2e3b5f --- /dev/null +++ b/apps/api/src/routes/gitlab.ts @@ -0,0 +1,35 @@ +import type { GitLabProjectListResponse } from '@simple-agent-manager/shared'; +import { Hono } from 'hono'; + +import type { Env } from '../env'; +import { getUserId, requireApproved, requireAuth } from '../middleware/auth'; +import { errors } from '../middleware/error'; +import { + listGitLabBranches, + listGitLabProjects, + requireGitLabUserAccessToken, +} from '../services/gitlab'; + +const gitlabRoutes = new Hono<{ Bindings: Env }>(); + +gitlabRoutes.get('/projects', requireAuth(), requireApproved(), async (c) => { + const userId = getUserId(c); + const accessToken = await requireGitLabUserAccessToken(c, userId); + const projects = await listGitLabProjects(c.env, accessToken, c.req.query('search')); + return c.json({ projects } satisfies GitLabProjectListResponse); +}); + +gitlabRoutes.get('/branches', requireAuth(), requireApproved(), async (c) => { + const userId = getUserId(c); + const projectIdRaw = c.req.query('project_id')?.trim(); + const projectId = Number.parseInt(projectIdRaw ?? '', 10); + if (!Number.isFinite(projectId) || projectId <= 0) { + throw errors.badRequest('project_id is required'); + } + + const accessToken = await requireGitLabUserAccessToken(c, userId); + const branches = await listGitLabBranches(c.env, accessToken, projectId); + return c.json(branches); +}); + +export { gitlabRoutes }; diff --git a/apps/api/src/routes/mcp/instruction-tools.ts b/apps/api/src/routes/mcp/instruction-tools.ts index b93714fc8..d63d52f18 100644 --- a/apps/api/src/routes/mcp/instruction-tools.ts +++ b/apps/api/src/routes/mcp/instruction-tools.ts @@ -2,7 +2,13 @@ * MCP instruction tools — get_instructions and request_human_input. */ import type { HumanInputCategory } from '@simple-agent-manager/shared'; -import { HUMAN_INPUT_CATEGORIES, KNOWLEDGE_DEFAULTS, MAX_HUMAN_INPUT_CONTEXT_LENGTH, MAX_HUMAN_INPUT_OPTION_LENGTH, MAX_HUMAN_INPUT_OPTIONS_COUNT } from '@simple-agent-manager/shared'; +import { + HUMAN_INPUT_CATEGORIES, + KNOWLEDGE_DEFAULTS, + MAX_HUMAN_INPUT_CONTEXT_LENGTH, + MAX_HUMAN_INPUT_OPTION_LENGTH, + MAX_HUMAN_INPUT_OPTIONS_COUNT, +} from '@simple-agent-manager/shared'; import { and, eq } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/d1'; @@ -42,7 +48,7 @@ function inferInstructionContextType(tokenData: McpTokenData): InstructionContex export async function resolveInstructionContext( tokenData: McpTokenData, - env: Env, + env: Env ): Promise<{ ok: true; context: ResolvedInstructionContext } | { ok: false; message: string }> { const contextType = inferInstructionContextType(tokenData); @@ -55,10 +61,7 @@ export async function resolveInstructionContext( .select() .from(schema.tasks) .where( - and( - eq(schema.tasks.id, tokenData.taskId), - eq(schema.tasks.projectId, tokenData.projectId), - ), + and(eq(schema.tasks.id, tokenData.taskId), eq(schema.tasks.projectId, tokenData.projectId)) ) .limit(1); @@ -78,7 +81,9 @@ export async function resolveInstructionContext( } const session = tokenData.chatSessionId - ? await projectDataService.getSession(env, tokenData.projectId, tokenData.chatSessionId).catch(() => null) + ? await projectDataService + .getSession(env, tokenData.projectId, tokenData.chatSessionId) + .catch(() => null) : null; if (contextType === 'conversation' && !session) { @@ -100,7 +105,7 @@ export async function resolveInstructionContext( export async function handleGetInstructions( requestId: string | number | null, tokenData: McpTokenData, - env: Env, + env: Env ): Promise { const db = drizzle(env.DATABASE, { schema }); @@ -127,12 +132,24 @@ export async function handleGetInstructions( // knowledge), we retrieve all observations above a confidence threshold. For typical // projects with <50 observations, this is a small amount of text that gives the agent // full context about user preferences, project conventions, and decisions. - const minConfidence = parseFloat(env.KNOWLEDGE_AUTO_RETRIEVE_MIN_CONFIDENCE || '') || KNOWLEDGE_DEFAULTS.autoRetrieveMinConfidence; - const highConfidenceLimit = parseInt(env.KNOWLEDGE_AUTO_RETRIEVE_HIGH_CONFIDENCE_LIMIT || '', 10) || KNOWLEDGE_DEFAULTS.autoRetrieveHighConfidenceLimit; - let knowledgeContext: { entityName: string; entityType: string; observation: string; confidence: number }[] = []; + const minConfidence = + parseFloat(env.KNOWLEDGE_AUTO_RETRIEVE_MIN_CONFIDENCE || '') || + KNOWLEDGE_DEFAULTS.autoRetrieveMinConfidence; + const highConfidenceLimit = + parseInt(env.KNOWLEDGE_AUTO_RETRIEVE_HIGH_CONFIDENCE_LIMIT || '', 10) || + KNOWLEDGE_DEFAULTS.autoRetrieveHighConfidenceLimit; + let knowledgeContext: { + entityName: string; + entityType: string; + observation: string; + confidence: number; + }[] = []; try { const allHighConfidence = await projectDataService.getAllHighConfidenceKnowledge( - env, tokenData.projectId, minConfidence, highConfidenceLimit, + env, + tokenData.projectId, + minConfidence, + highConfidenceLimit ); knowledgeContext = allHighConfidence.map((r) => ({ entityName: r.entityName, @@ -154,12 +171,18 @@ export async function handleGetInstructions( // Build knowledge-related instructions based on whether knowledge exists const knowledgeInstructions = buildKnowledgeInstructions( knowledgeContext.length > 0, - context.type === 'conversation' || context.task?.taskMode === 'conversation', + context.type === 'conversation' || context.task?.taskMode === 'conversation' ); // Retrieve active project policies (Phase 4: Policy Propagation). // Policies are dynamic rules and preferences that agents must follow. - let policyContext: { id: string; category: string; title: string; content: string; confidence: number }[] = []; + let policyContext: { + id: string; + category: string; + title: string; + content: string; + confidence: number; + }[] = []; try { const activePolicies = await projectDataService.getActivePolicies(env, tokenData.projectId); policyContext = activePolicies.map((p) => ({ @@ -182,7 +205,8 @@ export async function handleGetInstructions( context.type === 'conversation' || context.task?.taskMode === 'conversation' ); - const isConversation = context.type === 'conversation' || context.task?.taskMode === 'conversation'; + const isConversation = + context.type === 'conversation' || context.task?.taskMode === 'conversation'; const result = { context: { @@ -220,9 +244,9 @@ export async function handleGetInstructions( }, instructions: [ 'Tool names in these instructions refer to SAM MCP tools from the `sam-mcp` MCP server.', - 'After reading this response, check whether the current chat session topic/title accurately reflects the actual work. ' - + 'If the title is stale, generic, copied from a fork such as "get details from previous session", or the session changes direction later, ' - + 'call the SAM MCP `update_session_topic` tool with a concise descriptive topic.', + 'After reading this response, check whether the current chat session topic/title accurately reflects the actual work. ' + + 'If the title is stale, generic, copied from a fork such as "get details from previous session", or the session changes direction later, ' + + 'call the SAM MCP `update_session_topic` tool with a concise descriptive topic.', ...(isConversation ? [ 'You are in a conversation with a human. Respond to their messages directly.', @@ -239,13 +263,20 @@ export async function handleGetInstructions( ]), ...knowledgeInstructions, ...policyInstructions, - ...((project.repoProvider === 'artifacts') + ...(project.repoProvider === 'artifacts' ? [ 'This project uses SAM Git (Cloudflare Artifacts) — NOT GitHub.', 'Do NOT use `gh pr create`, `gh` CLI, or any GitHub-specific commands.', 'Push your changes directly to the remote branch. Summarize your changes in the task completion message.', ] : []), + ...(project.repoProvider === 'gitlab' + ? [ + 'This project uses GitLab — NOT GitHub.', + 'Do NOT use `gh pr create`, `gh` CLI, or any GitHub-specific commands.', + 'Push your changes to the remote branch. SAM will create a GitLab merge request from the workspace completion path when applicable.', + ] + : []), ], // Include formatted directives as a readable text block (primary way agents consume knowledge) ...(knowledgeDirectives ? { knowledgeDirectives } : {}), @@ -313,56 +344,56 @@ function buildKnowledgeInstructions(hasKnowledge: boolean, isConversation: boole // Core directive — MUST, not "you can" instructions.push( - 'You MUST use the knowledge graph to remember important facts about the user and project across sessions.', + 'You MUST use the knowledge graph to remember important facts about the user and project across sessions.' ); // When to SAVE — concrete trigger patterns instructions.push( - 'Save to knowledge graph (via `add_knowledge`) when ANY of these happen: ' - + '(1) User corrects you or says "don\'t do X" → sourceType "explicit", confidence 0.9+. ' - + '(2) User states a preference ("I prefer...", "always use...", "never...") → sourceType "explicit", confidence 0.9+. ' - + '(3) User describes their role, expertise, or background → entityType "expertise". ' - + '(4) You learn a project convention or architecture decision → entityType "context". ' - + '(5) User gives feedback on your response style → entityType "preference".', + 'Save to knowledge graph (via `add_knowledge`) when ANY of these happen: ' + + '(1) User corrects you or says "don\'t do X" → sourceType "explicit", confidence 0.9+. ' + + '(2) User states a preference ("I prefer...", "always use...", "never...") → sourceType "explicit", confidence 0.9+. ' + + '(3) User describes their role, expertise, or background → entityType "expertise". ' + + '(4) You learn a project convention or architecture decision → entityType "context". ' + + '(5) User gives feedback on your response style → entityType "preference".' ); // When to READ — decision-point retrieval (Layer 2) instructions.push( - 'Search knowledge (via `search_knowledge`) BEFORE making key decisions: ' - + 'before writing content/blogs → search "ContentStyle"; ' - + 'before choosing libraries/tools → search "CodeQuality"; ' - + 'before UI layout decisions → search "User" and "mobile"; ' - + 'before architecture decisions → search "Architecture"; ' - + 'before pricing/business decisions → search "BusinessStrategy".', + 'Search knowledge (via `search_knowledge`) BEFORE making key decisions: ' + + 'before writing content/blogs → search "ContentStyle"; ' + + 'before choosing libraries/tools → search "CodeQuality"; ' + + 'before UI layout decisions → search "User" and "mobile"; ' + + 'before architecture decisions → search "Architecture"; ' + + 'before pricing/business decisions → search "BusinessStrategy".' ); // What NOT to save instructions.push( - 'Do NOT save to knowledge: code patterns derivable from the codebase, git history, ephemeral task details, or things already in CLAUDE.md or project config.', + 'Do NOT save to knowledge: code patterns derivable from the codebase, git history, ephemeral task details, or things already in CLAUDE.md or project config.' ); if (hasKnowledge) { // Knowledge exists — tell agent to apply it and maintain it instructions.push( - 'The knowledgeDirectives field above contains stored knowledge from previous sessions. Apply these preferences and facts to your work. ' - + 'If any observation seems outdated, call `update_knowledge` or `remove_knowledge`. ' - + 'If you verify an observation is still accurate, call `confirm_knowledge` to keep it fresh.', + 'The knowledgeDirectives field above contains stored knowledge from previous sessions. Apply these preferences and facts to your work. ' + + 'If any observation seems outdated, call `update_knowledge` or `remove_knowledge`. ' + + 'If you verify an observation is still accurate, call `confirm_knowledge` to keep it fresh.' ); } else { // Empty knowledge graph — bootstrapping prompt instructions.push( - 'This project has no stored knowledge yet. ' - + 'Actively look for user preferences, project conventions, and important context to store. ' - + 'If this is a conversation, ask the user about their preferences when relevant. ' - + 'You can also search past conversations (via `search_messages`) for user preferences using queries like "prefer", "don\'t want", "I like", "always" to seed the knowledge graph.', + 'This project has no stored knowledge yet. ' + + 'Actively look for user preferences, project conventions, and important context to store. ' + + 'If this is a conversation, ask the user about their preferences when relevant. ' + + 'You can also search past conversations (via `search_messages`) for user preferences using queries like "prefer", "don\'t want", "I like", "always" to seed the knowledge graph.' ); } if (isConversation) { instructions.push( - 'You are in a direct conversation — this is the richest source of user knowledge. ' - + 'Pay close attention to corrections, preferences, and context the user shares. ' - + 'Store important observations as you go, not just at the end.', + 'You are in a direct conversation — this is the richest source of user knowledge. ' + + 'Pay close attention to corrections, preferences, and context the user shares. ' + + 'Store important observations as you go, not just at the end.' ); } @@ -436,19 +467,19 @@ function buildPolicyInstructions(hasPolicies: boolean, isConversation: boolean): if (hasPolicies) { instructions.push( - 'The policyDirectives field above contains project policies set by the user. ' - + 'You MUST follow all rules and constraints. Preferences are softer guidance — follow them unless you have a good reason not to.', + 'The policyDirectives field above contains project policies set by the user. ' + + 'You MUST follow all rules and constraints. Preferences are softer guidance — follow them unless you have a good reason not to.' ); instructions.push( - 'If a user statement contradicts an existing policy, use `update_policy` to update it. ' - + 'If a policy is no longer relevant, use `remove_policy` to deactivate it.', + 'If a user statement contradicts an existing policy, use `update_policy` to update it. ' + + 'If a policy is no longer relevant, use `remove_policy` to deactivate it.' ); } if (isConversation) { instructions.push( - 'When a user states a rule, constraint, delegation preference, or soft preference, ' - + 'save it as a project policy via `add_policy` so it applies to all future agents in this project.', + 'When a user states a rule, constraint, delegation preference, or soft preference, ' + + 'save it as a project policy via `add_policy` so it applies to all future agents in this project.' ); } @@ -459,18 +490,22 @@ export async function handleRequestHumanInput( requestId: string | number | null, params: Record, tokenData: McpTokenData, - env: Env, + env: Env ): Promise { const context = params.context; if (typeof context !== 'string' || !context.trim()) { - return jsonRpcError(requestId, INVALID_PARAMS, 'context is required and must be a non-empty string'); + return jsonRpcError( + requestId, + INVALID_PARAMS, + 'context is required and must be a non-empty string' + ); } if (context.length > MAX_HUMAN_INPUT_CONTEXT_LENGTH) { return jsonRpcError( requestId, INVALID_PARAMS, - `context exceeds maximum length of ${MAX_HUMAN_INPUT_CONTEXT_LENGTH} characters`, + `context exceeds maximum length of ${MAX_HUMAN_INPUT_CONTEXT_LENGTH} characters` ); } @@ -480,8 +515,15 @@ export async function handleRequestHumanInput( // Validate category if provided let category: HumanInputCategory | null = null; if (params.category !== undefined) { - if (typeof params.category !== 'string' || !(HUMAN_INPUT_CATEGORIES as readonly string[]).includes(params.category)) { - return jsonRpcError(requestId, INVALID_PARAMS, `category must be one of: ${HUMAN_INPUT_CATEGORIES.join(', ')}`); + if ( + typeof params.category !== 'string' || + !(HUMAN_INPUT_CATEGORIES as readonly string[]).includes(params.category) + ) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + `category must be one of: ${HUMAN_INPUT_CATEGORIES.join(', ')}` + ); } category = params.category as HumanInputCategory; } @@ -503,11 +545,13 @@ export async function handleRequestHumanInput( // Fetch task title (user_id verified against token below) const taskRow = await env.DATABASE.prepare( - `SELECT user_id, title FROM tasks WHERE id = ? AND project_id = ?`, - ).bind(tokenData.taskId, tokenData.projectId).first<{ - user_id: string; - title: string; - }>(); + `SELECT user_id, title FROM tasks WHERE id = ? AND project_id = ?` + ) + .bind(tokenData.taskId, tokenData.projectId) + .first<{ + user_id: string; + title: string; + }>(); if (!taskRow) { return jsonRpcError(requestId, INTERNAL_ERROR, 'Task not found'); @@ -579,6 +623,11 @@ export async function handleRequestHumanInput( }); return jsonRpcSuccess(requestId, { - content: [{ type: 'text', text: 'Human input request sent. The user has been notified. You may continue working or end your turn.' }], + content: [ + { + type: 'text', + text: 'Human input request sent. The user has been notified. You may continue working or end your turn.', + }, + ], }); } diff --git a/apps/api/src/routes/projects/_helpers.ts b/apps/api/src/routes/projects/_helpers.ts index 11d04f143..3c751cc6d 100644 --- a/apps/api/src/routes/projects/_helpers.ts +++ b/apps/api/src/routes/projects/_helpers.ts @@ -15,6 +15,12 @@ import { getGitHubUserAccessToken, getGitHubUserAccessTokenForOwner, } from '../../services/github-user-access-token'; +import { + getProjectGitLabRepository, + requireGitLabUserAccessToken, + requireGitLabUserAccessTokenForOwner, + verifyGitLabProjectAccess, +} from '../../services/gitlab'; export function normalizeProjectName(name: string): string { return name.trim().replace(/\s+/g, ' ').toLowerCase(); @@ -325,11 +331,27 @@ export async function requireRepositoryUserAccess( project: schema.Project, userId: string ): Promise { - // Artifacts-backed (non-github) projects have no GitHub installation to - // intersect against — they are out of scope for this gate. + // Artifacts-backed projects have no external user repository to intersect + // against — they are out of scope for this gate. if (project.repoProvider === 'artifacts') { return; } + if (project.repoProvider === 'gitlab') { + const metadata = await getProjectGitLabRepository(db, project.id); + if (!metadata) { + throw errors.forbidden('GitLab repository metadata is missing'); + } + const accessToken = await requireGitLabUserAccessToken(c, userId); + const verified = await verifyGitLabProjectAccess(c.env, accessToken, metadata.gitlabProjectId); + if ( + verified.host !== metadata.host || + verified.gitlabProjectId !== metadata.gitlabProjectId || + verified.pathWithNamespace !== metadata.pathWithNamespace + ) { + throw errors.forbidden('GitLab repository access has changed; repository no longer matches'); + } + return; + } if (project.repoProvider && project.repoProvider !== 'github') { throw errors.forbidden('Unsupported repository provider'); } @@ -358,6 +380,22 @@ export async function requireRepositoryOwnerAccess( if (project.repoProvider === 'artifacts') { return; } + if (project.repoProvider === 'gitlab') { + const metadata = await getProjectGitLabRepository(db, project.id); + if (!metadata) { + throw errors.forbidden('GitLab repository metadata is missing'); + } + const accessToken = await requireGitLabUserAccessTokenForOwner(env, userId, flow); + const verified = await verifyGitLabProjectAccess(env, accessToken, metadata.gitlabProjectId); + if ( + verified.host !== metadata.host || + verified.gitlabProjectId !== metadata.gitlabProjectId || + verified.pathWithNamespace !== metadata.pathWithNamespace + ) { + throw errors.forbidden('GitLab repository access has changed; repository no longer matches'); + } + return; + } if (project.repoProvider && project.repoProvider !== 'github') { throw errors.forbidden('Unsupported repository provider'); } diff --git a/apps/api/src/routes/projects/crud.ts b/apps/api/src/routes/projects/crud.ts index 72f85c548..4cd6a9d73 100644 --- a/apps/api/src/routes/projects/crud.ts +++ b/apps/api/src/routes/projects/crud.ts @@ -47,6 +47,11 @@ import { import { seedArtifactsReadme } from '../../services/artifacts/seed-readme'; import { encrypt } from '../../services/encryption'; import { getExternalInstallationId } from '../../services/github-installation-ids'; +import { + listGitLabBranches, + requireGitLabUserAccessToken, + verifyGitLabProjectAccess, +} from '../../services/gitlab'; import { getRuntimeLimits } from '../../services/limits'; import * as projectDataService from '../../services/project-data'; import { getProjectMultiplayerState } from '../../services/project-multiplayer'; @@ -103,7 +108,12 @@ crudRoutes.post('/', jsonValidator(CreateProjectSchema), async (c) => { const body = c.req.valid('json'); const name = body.name?.trim(); - const repoProvider: RepoProvider = body.repoProvider === 'artifacts' ? 'artifacts' : 'github'; + const repoProvider: RepoProvider = + body.repoProvider === 'artifacts' + ? 'artifacts' + : body.repoProvider === 'gitlab' + ? 'gitlab' + : 'github'; const description = body.description?.trim() || null; if (!name) { @@ -258,6 +268,79 @@ crudRoutes.post('/', jsonValidator(CreateProjectSchema), async (c) => { }); throw dbError; } + } else if (repoProvider === 'gitlab') { + // ─── GitLab-backed project ──────────────────────────────────────── + const gitlabProjectId = typeof body.gitlabProjectId === 'number' ? body.gitlabProjectId : null; + if (!gitlabProjectId || !Number.isFinite(gitlabProjectId) || gitlabProjectId <= 0) { + throw errors.badRequest('gitlabProjectId is required for GitLab projects'); + } + + const accessToken = await requireGitLabUserAccessToken(c, userId); + const metadata = await verifyGitLabProjectAccess(c.env, accessToken, gitlabProjectId); + const selectedBranch = body.defaultBranch?.trim() || metadata.defaultBranch; + if (selectedBranch.length > 255 || !/^[a-zA-Z0-9._\-/]+$/.test(selectedBranch)) { + throw errors.badRequest( + 'defaultBranch contains invalid characters. Only alphanumeric, hyphens, underscores, slashes, and dots are allowed (max 255 chars).' + ); + } + if (selectedBranch !== metadata.defaultBranch) { + const branches = await listGitLabBranches(c.env, accessToken, gitlabProjectId); + if (!branches.some((branch) => branch.name === selectedBranch)) { + throw errors.badRequest('Selected GitLab branch does not exist'); + } + } + + const duplicateRows = await db + .select({ id: schema.projectGitlabRepositories.id }) + .from(schema.projectGitlabRepositories) + .where( + and( + eq(schema.projectGitlabRepositories.userId, userId), + eq(schema.projectGitlabRepositories.host, metadata.host), + eq(schema.projectGitlabRepositories.gitlabProjectId, metadata.gitlabProjectId) + ) + ) + .limit(1); + if (duplicateRows[0]) { + throw errors.conflict('A project with this GitLab repository already exists'); + } + + await db.insert(schema.projects).values({ + id: projectId, + userId, + name, + normalizedName, + description, + installationId: + c.env.TRIAL_ANONYMOUS_INSTALLATION_ID ?? + 'system_anonymous_trials_installation', + repository: metadata.pathWithNamespace, + defaultBranch: selectedBranch, + repoProvider: 'gitlab', + createdBy: userId, + createdAt: now, + updatedAt: now, + }); + + try { + await db.insert(schema.projectGitlabRepositories).values({ + id: ulid(), + projectId, + userId, + host: metadata.host, + gitlabProjectId: metadata.gitlabProjectId, + pathWithNamespace: metadata.pathWithNamespace, + webUrl: metadata.webUrl, + httpUrlToRepo: metadata.httpUrlToRepo, + defaultBranch: selectedBranch, + createdAt: now, + updatedAt: now, + }); + await createOwnerProjectMembership(db, projectId, userId, userId, now); + } catch (dbError) { + await db.delete(schema.projects).where(eq(schema.projects.id, projectId)); + throw dbError; + } } else { // ─── GitHub-backed project (existing flow) ────────────────────────── const installationId = body.installationId?.trim(); @@ -1013,6 +1096,11 @@ crudRoutes.delete('/:id', async (c) => { .delete(schema.projectGithubRepositories) .where(eq(schema.projectGithubRepositories.projectId, projectId)), ); + statements.push( + db + .delete(schema.projectGitlabRepositories) + .where(eq(schema.projectGitlabRepositories.projectId, projectId)), + ); // Detach workspaces (ALTER TABLE FK ON DELETE SET NULL is not enforced) statements.push( diff --git a/apps/api/src/routes/projects/repo-browse.ts b/apps/api/src/routes/projects/repo-browse.ts index 628fdfb11..192437005 100644 --- a/apps/api/src/routes/projects/repo-browse.ts +++ b/apps/api/src/routes/projects/repo-browse.ts @@ -68,7 +68,7 @@ async function resolveBrowser( externalInstallationId = getExternalInstallationId(installation); } - const browser = await resolveRepoBrowser({ project, env: c.env, externalInstallationId }); + const browser = await resolveRepoBrowser({ project, env: c.env, userId, externalInstallationId }); return { project, browser }; } diff --git a/apps/api/src/routes/workspaces/runtime.ts b/apps/api/src/routes/workspaces/runtime.ts index 5b7376563..13001fc92 100644 --- a/apps/api/src/routes/workspaces/runtime.ts +++ b/apps/api/src/routes/workspaces/runtime.ts @@ -43,6 +43,11 @@ import { import { getExternalInstallationId } from '../../services/github-installation-ids'; import { backfillProjectGithubRepoId } from '../../services/github-repo-id-backfill'; import { getGitHubUserAccessTokenForOwner } from '../../services/github-user-access-token'; +import { + getProjectGitLabRepository, + requireGitLabUserAccessTokenResultForOwner, + verifyGitLabProjectAccess, +} from '../../services/gitlab'; import { persistError } from '../../services/observability'; import { resolveProjectAgentDefault } from '../../services/project-agent-defaults'; import * as projectDataService from '../../services/project-data'; @@ -1303,6 +1308,56 @@ runtimeRoutes.post('/:id/git-token', async (c) => { }); } + if (repoProvider === 'gitlab') { + if (!workspace.projectId) { + throw errors.forbidden('GitLab workspace has no project'); + } + const metadata = await getProjectGitLabRepository(db, workspace.projectId); + if (!metadata) { + throw errors.forbidden('GitLab repository metadata is missing'); + } + if (metadata.userId !== workspace.userId) { + // The stored GitLab repo binding belongs to a different user than the + // workspace owner — vending the owner's OAuth token against another + // user's binding would cross a tenant boundary. Fail closed. + log.error('workspace_git_token.gitlab_user_mismatch', { + workspaceId: workspace.id, + projectId: workspace.projectId, + workspaceUserId: workspace.userId, + metadataUserId: metadata.userId, + action: 'rejected', + }); + throw errors.forbidden('GitLab repository is not linked for this workspace owner'); + } + const tokenResult = await requireGitLabUserAccessTokenResultForOwner( + c.env, + workspace.userId, + 'workspace-git-token' + ); + const verified = await verifyGitLabProjectAccess( + c.env, + tokenResult.accessToken, + metadata.gitlabProjectId + ); + if ( + verified.host !== metadata.host || + verified.gitlabProjectId !== metadata.gitlabProjectId || + verified.pathWithNamespace !== metadata.pathWithNamespace + ) { + throw errors.forbidden('GitLab repository access has changed; repository no longer matches'); + } + + return c.json({ + provider: 'gitlab', + token: tokenResult.accessToken, + expiresAt: tokenResult.accessTokenExpiresAt, + cloneUrl: metadata.httpUrlToRepo, + host: metadata.host, + username: 'oauth2', + repositoryPath: metadata.pathWithNamespace, + }); + } + // ─── GitHub token (existing flow) ────────────────────────────────── if (!workspace.installationId) { throw errors.notFound('Workspace has no GitHub installation'); diff --git a/apps/api/src/schemas/projects.ts b/apps/api/src/schemas/projects.ts index 94747b989..cb32bdfca 100644 --- a/apps/api/src/schemas/projects.ts +++ b/apps/api/src/schemas/projects.ts @@ -23,8 +23,9 @@ export const CreateProjectSchema = v.object({ repository: v.optional(v.string()), githubRepoId: v.optional(v.number()), githubRepoNodeId: v.optional(v.string()), + gitlabProjectId: v.optional(v.number()), defaultBranch: v.optional(v.string()), - repoProvider: v.optional(v.picklist(['github', 'artifacts'])), + repoProvider: v.optional(v.picklist(['github', 'artifacts', 'gitlab'])), }); export const UpdateProjectSchema = v.object({ diff --git a/apps/api/src/services/gitlab.ts b/apps/api/src/services/gitlab.ts new file mode 100644 index 000000000..9f1a1665c --- /dev/null +++ b/apps/api/src/services/gitlab.ts @@ -0,0 +1,650 @@ +import type { GitLabProject } from '@simple-agent-manager/shared'; +import { eq } from 'drizzle-orm'; +import { type drizzle } from 'drizzle-orm/d1'; +import { type Context } from 'hono'; +import * as v from 'valibot'; + +import { createAuth } from '../auth'; +import * as schema from '../db/schema'; +import type { Env } from '../env'; +import { log } from '../lib/logger'; +import { readResponseJson } from '../lib/runtime-validation'; +import { AppError, errors } from '../middleware/error'; +import { fetchWithTimeout, getTimeoutMs } from './fetch-timeout'; +import { getGitLabOAuthConfig } from './platform-config'; + +const MIN_GITLAB_WRITE_ACCESS_LEVEL = 30; // Developer + +// Bound every GitLab API call so a slow or unreachable GitLab host cannot hold a +// Cloudflare Worker open indefinitely. Configurable via GITLAB_API_TIMEOUT_MS. +const DEFAULT_GITLAB_API_TIMEOUT_MS = 30_000; + +const gitlabAccessSchema = v.object({ + access_level: v.number(), +}); + +const gitlabProjectSchema = v.object({ + id: v.number(), + path_with_namespace: v.string(), + name: v.string(), + visibility: v.optional(v.string()), + default_branch: v.optional(v.nullable(v.string())), + web_url: v.optional(v.nullable(v.string())), + http_url_to_repo: v.optional(v.nullable(v.string())), + permissions: v.optional( + v.nullable( + v.object({ + project_access: v.optional(v.nullable(gitlabAccessSchema)), + group_access: v.optional(v.nullable(gitlabAccessSchema)), + }) + ) + ), +}); + +const gitlabBranchSchema = v.object({ + name: v.string(), + default: v.optional(v.boolean()), +}); + +const gitlabTreeEntrySchema = v.object({ + path: v.string(), + name: v.string(), + type: v.string(), + size: v.optional(v.nullable(v.number())), +}); + +const gitlabFileSchema = v.object({ + file_path: v.string(), + size: v.number(), + encoding: v.string(), + content: v.string(), +}); + +const gitlabCompareSchema = v.object({ + diffs: v.optional( + v.array( + v.object({ + old_path: v.string(), + new_path: v.string(), + new_file: v.optional(v.boolean()), + renamed_file: v.optional(v.boolean()), + deleted_file: v.optional(v.boolean()), + diff: v.optional(v.nullable(v.string())), + too_large: v.optional(v.boolean()), + collapsed: v.optional(v.boolean()), + binary: v.optional(v.boolean()), + }) + ) + ), +}); + +type GitLabProjectApi = v.InferOutput; +type GitLabTreeEntryApi = v.InferOutput; +type GitLabFileApi = v.InferOutput; +type GitLabCompareApi = v.InferOutput; + +export type GitLabRepositoryMetadata = { + host: string; + gitlabProjectId: number; + pathWithNamespace: string; + webUrl: string | null; + httpUrlToRepo: string; + defaultBranch: string; +}; + +/** GitLabRepositoryMetadata plus the owning user, as stored in D1. */ +export type StoredGitLabRepositoryMetadata = GitLabRepositoryMetadata & { + userId: string; +}; + +export type GitLabAccessTokenResult = { + accessToken: string; + /** ISO timestamp of access-token expiry, or null when the provider did not report one. */ + accessTokenExpiresAt: string | null; +}; + +type TokenResult = { + accessToken: string | null | undefined; + accessTokenExpiresAt?: Date | string | null; + scopes?: string[]; +}; + +function isExpired(expiresAt: Date | string | null | undefined): boolean { + if (!expiresAt) { + return false; + } + const expiresAtMs = new Date(expiresAt).getTime(); + return Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now(); +} + +function availableAccessToken( + token: TokenResult, + flow: string, + userId: string +): GitLabAccessTokenResult | null { + if (!token.accessToken) { + return null; + } + const expiresAtIso = token.accessTokenExpiresAt + ? new Date(token.accessTokenExpiresAt).toISOString() + : null; + if (isExpired(token.accessTokenExpiresAt)) { + log.warn('gitlab.user_access_token_expired', { + flow, + userId, + tokenPresent: true, + accessTokenExpiresAt: expiresAtIso, + }); + return null; + } + return { accessToken: token.accessToken, accessTokenExpiresAt: expiresAtIso }; +} + +const lockedTokenResponseSchema = v.object({ + accessToken: v.nullable(v.string()), + accessTokenExpiresAt: v.nullable(v.string()), + scopes: v.optional(v.array(v.string())), +}); + +async function getDirectGitLabUserAccessTokenResultWithHeaders( + env: Env, + headers: Headers, + userId: string, + flow: string +): Promise { + try { + const auth = await createAuth(env); + const token = await auth.api.getAccessToken({ + headers, + body: { providerId: 'gitlab', userId }, + }); + log.info('gitlab.user_access_token.lookup', { + flow, + userId, + tokenPresent: Boolean(token.accessToken), + scopes: token.scopes, + }); + return availableAccessToken(token, flow, userId); + } catch (err) { + log.warn('gitlab.user_access_token_unavailable', { + flow, + userId, + tokenPresent: false, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +/** + * Resolve the user's GitLab OAuth access token, serializing lookup/refresh per + * user through the GitLabUserAccessTokenLock Durable Object. GitLab refresh + * tokens are rotating and single-use — two concurrent refreshes replay a + * consumed refresh token and revoke the whole token family upstream, so the + * entire BetterAuth getAccessToken call must run inside the per-user lock. + * Falls back to the direct (unlocked) path only when the DO binding is absent + * (local dev / Miniflare without the binding configured). + */ +export async function getGitLabUserAccessTokenResultWithHeaders( + env: Env, + headers: Headers, + userId: string, + flow: string +): Promise { + if (!env.GITLAB_USER_ACCESS_TOKEN_LOCK) { + // Without the DO lock, concurrent refreshes can replay a consumed rotating + // refresh token and revoke the token family upstream. Acceptable only in + // local dev / test harnesses that lack the binding — never in deployment. + log.warn('gitlab.user_access_token_lock_binding_absent', { + flow, + userId, + action: 'unserialized_direct_lookup', + }); + return getDirectGitLabUserAccessTokenResultWithHeaders(env, headers, userId, flow); + } + try { + const id = env.GITLAB_USER_ACCESS_TOKEN_LOCK.idFromName(userId); + const stub = env.GITLAB_USER_ACCESS_TOKEN_LOCK.get(id); + const response = await stub.fetch('https://gitlab-user-access-token-lock/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId, + flow, + headers: Array.from(headers.entries()), + }), + }); + if (!response.ok) { + log.warn('gitlab.user_access_token_unavailable', { + flow, + userId, + tokenPresent: false, + status: response.status, + }); + return null; + } + const token = await readResponseJson( + response, + lockedTokenResponseSchema, + 'gitlab.user_access_token.locked' + ); + log.info('gitlab.user_access_token.lookup', { + flow, + userId, + tokenPresent: Boolean(token.accessToken), + scopes: token.scopes, + }); + return availableAccessToken(token, flow, userId); + } catch (err) { + log.warn('gitlab.user_access_token_unavailable', { + flow, + userId, + tokenPresent: false, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +export async function getGitLabUserAccessTokenWithHeaders( + env: Env, + headers: Headers, + userId: string, + flow: string +): Promise { + const result = await getGitLabUserAccessTokenResultWithHeaders(env, headers, userId, flow); + return result?.accessToken ?? null; +} + +export async function getGitLabUserAccessToken( + c: Context<{ Bindings: Env }>, + userId: string +): Promise { + return getGitLabUserAccessTokenWithHeaders(c.env, c.req.raw.headers, userId, 'request'); +} + +export async function getGitLabUserAccessTokenForOwner( + env: Env, + userId: string, + flow = 'owner-callback' +): Promise { + return getGitLabUserAccessTokenWithHeaders(env, new Headers(), userId, flow); +} + +export async function requireGitLabUserAccessToken( + c: Context<{ Bindings: Env }>, + userId: string +): Promise { + const accessToken = await getGitLabUserAccessToken(c, userId); + if (!accessToken) { + throw new AppError( + 401, + 'GITLAB_REAUTH_REQUIRED', + 'Your GitLab authorization has expired - please sign out and back in' + ); + } + return accessToken; +} + +export async function requireGitLabUserAccessTokenForOwner( + env: Env, + userId: string, + flow = 'owner-callback' +): Promise { + const result = await requireGitLabUserAccessTokenResultForOwner(env, userId, flow); + return result.accessToken; +} + +export async function requireGitLabUserAccessTokenResultForOwner( + env: Env, + userId: string, + flow = 'owner-callback' +): Promise { + const result = await getGitLabUserAccessTokenResultWithHeaders(env, new Headers(), userId, flow); + if (!result) { + throw new AppError( + 401, + 'GITLAB_REAUTH_REQUIRED', + 'Your GitLab authorization has expired - please sign out and back in' + ); + } + return result; +} + +async function getGitLabApiBase(env: Env): Promise<{ host: string; apiBaseUrl: string }> { + const config = await getGitLabOAuthConfig(env); + if (!config) { + throw errors.badRequest('GitLab is not configured on this deployment'); + } + return { host: config.host, apiBaseUrl: config.apiBaseUrl }; +} + +async function gitlabFetch( + env: Env, + accessToken: string, + pathAndQuery: string, + init?: RequestInit +): Promise { + const { apiBaseUrl } = await getGitLabApiBase(env); + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${accessToken}`); + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json'); + } + const timeoutMs = getTimeoutMs(env.GITLAB_API_TIMEOUT_MS, DEFAULT_GITLAB_API_TIMEOUT_MS); + return fetchWithTimeout( + `${apiBaseUrl}${pathAndQuery}`, + { + ...init, + headers, + }, + timeoutMs + ); +} + +function encodeProjectId(projectId: number | string): string { + return encodeURIComponent(String(projectId)); +} + +function encodeFilePath(path: string): string { + return path + .split('/') + .filter((part) => part.length > 0) + .map((part) => encodeURIComponent(part)) + .join('%2F'); +} + +function maxAccessLevel(project: GitLabProjectApi): number { + const permissions = project.permissions; + return Math.max( + permissions?.project_access?.access_level ?? 0, + permissions?.group_access?.access_level ?? 0 + ); +} + +function mapGitLabProject(project: GitLabProjectApi): GitLabProject { + return { + id: project.id, + pathWithNamespace: project.path_with_namespace, + name: project.name, + private: project.visibility !== 'public', + defaultBranch: project.default_branch || 'main', + webUrl: project.web_url ?? null, + httpUrlToRepo: project.http_url_to_repo ?? null, + }; +} + +function gitLabRepositoryHost(configHost: string): string { + const trimmed = configHost.trim().replace(/\/+$/, ''); + try { + return new URL(trimmed).host.toLowerCase(); + } catch { + return ( + trimmed + .replace(/^https?:\/\//i, '') + .split('/')[0] + ?.toLowerCase() ?? trimmed + ); + } +} + +function gitLabWebOrigin(configHost: string): string { + const trimmed = configHost.trim().replace(/\/+$/, ''); + try { + return new URL(trimmed).origin; + } catch { + return `https://${gitLabRepositoryHost(trimmed)}`; + } +} + +function mapProjectMetadata( + configHost: string, + project: GitLabProjectApi +): GitLabRepositoryMetadata { + const defaultBranch = project.default_branch?.trim(); + const pathWithNamespace = project.path_with_namespace.trim(); + if (!defaultBranch) { + throw errors.badRequest('Selected GitLab project does not have a default branch'); + } + if (!pathWithNamespace) { + throw errors.badRequest('Selected GitLab project is missing its path'); + } + const cloneUrl = + project.http_url_to_repo?.trim() || `${gitLabWebOrigin(configHost)}/${pathWithNamespace}.git`; + return { + host: gitLabRepositoryHost(configHost), + gitlabProjectId: project.id, + pathWithNamespace, + webUrl: project.web_url ?? null, + httpUrlToRepo: cloneUrl, + defaultBranch, + }; +} + +export async function listGitLabProjects( + env: Env, + accessToken: string, + search?: string +): Promise { + const params = new URLSearchParams({ + membership: 'true', + simple: 'false', + order_by: 'last_activity_at', + sort: 'desc', + per_page: '100', + }); + const trimmedSearch = search?.trim(); + if (trimmedSearch) { + params.set('search', trimmedSearch); + } + const res = await gitlabFetch(env, accessToken, `/projects?${params.toString()}`); + if (!res.ok) { + throw new Error(`GitLab project list failed: ${res.status}`); + } + const rows = await readResponseJson(res, v.array(gitlabProjectSchema), 'gitlab.projects'); + return rows + .filter((project) => maxAccessLevel(project) >= MIN_GITLAB_WRITE_ACCESS_LEVEL) + .map(mapGitLabProject); +} + +export async function getGitLabProject( + env: Env, + accessToken: string, + projectId: number +): Promise { + const res = await gitlabFetch(env, accessToken, `/projects/${encodeProjectId(projectId)}`); + if (res.status === 404) { + throw errors.notFound('GitLab project not found'); + } + if (!res.ok) { + throw new Error(`GitLab project lookup failed: ${res.status}`); + } + return readResponseJson(res, gitlabProjectSchema, 'gitlab.project'); +} + +export async function verifyGitLabProjectAccess( + env: Env, + accessToken: string, + projectId: number +): Promise { + const { host } = await getGitLabApiBase(env); + const project = await getGitLabProject(env, accessToken, projectId); + if (maxAccessLevel(project) < MIN_GITLAB_WRITE_ACCESS_LEVEL) { + throw errors.forbidden('GitLab project requires Developer access or higher'); + } + return mapProjectMetadata(host, project); +} + +export async function listGitLabBranches( + env: Env, + accessToken: string, + projectId: number +): Promise> { + const params = new URLSearchParams({ per_page: '100' }); + const res = await gitlabFetch( + env, + accessToken, + `/projects/${encodeProjectId(projectId)}/repository/branches?${params.toString()}` + ); + if (!res.ok) { + throw new Error(`GitLab branches fetch failed: ${res.status}`); + } + const rows = await readResponseJson(res, v.array(gitlabBranchSchema), 'gitlab.branches'); + return rows.map((branch) => ({ name: branch.name, isDefault: Boolean(branch.default) })); +} + +export async function getGitLabTree( + env: Env, + accessToken: string, + projectId: number, + ref: string +): Promise<{ entries: GitLabTreeEntryApi[]; truncated: boolean }> { + const params = new URLSearchParams({ + ref, + recursive: 'true', + per_page: '100', + }); + const res = await gitlabFetch( + env, + accessToken, + `/projects/${encodeProjectId(projectId)}/repository/tree?${params.toString()}` + ); + if (!res.ok) { + throw new Error(`GitLab tree fetch failed: ${res.status}`); + } + const entries = await readResponseJson(res, v.array(gitlabTreeEntrySchema), 'gitlab.tree'); + return { entries, truncated: Boolean(res.headers.get('x-next-page')) }; +} + +export async function getGitLabFile( + env: Env, + accessToken: string, + projectId: number, + ref: string, + path: string +): Promise { + const params = new URLSearchParams({ ref }); + const res = await gitlabFetch( + env, + accessToken, + `/projects/${encodeProjectId(projectId)}/repository/files/${encodeFilePath(path)}?${params.toString()}` + ); + if (res.status === 404) { + throw new Error('File not found'); + } + if (!res.ok) { + throw new Error(`GitLab file fetch failed: ${res.status}`); + } + return readResponseJson(res, gitlabFileSchema, 'gitlab.file'); +} + +export async function getGitLabRawFile( + env: Env, + accessToken: string, + projectId: number, + ref: string, + path: string +): Promise<{ bytes: Uint8Array; contentType: string }> { + const params = new URLSearchParams({ ref }); + const res = await gitlabFetch( + env, + accessToken, + `/projects/${encodeProjectId(projectId)}/repository/files/${encodeFilePath(path)}/raw?${params.toString()}`, + { headers: { Accept: '*/*' } } + ); + if (res.status === 404) { + throw new Error('File not found'); + } + if (!res.ok) { + throw new Error(`GitLab raw file fetch failed: ${res.status}`); + } + return { + bytes: new Uint8Array(await res.arrayBuffer()), + contentType: res.headers.get('content-type') || 'application/octet-stream', + }; +} + +export async function compareGitLabRefs( + env: Env, + accessToken: string, + projectId: number, + base: string, + head: string +): Promise { + const params = new URLSearchParams({ from: base, to: head, straight: 'false' }); + const res = await gitlabFetch( + env, + accessToken, + `/projects/${encodeProjectId(projectId)}/repository/compare?${params.toString()}` + ); + if (!res.ok) { + throw new Error(`GitLab compare failed: ${res.status}`); + } + return readResponseJson(res, gitlabCompareSchema, 'gitlab.compare'); +} + +export async function ensureGitLabBranchExists(input: { + env: Env; + userId: string; + projectId: number; + branch: string; + ref: string; + flow?: string; +}): Promise { + const accessToken = await requireGitLabUserAccessTokenForOwner( + input.env, + input.userId, + input.flow ?? 'gitlab-branch-ensure' + ); + const branchPath = `/projects/${encodeProjectId(input.projectId)}/repository/branches/${encodeURIComponent(input.branch)}`; + const existing = await gitlabFetch(input.env, accessToken, branchPath); + if (existing.ok) { + return false; + } + if (existing.status !== 404) { + throw new Error(`GitLab branch lookup failed: ${existing.status}`); + } + + const params = new URLSearchParams({ branch: input.branch, ref: input.ref }); + const created = await gitlabFetch( + input.env, + accessToken, + `/projects/${encodeProjectId(input.projectId)}/repository/branches?${params.toString()}`, + { method: 'POST' } + ); + if (!created.ok) { + throw new Error(`GitLab branch create failed: ${created.status}`); + } + return true; +} + +export async function getProjectGitLabRepository( + db: ReturnType>, + projectId: string +): Promise { + const rows = await db + .select({ + userId: schema.projectGitlabRepositories.userId, + host: schema.projectGitlabRepositories.host, + gitlabProjectId: schema.projectGitlabRepositories.gitlabProjectId, + pathWithNamespace: schema.projectGitlabRepositories.pathWithNamespace, + webUrl: schema.projectGitlabRepositories.webUrl, + httpUrlToRepo: schema.projectGitlabRepositories.httpUrlToRepo, + defaultBranch: schema.projectGitlabRepositories.defaultBranch, + }) + .from(schema.projectGitlabRepositories) + .where(eq(schema.projectGitlabRepositories.projectId, projectId)) + .limit(1); + const row = rows[0]; + return row + ? { + userId: row.userId, + host: row.host, + gitlabProjectId: row.gitlabProjectId, + pathWithNamespace: row.pathWithNamespace, + webUrl: row.webUrl, + httpUrlToRepo: row.httpUrlToRepo, + defaultBranch: row.defaultBranch, + } + : null; +} diff --git a/apps/api/src/services/node-agent.ts b/apps/api/src/services/node-agent.ts index 4e5c76389..1ec39c3e4 100644 --- a/apps/api/src/services/node-agent.ts +++ b/apps/api/src/services/node-agent.ts @@ -278,6 +278,10 @@ export async function createWorkspaceOnNode( workspaceId: string; repository: string; branch: string; + repoProvider?: 'github' | 'artifacts' | 'gitlab'; + cloneUrl?: string | null; + repositoryHost?: string | null; + repositoryPath?: string | null; callbackToken: string; gitUserName?: string | null; gitUserEmail?: string | null; diff --git a/apps/api/src/services/repo-browse/gitlab.ts b/apps/api/src/services/repo-browse/gitlab.ts new file mode 100644 index 000000000..2872b5b5f --- /dev/null +++ b/apps/api/src/services/repo-browse/gitlab.ts @@ -0,0 +1,156 @@ +import type { + RepoBranchesResponse, + RepoCompareFile, + RepoCompareFileStatus, + RepoCompareResponse, + RepoFileContent, + RepoTreeEntry, + RepoTreeResponse, +} from '@simple-agent-manager/shared'; + +import type { Env } from '../../env'; +import type { GitLabRepositoryMetadata } from '../gitlab'; +import { + compareGitLabRefs, + getGitLabFile, + getGitLabRawFile, + getGitLabTree, + listGitLabBranches, + requireGitLabUserAccessTokenForOwner, +} from '../gitlab'; +import type { RepoBrowser } from './types'; +import { basename, isBinaryBytes, maxInlineBytes } from './util'; + +function mapCompareStatus(input: { + newFile?: boolean; + deletedFile?: boolean; + renamedFile?: boolean; +}): RepoCompareFileStatus { + if (input.newFile) return 'added'; + if (input.deletedFile) return 'removed'; + if (input.renamedFile) return 'renamed'; + return 'modified'; +} + +export class GitLabRepoBrowser implements RepoBrowser { + private tokenPromise: Promise | null = null; + + constructor( + private readonly metadata: GitLabRepositoryMetadata, + private readonly userId: string, + private readonly env: Env + ) {} + + private async token(): Promise { + if (!this.tokenPromise) { + this.tokenPromise = requireGitLabUserAccessTokenForOwner( + this.env, + this.userId, + 'gitlab-repo-browse' + ); + } + return this.tokenPromise; + } + + async listBranches(): Promise { + const branches = await listGitLabBranches( + this.env, + await this.token(), + this.metadata.gitlabProjectId + ); + return { + branches: branches.map((branch) => ({ + name: branch.name, + isDefault: branch.name === this.metadata.defaultBranch || branch.isDefault, + })), + truncated: false, + }; + } + + async listTree(ref: string): Promise { + const result = await getGitLabTree( + this.env, + await this.token(), + this.metadata.gitlabProjectId, + ref + ); + const entries: RepoTreeEntry[] = []; + for (const entry of result.entries) { + if (entry.type !== 'blob' && entry.type !== 'tree') continue; + entries.push({ + path: entry.path, + name: entry.name || basename(entry.path), + type: entry.type === 'tree' ? 'tree' : 'blob', + size: entry.type === 'blob' ? (entry.size ?? null) : null, + }); + } + return { ref, path: '', entries, truncated: result.truncated }; + } + + async getFile(ref: string, path: string): Promise { + const file = await getGitLabFile( + this.env, + await this.token(), + this.metadata.gitlabProjectId, + ref, + path + ); + const base: RepoFileContent = { + ref, + path, + size: file.size, + isBinary: false, + tooLarge: false, + content: null, + rawUrl: null, + }; + if (file.size > maxInlineBytes(this.env) || file.encoding !== 'base64' || !file.content) { + return { ...base, tooLarge: true }; + } + const bytes = Uint8Array.from(atob(file.content.replace(/\n/g, '')), (ch) => ch.charCodeAt(0)); + if (isBinaryBytes(bytes)) { + return { ...base, isBinary: true }; + } + return { ...base, content: new TextDecoder().decode(bytes) }; + } + + async getRawFile(ref: string, path: string): Promise<{ bytes: Uint8Array; contentType: string }> { + return getGitLabRawFile(this.env, await this.token(), this.metadata.gitlabProjectId, ref, path); + } + + async compare(base: string, head: string): Promise { + const result = await compareGitLabRefs( + this.env, + await this.token(), + this.metadata.gitlabProjectId, + base, + head + ); + const files: RepoCompareFile[] = (result.diffs ?? []).map((diff) => { + const patch = diff.diff ?? null; + return { + path: diff.new_path, + previousPath: diff.renamed_file ? diff.old_path : undefined, + status: mapCompareStatus({ + newFile: diff.new_file, + deletedFile: diff.deleted_file, + renamedFile: diff.renamed_file, + }), + additions: 0, + deletions: 0, + patch, + patchTruncated: Boolean(diff.too_large || diff.collapsed), + isBinary: Boolean(diff.binary), + }; + }); + return { + base, + head, + files, + totalAdditions: 0, + totalDeletions: 0, + filesChanged: files.length, + truncated: files.some((file) => file.patchTruncated), + }; + } +} diff --git a/apps/api/src/services/repo-browse/index.ts b/apps/api/src/services/repo-browse/index.ts index 360de842a..077a83ba2 100644 --- a/apps/api/src/services/repo-browse/index.ts +++ b/apps/api/src/services/repo-browse/index.ts @@ -1,4 +1,4 @@ -import type * as schema from '../../db/schema'; +import * as schema from '../../db/schema'; import type { Env } from '../../env'; import { errors } from '../../middleware/error'; import { GitHubRepoBrowser } from './github'; @@ -17,10 +17,12 @@ export type { RepoBrowser } from './types'; export async function resolveRepoBrowser(opts: { project: schema.Project; env: Env; + /** User whose current provider token authorizes provider-backed reads. */ + userId?: string; /** External GitHub installation id (required for GitHub-backed projects). */ externalInstallationId?: string; }): Promise { - const { project, env, externalInstallationId } = opts; + const { project, env, externalInstallationId, userId } = opts; const provider = project.repoProvider ?? 'github'; if (provider === 'github') { @@ -52,5 +54,22 @@ export async function resolveRepoBrowser(opts: { }); } + if (provider === 'gitlab') { + if (!userId) { + throw errors.badRequest('GitLab repository browsing requires a user'); + } + const { getProjectGitLabRepository } = await import('../gitlab'); + const { drizzle } = await import('drizzle-orm/d1'); + const metadata = await getProjectGitLabRepository( + drizzle(env.DATABASE, { schema }), + project.id + ); + if (!metadata) { + throw errors.badRequest('Project has no GitLab repository metadata'); + } + const { GitLabRepoBrowser } = await import('./gitlab'); + return new GitLabRepoBrowser(metadata, userId, env); + } + throw errors.badRequest(`Unsupported repository provider: ${provider}`); } diff --git a/apps/api/tests/unit/artifacts-project-creation.test.ts b/apps/api/tests/unit/artifacts-project-creation.test.ts index fc0126dcc..e5a50dfeb 100644 --- a/apps/api/tests/unit/artifacts-project-creation.test.ts +++ b/apps/api/tests/unit/artifacts-project-creation.test.ts @@ -119,10 +119,11 @@ describe('CreateProjectSchema with repoProvider', () => { }); describe('RepoProvider type', () => { - it('VALID_REPO_PROVIDERS contains both providers', () => { + it('VALID_REPO_PROVIDERS contains every supported provider', () => { expect(VALID_REPO_PROVIDERS).toContain('github'); expect(VALID_REPO_PROVIDERS).toContain('artifacts'); - expect(VALID_REPO_PROVIDERS).toHaveLength(2); + expect(VALID_REPO_PROVIDERS).toContain('gitlab'); + expect(VALID_REPO_PROVIDERS).toHaveLength(3); }); it('ARTIFACTS_DEFAULTS has expected values', () => { diff --git a/apps/api/tests/unit/durable-objects/gitlab-user-access-token-lock.test.ts b/apps/api/tests/unit/durable-objects/gitlab-user-access-token-lock.test.ts new file mode 100644 index 000000000..146f6c96d --- /dev/null +++ b/apps/api/tests/unit/durable-objects/gitlab-user-access-token-lock.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { createAuthMock, logWarnMock } = vi.hoisted(() => ({ + createAuthMock: vi.fn(), + logWarnMock: vi.fn(), +})); + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + constructor( + public ctx: unknown, + public env: unknown + ) {} + }, +})); + +vi.mock('../../../src/auth', () => ({ + createAuth: createAuthMock, +})); + +vi.mock('../../../src/lib/logger', () => ({ + log: { + warn: logWarnMock, + info: vi.fn(), + error: vi.fn(), + }, +})); + +import { GitLabUserAccessTokenLock } from '../../../src/durable-objects/gitlab-user-access-token-lock'; + +function makeRequest(): Request { + return new Request('https://gitlab-user-access-token-lock/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: 'user-1', + flow: 'test', + headers: [['cookie', 'session=abc']], + }), + }); +} + +describe('GitLabUserAccessTokenLock', () => { + beforeEach(() => { + createAuthMock.mockReset(); + logWarnMock.mockReset(); + }); + + it('serializes overlapping expired-token refreshes per user (exactly one upstream refresh)', async () => { + // Model GitLab's rotating single-use refresh tokens: the stored access + // token starts expired; the first getAccessToken call performs the + // upstream refresh (rotating the token); a properly serialized second + // call re-reads the post-rotation state and does NOT refresh again. + let storedToken = { + accessToken: 'stale-access', + accessTokenExpiresAt: new Date(Date.now() - 60_000), + scopes: ['api'], + }; + let refreshCount = 0; + + const getAccessToken = vi.fn(async () => { + const isExpired = storedToken.accessTokenExpiresAt.getTime() <= Date.now(); + if (isExpired) { + refreshCount += 1; + // Simulate upstream refresh latency so an unserialized second caller + // would observe the still-expired token and refresh again. + await new Promise((resolve) => setTimeout(resolve, 25)); + storedToken = { + accessToken: 'fresh-access', + accessTokenExpiresAt: new Date(Date.now() + 3_600_000), + scopes: ['api'], + }; + } + return storedToken; + }); + + createAuthMock.mockReturnValue({ api: { getAccessToken } }); + + const lock = new GitLabUserAccessTokenLock({} as never, {} as never); + + const [res1, res2] = await Promise.all([lock.fetch(makeRequest()), lock.fetch(makeRequest())]); + + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + const body1 = (await res1.json()) as { accessToken: string | null }; + const body2 = (await res2.json()) as { accessToken: string | null }; + expect(body1.accessToken).toBe('fresh-access'); + expect(body2.accessToken).toBe('fresh-access'); + expect(refreshCount).toBe(1); + }); + + it('returns 401 token_unavailable when BetterAuth cannot produce a token', async () => { + const getAccessToken = vi.fn(async () => { + throw new Error('FAILED_TO_GET_ACCESS_TOKEN'); + }); + createAuthMock.mockReturnValue({ api: { getAccessToken } }); + + const lock = new GitLabUserAccessTokenLock({} as never, {} as never); + const res = await lock.fetch(makeRequest()); + + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'token_unavailable' }); + expect(logWarnMock).toHaveBeenCalledWith( + 'gitlab.user_access_token_lock.unavailable', + expect.objectContaining({ flow: 'test', userId: 'user-1' }) + ); + }); + + it('rejects non-POST requests', async () => { + createAuthMock.mockReturnValue({ api: { getAccessToken: vi.fn() } }); + const lock = new GitLabUserAccessTokenLock({} as never, {} as never); + const res = await lock.fetch(new Request('https://gitlab-user-access-token-lock/token')); + expect(res.status).toBe(405); + }); + + it('rejects malformed payloads', async () => { + createAuthMock.mockReturnValue({ api: { getAccessToken: vi.fn() } }); + const lock = new GitLabUserAccessTokenLock({} as never, {} as never); + const res = await lock.fetch( + new Request('https://gitlab-user-access-token-lock/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nope: true }), + }) + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_request' }); + }); +}); diff --git a/apps/api/tests/unit/routes/project-delete.test.ts b/apps/api/tests/unit/routes/project-delete.test.ts index f0b8f31ae..5918d5396 100644 --- a/apps/api/tests/unit/routes/project-delete.test.ts +++ b/apps/api/tests/unit/routes/project-delete.test.ts @@ -162,9 +162,9 @@ describe('DELETE /api/projects/:id', () => { // With tasks: taskStatusEvents(1) + taskDependencies(2) + tasks(1) + // runtimeEnvVars(1) + runtimeFiles(1) + agentProfiles(1) + - // projectGithubRepositories(1) + projects(1) = 9 + // projectGithubRepositories(1) + projectGitlabRepositories(1) + projects(1) = 10 const deleteOps = operations.filter((o) => o.startsWith('delete:')); - expect(deleteOps.length).toBe(9); + expect(deleteOps.length).toBe(10); }); it('skips task grandchild cleanup when no tasks exist', async () => { @@ -182,9 +182,9 @@ describe('DELETE /api/projects/:id', () => { expect(response.status).toBe(200); // Without tasks: tasks(1) + runtimeEnvVars(1) + runtimeFiles(1) + agentProfiles(1) + - // projectGithubRepositories(1) + projects(1) = 6 + // projectGithubRepositories(1) + projectGitlabRepositories(1) + projects(1) = 7 const deleteOps = operations.filter((o) => o.startsWith('delete:')); - expect(deleteOps.length).toBe(6); + expect(deleteOps.length).toBe(7); }); it('nullifies workspace project_id in the batch', async () => { @@ -229,9 +229,9 @@ describe('DELETE /api/projects/:id', () => { expect(response.status).toBe(200); // All mutations should be collected and passed to batch - // With 1 task: 3 grandchild + 5 child (tasks, env, files, profiles, githubRepos) - // + 1 update + 1 project = 10 - expect(batchedStatements.length).toBe(10); + // With 1 task: 3 grandchild + 6 child (tasks, env, files, profiles, githubRepos, gitlabRepos) + // + 1 update + 1 project = 11 + expect(batchedStatements.length).toBe(11); }); it('calls requireProjectCapability for authorization', async () => { @@ -287,9 +287,9 @@ describe('DELETE /api/projects/:id', () => { // With 1 task: taskStatusEvents(1) + taskDependencies(2) + tasks(1) + // runtimeEnvVars(1) + runtimeFiles(1) + agentProfiles(1) + - // projectGithubRepositories(1) + projects(1) = 9 + // projectGithubRepositories(1) + projectGitlabRepositories(1) + projects(1) = 10 const deleteOps = operations.filter((o) => o.startsWith('delete:')); - expect(deleteOps.length).toBe(9); + expect(deleteOps.length).toBe(10); }); it('deletes the Artifacts repo after project rows are deleted', async () => { diff --git a/apps/api/tests/unit/routes/project-gitlab-creation.test.ts b/apps/api/tests/unit/routes/project-gitlab-creation.test.ts new file mode 100644 index 000000000..0022f6d73 --- /dev/null +++ b/apps/api/tests/unit/routes/project-gitlab-creation.test.ts @@ -0,0 +1,273 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Env } from '../../../src/env'; +import { projectsRoutes } from '../../../src/routes/projects'; + +/** + * Behavioral tests for the GitLab branch of `POST /api/projects` in + * `src/routes/projects/crud.ts`. These exercise the real Hono route (mounted via + * projectsRoutes) with the GitLab service boundary and D1 mocked, covering the + * full creation flow, its input/access guards, and the sidecar-insert rollback. + */ + +const mocks = vi.hoisted(() => ({ + createOwnerProjectMembership: vi.fn(), + requireProjectAccess: vi.fn(), + requireProjectCapability: vi.fn(), + requireGitLabUserAccessToken: vi.fn(), + verifyGitLabProjectAccess: vi.fn(), + listGitLabBranches: vi.fn(), +})); + +vi.mock('drizzle-orm/d1'); +vi.mock('../../../src/middleware/auth', () => ({ + requireAuth: () => vi.fn((c: any, next: any) => next()), + requireApproved: () => vi.fn((c: any, next: any) => next()), + getUserId: () => 'user-1', +})); +vi.mock('../../../src/middleware/project-auth', () => ({ + createOwnerProjectMembership: mocks.createOwnerProjectMembership, + requireProjectAccess: mocks.requireProjectAccess, + requireProjectCapability: mocks.requireProjectCapability, +})); +vi.mock('../../../src/services/gitlab', () => ({ + requireGitLabUserAccessToken: mocks.requireGitLabUserAccessToken, + verifyGitLabProjectAccess: mocks.verifyGitLabProjectAccess, + listGitLabBranches: mocks.listGitLabBranches, +})); + +const GITLAB_METADATA = { + host: 'gitlab.example.com', + gitlabProjectId: 123, + pathWithNamespace: 'group/project', + webUrl: 'https://gitlab.example.com/group/project', + httpUrlToRepo: 'https://gitlab.example.com/group/project.git', + defaultBranch: 'main', +}; + +// Row returned by the route's final load-and-respond select after a successful insert. +const CREATED_PROJECT_ROW = { + id: 'proj-1', + userId: 'user-1', + name: 'GitLab Project', + normalizedName: 'gitlab project', + description: null, + installationId: 'system_anonymous_trials_installation', + repository: 'group/project', + defaultBranch: 'main', + repoProvider: 'gitlab', + githubRepoId: null, + githubRepoNodeId: null, + artifactsRepoId: null, + createdBy: 'user-1', + createdAt: '2026-07-11T00:00:00.000Z', + updatedAt: '2026-07-11T00:00:00.000Z', +}; + +describe('POST /api/projects — GitLab provider', () => { + let app: Hono<{ Bindings: Env }>; + let whereResponses: unknown[][]; + let limitResponses: unknown[][]; + let insertedRows: unknown[]; + let deletedProjectIds: unknown[]; + let failSidecarInsert: boolean; + const mockEnv = { DATABASE: {} as D1Database } as Env; + + const createGitLabProject = ( + body: Record = { name: 'GitLab Project', repoProvider: 'gitlab', gitlabProjectId: 123 } + ) => + app.request( + '/api/projects', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + mockEnv + ); + + beforeEach(() => { + vi.clearAllMocks(); + whereResponses = []; + limitResponses = []; + insertedRows = []; + deletedProjectIds = []; + failSidecarInsert = false; + + const makeSelectBuilder = () => { + const fromBuilder = { + where: vi.fn(() => + Object.assign(Promise.resolve(whereResponses.shift() ?? []), { + limit: vi.fn(() => Promise.resolve(limitResponses.shift() ?? [])), + }) + ), + }; + return { from: vi.fn(() => fromBuilder) }; + }; + + let insertCall = 0; + const mockDB = { + select: vi.fn(() => makeSelectBuilder()), + insert: vi.fn(() => ({ + values: vi.fn((row: unknown) => { + insertCall += 1; + insertedRows.push(row); + // The second insert in the GitLab path is the projectGitlabRepositories + // sidecar — used to exercise the rollback branch. + if (failSidecarInsert && insertCall === 2) { + return Promise.reject(new Error('sidecar insert failed')); + } + return Promise.resolve(undefined); + }), + })), + delete: vi.fn(() => ({ + where: vi.fn((predicate: unknown) => { + deletedProjectIds.push(predicate); + return Promise.resolve(undefined); + }), + })), + }; + (drizzle as unknown as ReturnType).mockReturnValue(mockDB); + + mocks.requireGitLabUserAccessToken.mockResolvedValue('gl_token'); + mocks.verifyGitLabProjectAccess.mockResolvedValue(GITLAB_METADATA); + mocks.listGitLabBranches.mockResolvedValue([{ name: 'main', isDefault: true }]); + mocks.createOwnerProjectMembership.mockResolvedValue(undefined); + + app = new Hono<{ Bindings: Env }>(); + app.onError((err, c) => { + const appError = err as { statusCode?: number; error?: string; message?: string }; + if (typeof appError.statusCode === 'number' && typeof appError.error === 'string') { + return c.json({ error: appError.error, message: appError.message }, appError.statusCode); + } + return c.json({ error: 'INTERNAL_ERROR', message: err.message }, 500); + }); + app.route('/api/projects', projectsRoutes); + }); + + it('creates a GitLab project and persists provider identity to the sidecar', async () => { + // dup-name empty, dup-repo empty, final load returns the created row. + limitResponses.push([], [], [CREATED_PROJECT_ROW]); + + const res = await createGitLabProject(); + + expect(res.status).toBe(201); + expect(mocks.verifyGitLabProjectAccess).toHaveBeenCalledWith(mockEnv, 'gl_token', 123); + // First insert = projects row (repoProvider gitlab, verified path as repository). + expect(insertedRows[0]).toMatchObject({ + userId: 'user-1', + repoProvider: 'gitlab', + repository: 'group/project', + defaultBranch: 'main', + }); + // Second insert = project_gitlab_repositories sidecar with the verified identity. + expect(insertedRows[1]).toMatchObject({ + userId: 'user-1', + host: 'gitlab.example.com', + gitlabProjectId: 123, + pathWithNamespace: 'group/project', + httpUrlToRepo: 'https://gitlab.example.com/group/project.git', + defaultBranch: 'main', + }); + expect(mocks.createOwnerProjectMembership).toHaveBeenCalledTimes(1); + }); + + it('rejects creation when gitlabProjectId is missing', async () => { + const res = await createGitLabProject({ name: 'No ID', repoProvider: 'gitlab' }); + + expect(res.status).toBe(400); + expect(mocks.verifyGitLabProjectAccess).not.toHaveBeenCalled(); + expect(insertedRows).toHaveLength(0); + }); + + it('propagates 401 when the user has no live GitLab token', async () => { + mocks.requireGitLabUserAccessToken.mockRejectedValue( + Object.assign(new Error('reauth'), { statusCode: 401, error: 'GITLAB_REAUTH_REQUIRED' }) + ); + + const res = await createGitLabProject(); + + expect(res.status).toBe(401); + await expect(res.json()).resolves.toMatchObject({ error: 'GITLAB_REAUTH_REQUIRED' }); + expect(insertedRows).toHaveLength(0); + }); + + it('propagates 403 when GitLab access is insufficient', async () => { + mocks.verifyGitLabProjectAccess.mockRejectedValue( + Object.assign(new Error('forbidden'), { statusCode: 403, error: 'FORBIDDEN' }) + ); + + const res = await createGitLabProject(); + + expect(res.status).toBe(403); + expect(insertedRows).toHaveLength(0); + }); + + it('propagates 404 when the GitLab project is not found', async () => { + mocks.verifyGitLabProjectAccess.mockRejectedValue( + Object.assign(new Error('not found'), { statusCode: 404, error: 'NOT_FOUND' }) + ); + + const res = await createGitLabProject(); + + expect(res.status).toBe(404); + expect(insertedRows).toHaveLength(0); + }); + + it('accepts a non-default branch that exists on the remote', async () => { + mocks.listGitLabBranches.mockResolvedValue([ + { name: 'main', isDefault: true }, + { name: 'feature/x', isDefault: false }, + ]); + limitResponses.push([], [], [{ ...CREATED_PROJECT_ROW, defaultBranch: 'feature/x' }]); + + const res = await createGitLabProject({ + name: 'Feature Branch', + repoProvider: 'gitlab', + gitlabProjectId: 123, + defaultBranch: 'feature/x', + }); + + expect(res.status).toBe(201); + expect(mocks.listGitLabBranches).toHaveBeenCalledWith(mockEnv, 'gl_token', 123); + expect(insertedRows[0]).toMatchObject({ defaultBranch: 'feature/x' }); + }); + + it('rejects a non-default branch that does not exist on the remote', async () => { + mocks.listGitLabBranches.mockResolvedValue([{ name: 'main', isDefault: true }]); + + const res = await createGitLabProject({ + name: 'Bad Branch', + repoProvider: 'gitlab', + gitlabProjectId: 123, + defaultBranch: 'does-not-exist', + }); + + expect(res.status).toBe(400); + expect(insertedRows).toHaveLength(0); + }); + + it('rejects a duplicate GitLab repository for the same user', async () => { + // The duplicate-repo lookup (.where().limit(1)) returns an existing sidecar row. + limitResponses.push([], [{ id: 'existing-pgr' }]); + + const res = await createGitLabProject(); + + expect(res.status).toBe(409); + expect(insertedRows).toHaveLength(0); + }); + + it('rolls back the project row when the sidecar insert fails', async () => { + failSidecarInsert = true; + + const res = await createGitLabProject(); + + expect(res.status).not.toBe(201); + // The projects row was inserted, then deleted on sidecar failure. + expect(insertedRows).toHaveLength(2); + expect(deletedProjectIds).toHaveLength(1); + expect(mocks.createOwnerProjectMembership).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/tests/unit/routes/require-repository-user-access.test.ts b/apps/api/tests/unit/routes/require-repository-user-access.test.ts index f9ddefa1a..ed1b5efa2 100644 --- a/apps/api/tests/unit/routes/require-repository-user-access.test.ts +++ b/apps/api/tests/unit/routes/require-repository-user-access.test.ts @@ -10,6 +10,9 @@ import { getGitHubUserAccessToken } from '../../../src/services/github-user-acce const mocks = vi.hoisted(() => ({ getGitHubUserAccessToken: vi.fn(), getUserInstallationRepositories: vi.fn(), + getProjectGitLabRepository: vi.fn(), + requireGitLabUserAccessToken: vi.fn(), + verifyGitLabProjectAccess: vi.fn(), })); vi.mock('../../../src/services/github-user-access-token', () => ({ @@ -19,6 +22,12 @@ vi.mock('../../../src/services/github-user-access-token', () => ({ vi.mock('../../../src/services/github-app', () => ({ getUserInstallationRepositories: mocks.getUserInstallationRepositories, })); +vi.mock('../../../src/services/gitlab', () => ({ + getProjectGitLabRepository: mocks.getProjectGitLabRepository, + requireGitLabUserAccessToken: mocks.requireGitLabUserAccessToken, + requireGitLabUserAccessTokenForOwner: vi.fn(), + verifyGitLabProjectAccess: mocks.verifyGitLabProjectAccess, +})); /** * Build a Drizzle-shaped db stub whose `.select().from().where().limit(n)` @@ -37,7 +46,7 @@ function makeDb(installationRows: Array>) { } as unknown as Parameters[1]; } -const ctx = {} as Context<{ Bindings: Env }>; +const ctx = { env: {} as Env } as Context<{ Bindings: Env }>; function makeProject(overrides: Partial = {}): schema.Project { return { @@ -68,6 +77,23 @@ const VISIBLE_REPO = { describe('requireRepositoryUserAccess', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.getProjectGitLabRepository.mockResolvedValue({ + host: 'gitlab.example.com', + gitlabProjectId: 123, + pathWithNamespace: 'group/project', + webUrl: 'https://gitlab.example.com/group/project', + httpUrlToRepo: 'https://gitlab.example.com/group/project.git', + defaultBranch: 'main', + }); + mocks.requireGitLabUserAccessToken.mockResolvedValue('gitlab-token'); + mocks.verifyGitLabProjectAccess.mockResolvedValue({ + host: 'gitlab.example.com', + gitlabProjectId: 123, + pathWithNamespace: 'group/project', + webUrl: 'https://gitlab.example.com/group/project', + httpUrlToRepo: 'https://gitlab.example.com/group/project.git', + defaultBranch: 'main', + }); }); it('skips the gate for non-github (artifacts-backed) projects', async () => { @@ -81,6 +107,42 @@ describe('requireRepositoryUserAccess', () => { expect(mocks.getUserInstallationRepositories).not.toHaveBeenCalled(); }); + it('re-verifies GitLab access and exact repository identity', async () => { + const project = makeProject({ + repoProvider: 'gitlab', + installationId: '', + repository: 'group/project', + }); + + await expect( + requireRepositoryUserAccess(ctx, makeDb([]), project, 'user-1') + ).resolves.toBeUndefined(); + + expect(mocks.requireGitLabUserAccessToken).toHaveBeenCalledWith(ctx, 'user-1'); + expect(mocks.verifyGitLabProjectAccess).toHaveBeenCalledWith(ctx.env, 'gitlab-token', 123); + expect(mocks.getGitHubUserAccessToken).not.toHaveBeenCalled(); + }); + + it('rejects GitLab access when the verified repository path drifts', async () => { + mocks.verifyGitLabProjectAccess.mockResolvedValue({ + host: 'gitlab.example.com', + gitlabProjectId: 123, + pathWithNamespace: 'other/project', + webUrl: null, + httpUrlToRepo: 'https://gitlab.example.com/other/project.git', + defaultBranch: 'main', + }); + + await expect( + requireRepositoryUserAccess( + ctx, + makeDb([]), + makeProject({ repoProvider: 'gitlab', installationId: '' }), + 'user-1' + ) + ).rejects.toMatchObject({ statusCode: 403 }); + }); + it('still runs the gate for a legacy project whose repoProvider is null (falsy guard does not skip)', async () => { // The guard skips only EXPLICIT non-github providers (`repoProvider && // repoProvider !== 'github'`). A null/undefined repoProvider is a legacy @@ -93,7 +155,10 @@ describe('requireRepositoryUserAccess', () => { requireRepositoryUserAccess( ctx, makeDb([INSTALLATION_ROW]), - makeProject({ repoProvider: null as unknown as schema.Project['repoProvider'], githubRepoId: 42 }), + makeProject({ + repoProvider: null as unknown as schema.Project['repoProvider'], + githubRepoId: 42, + }), 'user-1' ) ).resolves.toBeUndefined(); @@ -124,7 +189,13 @@ describe('requireRepositoryUserAccess', () => { it('rejects with 403 when the bound repository is no longer visible to the user', async () => { mocks.getGitHubUserAccessToken.mockResolvedValue('github-user-token'); mocks.getUserInstallationRepositories.mockResolvedValue([ - { id: 7, nodeId: 'R_kgDOOther', fullName: 'acme/other-private', private: true, defaultBranch: 'main' }, + { + id: 7, + nodeId: 'R_kgDOOther', + fullName: 'acme/other-private', + private: true, + defaultBranch: 'main', + }, ]); await expect( @@ -139,12 +210,15 @@ describe('requireRepositoryUserAccess', () => { mocks.getGitHubUserAccessToken.mockResolvedValue('github-user-token'); // User can see a repo with the same full name, but a DIFFERENT id — // the repository was deleted and recreated, or the name was re-pointed. - mocks.getUserInstallationRepositories.mockResolvedValue([ - { ...VISIBLE_REPO, id: 999 }, - ]); + mocks.getUserInstallationRepositories.mockResolvedValue([{ ...VISIBLE_REPO, id: 999 }]); await expect( - requireRepositoryUserAccess(ctx, makeDb([INSTALLATION_ROW]), makeProject({ githubRepoId: 42 }), 'user-1') + requireRepositoryUserAccess( + ctx, + makeDb([INSTALLATION_ROW]), + makeProject({ githubRepoId: 42 }), + 'user-1' + ) ).rejects.toMatchObject({ statusCode: 403, message: 'GitHub repository access has changed; repository ID no longer matches', @@ -156,20 +230,21 @@ describe('requireRepositoryUserAccess', () => { mocks.getUserInstallationRepositories.mockResolvedValue([VISIBLE_REPO]); await expect( - requireRepositoryUserAccess(ctx, makeDb([INSTALLATION_ROW]), makeProject({ githubRepoId: 42 }), 'user-1') + requireRepositoryUserAccess( + ctx, + makeDb([INSTALLATION_ROW]), + makeProject({ githubRepoId: 42 }), + 'user-1' + ) ).resolves.toBeUndefined(); // Intersection uses the user OAuth token + the installation's EXTERNAL id. - expect(getUserInstallationRepositories).toHaveBeenCalledWith( - 'github-user-token', - '120081765', - { - flow: 'project-access', - userId: 'user-1', - installationId: '120081765', - repository: 'acme/allowed-private', - } - ); + expect(getUserInstallationRepositories).toHaveBeenCalledWith('github-user-token', '120081765', { + flow: 'project-access', + userId: 'user-1', + installationId: '120081765', + repository: 'acme/allowed-private', + }); }); it('skips the drift check when the project has no bound githubRepoId (legacy project)', async () => { @@ -177,7 +252,12 @@ describe('requireRepositoryUserAccess', () => { mocks.getUserInstallationRepositories.mockResolvedValue([{ ...VISIBLE_REPO, id: 12345 }]); await expect( - requireRepositoryUserAccess(ctx, makeDb([INSTALLATION_ROW]), makeProject({ githubRepoId: null }), 'user-1') + requireRepositoryUserAccess( + ctx, + makeDb([INSTALLATION_ROW]), + makeProject({ githubRepoId: null }), + 'user-1' + ) ).resolves.toBeUndefined(); }); diff --git a/apps/api/tests/unit/routes/workspace-git-token.test.ts b/apps/api/tests/unit/routes/workspace-git-token.test.ts index 75194a995..0436f8523 100644 --- a/apps/api/tests/unit/routes/workspace-git-token.test.ts +++ b/apps/api/tests/unit/routes/workspace-git-token.test.ts @@ -15,6 +15,9 @@ const mocks = vi.hoisted(() => ({ assertRepositoryAccess: vi.fn(), verifyWorkspaceCallbackAuth: vi.fn(), backfillProjectGithubRepoId: vi.fn(), + getProjectGitLabRepository: vi.fn(), + requireGitLabUserAccessTokenResultForOwner: vi.fn(), + verifyGitLabProjectAccess: vi.fn(), and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })), eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })), })); @@ -62,6 +65,11 @@ vi.mock('../../../src/services/github-cli-policy', () => { vi.mock('../../../src/services/github-repo-id-backfill', () => ({ backfillProjectGithubRepoId: mocks.backfillProjectGithubRepoId, })); +vi.mock('../../../src/services/gitlab', () => ({ + getProjectGitLabRepository: mocks.getProjectGitLabRepository, + requireGitLabUserAccessTokenResultForOwner: mocks.requireGitLabUserAccessTokenResultForOwner, + verifyGitLabProjectAccess: mocks.verifyGitLabProjectAccess, +})); describe('workspace git-token GitHub scoping', () => { let app: Hono<{ Bindings: Env }>; @@ -175,6 +183,27 @@ describe('workspace git-token GitHub scoping', () => { token: 'github-installation-token', expiresAt: '2026-06-06T19:00:00.000Z', }); + mocks.getProjectGitLabRepository.mockResolvedValue({ + userId: 'user-1', + host: 'gitlab.example.com', + gitlabProjectId: 123, + pathWithNamespace: 'group/project', + webUrl: 'https://gitlab.example.com/group/project', + httpUrlToRepo: 'https://gitlab.example.com/group/project.git', + defaultBranch: 'main', + }); + mocks.requireGitLabUserAccessTokenResultForOwner.mockResolvedValue({ + accessToken: 'gitlab-oauth-token', + accessTokenExpiresAt: '2026-07-12T12:00:00.000Z', + }); + mocks.verifyGitLabProjectAccess.mockResolvedValue({ + host: 'gitlab.example.com', + gitlabProjectId: 123, + pathWithNamespace: 'group/project', + webUrl: 'https://gitlab.example.com/group/project', + httpUrlToRepo: 'https://gitlab.example.com/group/project.git', + defaultBranch: 'main', + }); const makeSelectBuilder = () => { let whereClause: unknown = null; @@ -217,6 +246,166 @@ describe('workspace git-token GitHub scoping', () => { app.route('/ws', runtimeRoutes); }); + it('returns host/path-constrained GitLab credentials after re-verifying project access', async () => { + limitResponses.push( + [workspaceRow()], + [ + githubProjectRow({ + repoProvider: 'gitlab', + githubRepoId: null, + repository: 'group/project', + }), + ] + ); + + const res = await app.request('/ws/ws-1/git-token', { method: 'POST' }, mockEnv); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + provider: 'gitlab', + token: 'gitlab-oauth-token', + expiresAt: '2026-07-12T12:00:00.000Z', + cloneUrl: 'https://gitlab.example.com/group/project.git', + host: 'gitlab.example.com', + username: 'oauth2', + repositoryPath: 'group/project', + }); + expect(mocks.verifyGitLabProjectAccess).toHaveBeenCalledWith( + mockEnv, + 'gitlab-oauth-token', + 123 + ); + expect(mocks.getInstallationToken).not.toHaveBeenCalled(); + }); + + it('passes through a null GitLab token expiry when the provider does not report one', async () => { + limitResponses.push( + [workspaceRow()], + [ + githubProjectRow({ + repoProvider: 'gitlab', + githubRepoId: null, + repository: 'group/project', + }), + ] + ); + mocks.requireGitLabUserAccessTokenResultForOwner.mockResolvedValue({ + accessToken: 'gitlab-oauth-token', + accessTokenExpiresAt: null, + }); + + const res = await app.request('/ws/ws-1/git-token', { method: 'POST' }, mockEnv); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + provider: 'gitlab', + token: 'gitlab-oauth-token', + expiresAt: null, + }); + }); + + it('rejects GitLab token exchange when the stored repo binding belongs to a different user', async () => { + limitResponses.push( + [workspaceRow()], + [ + githubProjectRow({ + repoProvider: 'gitlab', + githubRepoId: null, + repository: 'group/project', + }), + ] + ); + mocks.getProjectGitLabRepository.mockResolvedValue({ + userId: 'user-2', + host: 'gitlab.example.com', + gitlabProjectId: 123, + pathWithNamespace: 'group/project', + webUrl: 'https://gitlab.example.com/group/project', + httpUrlToRepo: 'https://gitlab.example.com/group/project.git', + defaultBranch: 'main', + }); + + const res = await app.request('/ws/ws-1/git-token', { method: 'POST' }, mockEnv); + + expect(res.status).toBe(403); + // Fail closed BEFORE any token is minted or upstream access re-verified. + expect(mocks.requireGitLabUserAccessTokenResultForOwner).not.toHaveBeenCalled(); + expect(mocks.verifyGitLabProjectAccess).not.toHaveBeenCalled(); + }); + + it('rejects GitLab token exchange when re-verified repository identity drifts', async () => { + limitResponses.push( + [workspaceRow()], + [ + githubProjectRow({ + repoProvider: 'gitlab', + githubRepoId: null, + repository: 'group/project', + }), + ] + ); + mocks.verifyGitLabProjectAccess.mockResolvedValue({ + host: 'gitlab.example.com', + gitlabProjectId: 123, + pathWithNamespace: 'other/project', + webUrl: 'https://gitlab.example.com/other/project', + httpUrlToRepo: 'https://gitlab.example.com/other/project.git', + defaultBranch: 'main', + }); + + const res = await app.request('/ws/ws-1/git-token', { method: 'POST' }, mockEnv); + + expect(res.status).toBe(403); + expect(mocks.getInstallationToken).not.toHaveBeenCalled(); + }); + + it('propagates GITLAB_REAUTH_REQUIRED as 401 when the owner token cannot be resolved', async () => { + limitResponses.push( + [workspaceRow()], + [ + githubProjectRow({ + repoProvider: 'gitlab', + githubRepoId: null, + repository: 'group/project', + }), + ] + ); + mocks.requireGitLabUserAccessTokenResultForOwner.mockRejectedValue( + Object.assign( + new Error('Your GitLab authorization has expired - please sign out and back in'), + { statusCode: 401, error: 'GITLAB_REAUTH_REQUIRED' } + ) + ); + + const res = await app.request('/ws/ws-1/git-token', { method: 'POST' }, mockEnv); + + expect(res.status).toBe(401); + await expect(res.json()).resolves.toMatchObject({ error: 'GITLAB_REAUTH_REQUIRED' }); + // No token exists, so upstream access must never be re-verified. + expect(mocks.verifyGitLabProjectAccess).not.toHaveBeenCalled(); + }); + + it('rejects GitLab token exchange when repository metadata is missing', async () => { + limitResponses.push( + [workspaceRow()], + [ + githubProjectRow({ + repoProvider: 'gitlab', + githubRepoId: null, + repository: 'group/project', + }), + ] + ); + mocks.getProjectGitLabRepository.mockResolvedValue(null); + + const res = await app.request('/ws/ws-1/git-token', { method: 'POST' }, mockEnv); + + expect(res.status).toBe(403); + // Fail closed BEFORE any token is minted. + expect(mocks.requireGitLabUserAccessTokenResultForOwner).not.toHaveBeenCalled(); + expect(mocks.verifyGitLabProjectAccess).not.toHaveBeenCalled(); + }); + it('returns Artifacts token expiry from camelCase binding shape', async () => { const createToken = vi.fn().mockResolvedValue({ plaintext: 'artifacts-token?expires=ignored', diff --git a/apps/api/tests/unit/services/gitlab-user-access-token.test.ts b/apps/api/tests/unit/services/gitlab-user-access-token.test.ts new file mode 100644 index 000000000..e0082241c --- /dev/null +++ b/apps/api/tests/unit/services/gitlab-user-access-token.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + createAuth: vi.fn(), + logWarn: vi.fn(), + logInfo: vi.fn(), +})); + +vi.mock('../../../src/auth', () => ({ + createAuth: mocks.createAuth, +})); + +vi.mock('../../../src/lib/logger', () => ({ + log: { + warn: mocks.logWarn, + info: mocks.logInfo, + error: vi.fn(), + }, +})); + +import type { Env } from '../../../src/env'; +import { getGitLabUserAccessTokenWithHeaders } from '../../../src/services/gitlab'; + +function makeLockBinding(response: Response) { + const stubFetch = vi.fn(async () => response); + const binding = { + idFromName: vi.fn(() => 'do-id'), + get: vi.fn(() => ({ fetch: stubFetch })), + }; + return { binding, stubFetch }; +} + +describe('getGitLabUserAccessTokenWithHeaders', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('routes through the per-user DO lock when the binding is present', async () => { + const { binding, stubFetch } = makeLockBinding( + Response.json({ + accessToken: 'locked-access', + accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(), + scopes: ['api'], + }) + ); + const env = { GITLAB_USER_ACCESS_TOKEN_LOCK: binding } as unknown as Env; + const headers = new Headers({ cookie: 'session=abc' }); + + const token = await getGitLabUserAccessTokenWithHeaders(env, headers, 'user-1', 'request'); + + expect(token).toBe('locked-access'); + // The DO lock is keyed per user so overlapping refreshes serialize. + expect(binding.idFromName).toHaveBeenCalledWith('user-1'); + expect(stubFetch).toHaveBeenCalledTimes(1); + const [, init] = stubFetch.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + userId: 'user-1', + flow: 'request', + headers: [['cookie', 'session=abc']], + }); + // Proves the direct (unlocked) path was NOT used when the binding exists. + expect(mocks.createAuth).not.toHaveBeenCalled(); + }); + + it('falls back to the direct path only when the binding is absent', async () => { + mocks.createAuth.mockResolvedValue({ + api: { + getAccessToken: vi.fn(async () => ({ + accessToken: 'direct-access', + accessTokenExpiresAt: new Date(Date.now() + 3_600_000), + scopes: ['api'], + })), + }, + }); + const env = {} as unknown as Env; + + const token = await getGitLabUserAccessTokenWithHeaders( + env, + new Headers(), + 'user-1', + 'request' + ); + + expect(token).toBe('direct-access'); + expect(mocks.createAuth).toHaveBeenCalledTimes(1); + }); + + it('returns null when the DO lock reports token_unavailable', async () => { + const { binding } = makeLockBinding( + Response.json({ error: 'token_unavailable' }, { status: 401 }) + ); + const env = { GITLAB_USER_ACCESS_TOKEN_LOCK: binding } as unknown as Env; + + const token = await getGitLabUserAccessTokenWithHeaders( + env, + new Headers(), + 'user-1', + 'request' + ); + + expect(token).toBeNull(); + expect(mocks.logWarn).toHaveBeenCalledWith( + 'gitlab.user_access_token_unavailable', + expect.objectContaining({ userId: 'user-1', status: 401 }) + ); + }); + + it('returns null for an expired token returned by the DO lock', async () => { + const { binding } = makeLockBinding( + Response.json({ + accessToken: 'stale-access', + accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(), + scopes: ['api'], + }) + ); + const env = { GITLAB_USER_ACCESS_TOKEN_LOCK: binding } as unknown as Env; + + const token = await getGitLabUserAccessTokenWithHeaders( + env, + new Headers(), + 'user-1', + 'request' + ); + + expect(token).toBeNull(); + expect(mocks.logWarn).toHaveBeenCalledWith( + 'gitlab.user_access_token_expired', + expect.objectContaining({ userId: 'user-1' }) + ); + }); +}); diff --git a/apps/api/tests/unit/services/gitlab.test.ts b/apps/api/tests/unit/services/gitlab.test.ts new file mode 100644 index 000000000..08e512ca1 --- /dev/null +++ b/apps/api/tests/unit/services/gitlab.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Env } from '../../../src/env'; +import { verifyGitLabProjectAccess } from '../../../src/services/gitlab'; + +const platformConfig = vi.hoisted(() => ({ + getGitLabOAuthConfig: vi.fn(), +})); + +vi.mock('../../../src/services/platform-config', () => platformConfig); + +const originalFetch = globalThis.fetch; + +beforeEach(() => { + platformConfig.getGitLabOAuthConfig.mockResolvedValue({ + host: 'https://gitlab.example.com/', + apiBaseUrl: 'https://gitlab.example.com/api/v4', + clientId: 'client-id', + clientSecret: 'client-secret', + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + globalThis.fetch = originalFetch; +}); + +describe('verifyGitLabProjectAccess', () => { + it('returns VM-facing bare host metadata and a valid HTTPS clone fallback', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 123, + path_with_namespace: 'group/project', + name: 'project', + visibility: 'private', + default_branch: 'main', + web_url: 'https://gitlab.example.com/group/project', + permissions: { + project_access: { access_level: 30 }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + globalThis.fetch = fetchMock; + + const metadata = await verifyGitLabProjectAccess({} as Env, 'gl_token', 123); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://gitlab.example.com/api/v4/projects/123', + expect.objectContaining({ + headers: expect.any(Headers), + }) + ); + expect(metadata).toEqual({ + host: 'gitlab.example.com', + gitlabProjectId: 123, + pathWithNamespace: 'group/project', + webUrl: 'https://gitlab.example.com/group/project', + httpUrlToRepo: 'https://gitlab.example.com/group/project.git', + defaultBranch: 'main', + }); + }); + + it('rejects a project when the user has less than Developer access', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 123, + path_with_namespace: 'group/project', + name: 'project', + visibility: 'private', + default_branch: 'main', + // Reporter (20) is below the Developer (30) write threshold. + permissions: { project_access: { access_level: 20 } }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + + await expect(verifyGitLabProjectAccess({} as Env, 'gl_token', 123)).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it('surfaces a 404 from GitLab as a not-found error, not a 500', async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response('{"message":"404 Project Not Found"}', { status: 404 })); + + await expect(verifyGitLabProjectAccess({} as Env, 'gl_token', 999)).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it('inherits group access when project access is below the threshold', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 123, + path_with_namespace: 'group/project', + name: 'project', + visibility: 'private', + default_branch: 'main', + permissions: { + project_access: { access_level: 10 }, + group_access: { access_level: 40 }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + + const metadata = await verifyGitLabProjectAccess({} as Env, 'gl_token', 123); + expect(metadata.gitlabProjectId).toBe(123); + }); +}); diff --git a/apps/api/tests/unit/services/platform-config-validation.test.ts b/apps/api/tests/unit/services/platform-config-validation.test.ts new file mode 100644 index 000000000..a69c1fad3 --- /dev/null +++ b/apps/api/tests/unit/services/platform-config-validation.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import type { Env } from '../../../src/env'; +import { validatePlatformIntegrationInput } from '../../../src/services/platform-config-validation'; + +// GitLab-only inputs never trigger the GitHub/Google OAuth pings (those only +// fire when both a clientId AND clientSecret are present for that provider), +// so no fetch mocking is required here. +const env = { BASE_DOMAIN: 'example.com' } as Env; + +describe('validatePlatformIntegrationInput — GitLab host', () => { + it('accepts a plain https host', async () => { + const result = await validatePlatformIntegrationInput(env, { + gitlab: { host: 'https://gitlab.example.com' }, + }); + expect(result).toEqual({ ok: true, errors: [] }); + }); + + it('accepts an https host with a trailing slash', async () => { + const result = await validatePlatformIntegrationInput(env, { + gitlab: { host: 'https://gitlab.example.com/' }, + }); + expect(result).toEqual({ ok: true, errors: [] }); + }); + + it('allows http for localhost self-hosted development', async () => { + for (const host of ['http://localhost:8080', 'http://127.0.0.1:8080']) { + const result = await validatePlatformIntegrationInput(env, { gitlab: { host } }); + expect(result, host).toEqual({ ok: true, errors: [] }); + } + }); + + it('rejects http for non-localhost hosts', async () => { + const result = await validatePlatformIntegrationInput(env, { + gitlab: { host: 'http://gitlab.example.com' }, + }); + expect(result.ok).toBe(false); + expect(result.errors).toContain('GitLab host must use HTTPS unless it points to localhost'); + }); + + it('rejects a host containing a path', async () => { + const result = await validatePlatformIntegrationInput(env, { + gitlab: { host: 'https://gitlab.example.com/some/path' }, + }); + expect(result.ok).toBe(false); + expect(result.errors).toContain( + 'GitLab host must not include credentials, a path, query string, or fragment' + ); + }); + + it('rejects a host containing credentials, query, or fragment', async () => { + for (const host of [ + 'https://user:pass@gitlab.example.com', + 'https://gitlab.example.com/?x=1', + 'https://gitlab.example.com/#frag', + ]) { + const result = await validatePlatformIntegrationInput(env, { gitlab: { host } }); + expect(result.ok, host).toBe(false); + expect(result.errors, host).toContain( + 'GitLab host must not include credentials, a path, query string, or fragment' + ); + } + }); + + it('rejects a value that is not a URL', async () => { + const result = await validatePlatformIntegrationInput(env, { + gitlab: { host: 'not a url' }, + }); + expect(result.ok).toBe(false); + expect(result.errors).toContain('GitLab host must be a valid URL'); + }); +}); + +describe('validatePlatformIntegrationInput — GitLab client credentials', () => { + it('rejects a too-short client id and secret', async () => { + const result = await validatePlatformIntegrationInput(env, { + gitlab: { clientId: 'abc', clientSecret: 'short' }, + }); + expect(result.ok).toBe(false); + expect(result.errors).toContain('GitLab OAuth client id is too short'); + expect(result.errors).toContain('GitLab OAuth client secret is too short'); + }); + + it('accepts a complete valid GitLab configuration', async () => { + const result = await validatePlatformIntegrationInput(env, { + gitlab: { + host: 'https://gitlab.example.com', + clientId: 'gitlab-client-id', + clientSecret: 'gitlab-client-secret', + }, + }); + expect(result).toEqual({ ok: true, errors: [] }); + }); +}); diff --git a/apps/api/tests/unit/services/platform-config.test.ts b/apps/api/tests/unit/services/platform-config.test.ts index c0a2288a4..40a16cc98 100644 --- a/apps/api/tests/unit/services/platform-config.test.ts +++ b/apps/api/tests/unit/services/platform-config.test.ts @@ -199,6 +199,29 @@ describe('platform config resolver', () => { }); }); + it('returns null GitLab OAuth config when the host is missing', async () => { + const env = createEnv({ GITLAB_HOST: undefined }); + await expect(getGitLabOAuthConfig(env)).resolves.toBeNull(); + }); + + it('returns null GitLab OAuth config when the client id is missing', async () => { + const env = createEnv({ GITLAB_CLIENT_ID: undefined }); + await expect(getGitLabOAuthConfig(env)).resolves.toBeNull(); + }); + + it('returns null GitLab OAuth config when the client secret is missing', async () => { + const env = createEnv({ GITLAB_CLIENT_SECRET: undefined }); + await expect(getGitLabOAuthConfig(env)).resolves.toBeNull(); + }); + + it('normalizes a GitLab host with a path down to its origin', async () => { + const env = createEnv({ GITLAB_HOST: 'https://gitlab.example.com/some/path?x=1' }); + await expect(getGitLabOAuthConfig(env)).resolves.toMatchObject({ + host: 'https://gitlab.example.com', + apiBaseUrl: 'https://gitlab.example.com/api/v4', + }); + }); + it('skips an undecryptable runtime secret and falls back to env instead of throwing', async () => { const env = createEnv(); await env.DATABASE.prepare( diff --git a/apps/api/tests/unit/services/repo-browse-gitlab.test.ts b/apps/api/tests/unit/services/repo-browse-gitlab.test.ts new file mode 100644 index 000000000..213f14360 --- /dev/null +++ b/apps/api/tests/unit/services/repo-browse-gitlab.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Env } from '../../../src/env'; +import type { GitLabRepositoryMetadata } from '../../../src/services/gitlab'; +import { GitLabRepoBrowser } from '../../../src/services/repo-browse/gitlab'; + +const gitlab = vi.hoisted(() => ({ + compareGitLabRefs: vi.fn(), + getGitLabFile: vi.fn(), + getGitLabRawFile: vi.fn(), + getGitLabTree: vi.fn(), + listGitLabBranches: vi.fn(), + requireGitLabUserAccessTokenForOwner: vi.fn(), +})); + +vi.mock('../../../src/services/gitlab', () => gitlab); + +const metadata: GitLabRepositoryMetadata = { + host: 'gitlab.com', + gitlabProjectId: 123, + pathWithNamespace: 'group/project', + webUrl: 'https://gitlab.com/group/project', + httpUrlToRepo: 'https://gitlab.com/group/project.git', + defaultBranch: 'main', +}; + +function makeBrowser(env: Partial = {}): GitLabRepoBrowser { + return new GitLabRepoBrowser(metadata, 'user-1', env as Env); +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + gitlab.requireGitLabUserAccessTokenForOwner.mockResolvedValue('gl_token'); +}); + +describe('GitLabRepoBrowser.listBranches', () => { + it('uses the user token and marks the default branch', async () => { + gitlab.listGitLabBranches.mockResolvedValue([ + { name: 'main', isDefault: false }, + { name: 'feature', isDefault: false }, + ]); + + const res = await makeBrowser().listBranches(); + + expect(gitlab.requireGitLabUserAccessTokenForOwner).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'gitlab-repo-browse' + ); + expect(gitlab.listGitLabBranches).toHaveBeenCalledWith(expect.anything(), 'gl_token', 123); + expect(res).toEqual({ + branches: [ + { name: 'main', isDefault: true }, + { name: 'feature', isDefault: false }, + ], + truncated: false, + }); + }); +}); + +describe('GitLabRepoBrowser.listTree', () => { + it('maps blobs and trees while skipping unsupported entry types', async () => { + gitlab.getGitLabTree.mockResolvedValue({ + truncated: true, + entries: [ + { path: 'src', name: 'src', type: 'tree' }, + { path: 'src/app.ts', name: 'app.ts', type: 'blob', size: 42 }, + { path: 'vendor', name: 'vendor', type: 'commit' }, + ], + }); + + const res = await makeBrowser().listTree('main'); + + expect(res).toEqual({ + ref: 'main', + path: '', + entries: [ + { path: 'src', name: 'src', type: 'tree', size: null }, + { path: 'src/app.ts', name: 'app.ts', type: 'blob', size: 42 }, + ], + truncated: true, + }); + }); +}); + +describe('GitLabRepoBrowser.getFile', () => { + it('inlines small base64 text content', async () => { + gitlab.getGitLabFile.mockResolvedValue({ + file_path: 'README.md', + size: 5, + encoding: 'base64', + content: btoa('hello'), + }); + + const file = await makeBrowser().getFile('main', 'README.md'); + + expect(file).toMatchObject({ + ref: 'main', + path: 'README.md', + size: 5, + isBinary: false, + tooLarge: false, + content: 'hello', + }); + }); + + it('does not inline oversized content', async () => { + gitlab.getGitLabFile.mockResolvedValue({ + file_path: 'large.txt', + size: 10, + encoding: 'base64', + content: btoa('0123456789'), + }); + + const file = await makeBrowser({ REPO_BROWSE_MAX_INLINE_BYTES: '2' }).getFile( + 'main', + 'large.txt' + ); + + expect(file.tooLarge).toBe(true); + expect(file.content).toBeNull(); + }); +}); + +describe('GitLabRepoBrowser.compare', () => { + it('maps GitLab diffs into the provider-neutral compare shape', async () => { + gitlab.compareGitLabRefs.mockResolvedValue({ + diffs: [ + { old_path: 'a.ts', new_path: 'a.ts', diff: '@@ x @@' }, + { old_path: 'old.ts', new_path: 'new.ts', renamed_file: true, diff: '@@ y @@' }, + { old_path: 'gone.ts', new_path: 'gone.ts', deleted_file: true, too_large: true }, + { old_path: 'img.png', new_path: 'img.png', new_file: true, binary: true }, + ], + }); + + const res = await makeBrowser().compare('main', 'feature'); + + expect(res.files).toEqual([ + expect.objectContaining({ path: 'a.ts', status: 'modified', patch: '@@ x @@' }), + expect.objectContaining({ + path: 'new.ts', + previousPath: 'old.ts', + status: 'renamed', + }), + expect.objectContaining({ path: 'gone.ts', status: 'removed', patchTruncated: true }), + expect.objectContaining({ path: 'img.png', status: 'added', isBinary: true }), + ]); + expect(res.filesChanged).toBe(4); + expect(res.truncated).toBe(true); + }); +}); diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index 2460f4302..26f0bd2fb 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -144,6 +144,11 @@ class_name = "CodexRefreshLock" name = "GITHUB_USER_ACCESS_TOKEN_LOCK" class_name = "GitHubUserAccessTokenLock" +# Durable Object for per-user GitLab OAuth user-token refresh serialization +[[durable_objects.bindings]] +name = "GITLAB_USER_ACCESS_TOKEN_LOCK" +class_name = "GitLabUserAccessTokenLock" + # Durable Object for trial-onboarding monthly cap counter (singleton; keyed by YYYY-MM) [[durable_objects.bindings]] name = "TRIAL_COUNTER" @@ -265,6 +270,10 @@ class_name = "VmAgentContainer" tag = "v16" new_sqlite_classes = ["VmAgentContainer"] # Raw Cloudflare Container DO for instant-session vm-agent +[[migrations]] +tag = "v17" +new_classes = ["GitLabUserAccessTokenLock"] # Stateless per-user mutex for BetterAuth GitLab OAuth refresh (rotating single-use refresh tokens) + # Analytics Engine for usage tracking (Phase 1 — server-side analytics) [[analytics_engine_datasets]] binding = "ANALYTICS" diff --git a/apps/web/src/components/GitLabProjectSelector.tsx b/apps/web/src/components/GitLabProjectSelector.tsx new file mode 100644 index 000000000..92cb502e8 --- /dev/null +++ b/apps/web/src/components/GitLabProjectSelector.tsx @@ -0,0 +1,157 @@ +import type { GitLabProject } from '@simple-agent-manager/shared'; +import { Alert, Input, Spinner } from '@simple-agent-manager/ui'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { listGitLabProjects } from '../lib/api'; + +interface GitLabProjectSelectorProps { + id?: string; + value: string; + onChange: (value: string) => void; + onProjectSelect: (project: GitLabProject | null) => void; + disabled?: boolean; +} + +export function GitLabProjectSelector({ + id, + value, + onChange, + onProjectSelect, + disabled = false, +}: GitLabProjectSelectorProps) { + const [projects, setProjects] = useState([]); + const [showDropdown, setShowDropdown] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + listGitLabProjects() + .then((result) => { + if (!active) return; + setProjects(result.projects); + }) + .catch((err) => { + if (!active) return; + setProjects([]); + setError(err instanceof Error ? err.message : 'Unable to load GitLab projects'); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setShowDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const filteredProjects = useMemo(() => { + const search = value.trim().toLowerCase(); + if (!search) return projects.slice(0, 25); + return projects + .filter((project) => project.pathWithNamespace.toLowerCase().includes(search)) + .slice(0, 25); + }, [projects, value]); + + const selectProject = (project: GitLabProject) => { + onChange(project.pathWithNamespace); + onProjectSelect(project); + setShowDropdown(false); + }; + + return ( +
+
+ { + onChange(event.currentTarget.value); + onProjectSelect(null); + setShowDropdown(true); + }} + onFocus={() => { + if (projects.length > 0) setShowDropdown(true); + }} + disabled={disabled} + required + placeholder="group/project" + /> + {loading && ( +
+ +
+ )} +
+ + {showDropdown && + filteredProjects.length > 0 && + createPortal( +
{ + const rect = containerRef.current!.getBoundingClientRect(); + return { top: rect.bottom + 4, left: rect.left, width: rect.width }; + })() + : {}), + borderRadius: 'var(--sam-radius-md)', + boxShadow: 'var(--sam-shadow-overlay)', + maxHeight: '15rem', + overflowY: 'auto', + }} + > + {filteredProjects.map((project) => ( + + ))} +
, + document.body + )} + + {error && {error}} +
+ ); +} diff --git a/apps/web/src/components/project-onboarding/ProjectOnboardingWizard.tsx b/apps/web/src/components/project-onboarding/ProjectOnboardingWizard.tsx index 22b118bd7..690702543 100644 --- a/apps/web/src/components/project-onboarding/ProjectOnboardingWizard.tsx +++ b/apps/web/src/components/project-onboarding/ProjectOnboardingWizard.tsx @@ -2,6 +2,7 @@ import type { AgentInfo, AgentProfile, GitHubInstallation, + GitLabProject, Project, RepoProvider, } from '@simple-agent-manager/shared'; @@ -17,6 +18,7 @@ import { createTrigger, listAgents, listBranches, + listGitLabBranches, submitTask, } from '../../lib/api'; import { @@ -48,6 +50,7 @@ import { StepProvider } from './StepProvider'; interface ProjectOnboardingWizardProps { installations: GitHubInstallation[]; artifactsEnabled?: boolean; + gitlabEnabled?: boolean; loading?: boolean; loadError?: string | null; onRetryInstallations?: () => void; @@ -56,6 +59,7 @@ interface ProjectOnboardingWizardProps { export function ProjectOnboardingWizard({ installations, artifactsEnabled = false, + gitlabEnabled = false, loading = false, loadError, onRetryInstallations, @@ -77,6 +81,7 @@ export function ProjectOnboardingWizard({ repository: '', defaultBranch: 'main', githubRepoId: undefined as number | undefined, + gitlabProjectId: undefined as number | undefined, }); const [branches, setBranches] = useState>([]); const [branchesLoading, setBranchesLoading] = useState(false); @@ -181,10 +186,20 @@ export function ProjectOnboardingWizard({ ); const handleRepositoryChange = (value: string) => { - setProjectForm((current) => ({ ...current, repository: value, githubRepoId: undefined })); + setProjectForm((current) => ({ + ...current, + repository: value, + githubRepoId: undefined, + gitlabProjectId: undefined, + })); setBranches([]); setBranchesError(null); - setFieldErrors((current) => ({ ...current, repository: undefined, githubRepoId: undefined })); + setFieldErrors((current) => ({ + ...current, + repository: undefined, + githubRepoId: undefined, + gitlabProjectId: undefined, + })); }; const handleRepoSelect = useCallback( @@ -204,6 +219,42 @@ export function ProjectOnboardingWizard({ [fetchBranches, projectForm.installationId, projectNameTouched] ); + const handleGitLabProjectSelect = useCallback( + (gitlabProject: GitLabProject | null) => { + if (!gitlabProject) { + setProjectForm((current) => ({ ...current, gitlabProjectId: undefined })); + setBranches([]); + setBranchesError(null); + return; + } + const nextName = deriveProjectName(gitlabProject.pathWithNamespace); + setRepoDefaultBranch(gitlabProject.defaultBranch); + setProjectForm((current) => ({ + ...current, + name: projectNameTouched || current.name.trim() ? current.name : nextName, + repository: gitlabProject.pathWithNamespace, + defaultBranch: gitlabProject.defaultBranch, + gitlabProjectId: gitlabProject.id, + })); + setBranchesLoading(true); + setBranches([]); + setBranchesError(null); + listGitLabBranches(gitlabProject.id) + .then((result) => { + setBranches(result.length > 0 ? result : [{ name: gitlabProject.defaultBranch }]); + if (result.length === 0) { + setBranchesError('No branches returned. The default branch is available.'); + } + }) + .catch(() => { + setBranches([{ name: gitlabProject.defaultBranch }]); + setBranchesError('Unable to fetch branches. The default branch is available.'); + }) + .finally(() => setBranchesLoading(false)); + }, + [projectNameTouched] + ); + const handleInstallationChange = (installationId: string) => { setProjectForm((current) => ({ ...current, @@ -211,6 +262,7 @@ export function ProjectOnboardingWizard({ repository: '', defaultBranch: 'main', githubRepoId: undefined, + gitlabProjectId: undefined, })); setBranches([]); setBranchesError(null); @@ -259,6 +311,23 @@ export function ProjectOnboardingWizard({ repoProvider: 'artifacts' as const, defaultBranch: 'main', }; + } else if (repoProvider === 'gitlab') { + if (!projectForm.gitlabProjectId) { + setFieldErrors({ gitlabProjectId: 'Select a GitLab project.' }); + return; + } + if (!projectForm.defaultBranch.trim()) { + setFieldErrors({ general: 'Default branch is required.' }); + setSubmitError('Default branch is required.'); + return; + } + payload = { + name: projectForm.name.trim(), + description: projectForm.description.trim() || undefined, + repoProvider: 'gitlab' as const, + gitlabProjectId: projectForm.gitlabProjectId, + defaultBranch: projectForm.defaultBranch.trim(), + }; } else { const repository = normalizeRepository(projectForm.repository); if (!projectForm.installationId.trim()) { @@ -453,6 +522,7 @@ export function ProjectOnboardingWizard({ ); @@ -495,6 +565,7 @@ export function ProjectOnboardingWizard({ onInstallationChange={handleInstallationChange} onRepositoryChange={handleRepositoryChange} onRepoSelect={handleRepoSelect} + onGitLabProjectSelect={handleGitLabProjectSelect} onBranchChange={(value) => setProjectForm((c) => ({ ...c, defaultBranch: value }))} onNameChange={(value) => { setProjectNameTouched(true); diff --git a/apps/web/src/components/project-onboarding/StepConnect.tsx b/apps/web/src/components/project-onboarding/StepConnect.tsx index 685f2d496..c408af4a4 100644 --- a/apps/web/src/components/project-onboarding/StepConnect.tsx +++ b/apps/web/src/components/project-onboarding/StepConnect.tsx @@ -1,8 +1,9 @@ -import type { GitHubInstallation, RepoProvider } from '@simple-agent-manager/shared'; +import type { GitHubInstallation, GitLabProject, RepoProvider } from '@simple-agent-manager/shared'; import { Alert, Input } from '@simple-agent-manager/ui'; import { Link } from 'react-router'; import { BranchSelector } from '../BranchSelector'; +import { GitLabProjectSelector } from '../GitLabProjectSelector'; import { RepoSelector } from '../RepoSelector'; import { Callout, StepHeader, WhyDetails } from './explain'; import type { FieldErrors } from './shared'; @@ -17,6 +18,7 @@ interface StepConnectProps { repository: string; defaultBranch: string; githubRepoId: number | undefined; + gitlabProjectId: number | undefined; }; branches: Array<{ name: string }>; branchesLoading: boolean; @@ -30,6 +32,7 @@ interface StepConnectProps { onRepoSelect: ( repo: { fullName: string; defaultBranch: string; githubRepoId?: number } | null ) => void; + onGitLabProjectSelect: (project: GitLabProject | null) => void; onBranchChange: (value: string) => void; onNameChange: (value: string) => void; onDescriptionChange: (value: string) => void; @@ -97,10 +100,12 @@ export function StepConnect(props: Readonly) { onInstallationChange, onRepositoryChange, onRepoSelect, + onGitLabProjectSelect, onBranchChange, } = props; const isArtifacts = repoProvider === 'artifacts'; + const isGitLab = repoProvider === 'gitlab'; const renderRepoSource = () => { if (isArtifacts) { @@ -111,6 +116,45 @@ export function StepConnect(props: Readonly) { ); } + if (isGitLab) { + return ( + <> + + + + + ); + } if (installations.length === 0) { return ( @@ -197,7 +241,9 @@ export function StepConnect(props: Readonly) { lead={ isArtifacts ? 'SAM will create and host a private Git repository for this project. Agents clone it into a fresh, isolated workspace each time they run, and push their work straight back to it.' - : 'Pick the repository and branch SAM should use when it starts work. Agents clone this repo into a fresh, isolated workspace each time they run.' + : isGitLab + ? 'Pick the GitLab project and branch SAM should use when it starts work. Agents clone this project into a fresh, isolated workspace each time they run.' + : 'Pick the repository and branch SAM should use when it starts work. Agents clone this repo into a fresh, isolated workspace each time they run.' } /> @@ -228,6 +274,18 @@ export function StepConnect(props: Readonly) { main.

+ ) : isGitLab ? ( + +

+ SAM uses your GitLab OAuth connection to verify project access before each run, then + gives the workspace a short-lived credential-helper path for clone and push. It does not + export a static GitLab token into the agent environment. +

+

+ The branch you pick is the base agents branch off of. Each run works on its own branch, + so SAM never pushes to your default branch on its own. +

+
) : (

diff --git a/apps/web/src/components/project-onboarding/StepProvider.tsx b/apps/web/src/components/project-onboarding/StepProvider.tsx index 5e840c255..8ed2fbedb 100644 --- a/apps/web/src/components/project-onboarding/StepProvider.tsx +++ b/apps/web/src/components/project-onboarding/StepProvider.tsx @@ -1,5 +1,5 @@ import type { RepoProvider } from '@simple-agent-manager/shared'; -import { Check, Cloud, Github, type LucideIcon } from 'lucide-react'; +import { Check, Cloud, Github, Gitlab, type LucideIcon } from 'lucide-react'; import type { ReactNode } from 'react'; import { StepHeader, WhyDetails } from './explain'; @@ -24,6 +24,17 @@ const OPTIONS: ProviderOption[] = [ 'Best when your team already collaborates on GitHub.', ], }, + { + id: 'gitlab', + icon: Gitlab, + title: 'Connect a GitLab project', + tagline: 'Your code already lives on GitLab.', + bullets: [ + 'SAM reaches your repo through your GitLab OAuth connection.', + 'Task agents clone the project and push their branch back to GitLab.', + 'Best when your team already collaborates on GitLab.', + ], + }, { id: 'artifacts', icon: Cloud, @@ -92,27 +103,31 @@ export function StepProvider({ value, onChange, artifactsEnabled = true, + gitlabEnabled = false, note, }: Readonly<{ value: RepoProvider; onChange: (provider: RepoProvider) => void; artifactsEnabled?: boolean; + gitlabEnabled?: boolean; note?: ReactNode; }>) { - const options = artifactsEnabled - ? OPTIONS - : OPTIONS.filter((option) => option.id !== 'artifacts'); + const options = OPTIONS.filter((option) => { + if (option.id === 'artifacts') return artifactsEnabled; + if (option.id === 'gitlab') return gitlabEnabled; + return true; + }); return (

1 ? 'sm:grid-cols-2' : ''}`} + className={`grid gap-3 ${options.length > 2 ? 'lg:grid-cols-3' : options.length > 1 ? 'sm:grid-cols-2' : ''}`} > {options.map((option) => ( +

+ GitLab is the right pick when your code + already lives there and you want agents to clone and push branches back to GitLab using + your current GitLab authorization. +

SAM-hosted (Cloudflare Artifacts) is the fastest way to start from nothing: SAM provisions a private Git repo, seeds it with a diff --git a/apps/web/src/components/project-onboarding/shared.tsx b/apps/web/src/components/project-onboarding/shared.tsx index 1dc1a3730..5ede00ddf 100644 --- a/apps/web/src/components/project-onboarding/shared.tsx +++ b/apps/web/src/components/project-onboarding/shared.tsx @@ -8,7 +8,7 @@ import { ModelSelect } from '../ModelSelect'; export type SetupStatus = 'pending' | 'done' | 'skipped'; export type FieldErrors = Partial< - Record<'name' | 'repository' | 'githubRepoId' | 'general', string> + Record<'name' | 'repository' | 'githubRepoId' | 'gitlabProjectId' | 'general', string> >; export interface ProfileDraft { @@ -51,6 +51,9 @@ export function mapProjectCreateError(error: unknown): FieldErrors { if (error.message.includes('repository ID')) { return { githubRepoId: 'This GitHub repository is already linked to another project.' }; } + if (error.message.includes('GitLab')) { + return { gitlabProjectId: error.message }; + } if (error.message.includes('repository')) { return { repository: 'This repository is already linked to another project.' }; } diff --git a/apps/web/src/lib/api/gitlab.ts b/apps/web/src/lib/api/gitlab.ts new file mode 100644 index 000000000..3731a4604 --- /dev/null +++ b/apps/web/src/lib/api/gitlab.ts @@ -0,0 +1,17 @@ +import type { GitLabProjectListResponse } from '@simple-agent-manager/shared'; + +import { request } from './client'; + +export async function listGitLabProjects(search?: string): Promise { + const params = new URLSearchParams(); + if (search?.trim()) { + params.set('search', search.trim()); + } + const qs = params.toString(); + return request(`/api/gitlab/projects${qs ? `?${qs}` : ''}`); +} + +export async function listGitLabBranches(projectId: number): Promise> { + const params = new URLSearchParams({ project_id: String(projectId) }); + return request>(`/api/gitlab/branches?${params.toString()}`); +} diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index c43cc15c4..0eabb05ae 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -243,6 +243,7 @@ export { listGitHubInstallations, listRepositories, } from './github'; +export { listGitLabBranches, listGitLabProjects } from './gitlab'; export { addObservation, createKnowledgeEntity, diff --git a/apps/web/src/pages/ProjectCreate.tsx b/apps/web/src/pages/ProjectCreate.tsx index 6bc3225da..2ebc15881 100644 --- a/apps/web/src/pages/ProjectCreate.tsx +++ b/apps/web/src/pages/ProjectCreate.tsx @@ -3,9 +3,11 @@ import { Breadcrumb, PageLayout } from '@simple-agent-manager/ui'; import { useCallback, useEffect, useState } from 'react'; import { ProjectOnboardingWizard } from '../components/project-onboarding'; +import { useLoginProviders } from '../hooks/useLoginProviders'; import { getArtifactsEnabled, listGitHubInstallations } from '../lib/api'; export function ProjectCreate() { + const providers = useLoginProviders(); const [installations, setInstallations] = useState([]); const [artifactsEnabled, setArtifactsEnabled] = useState(false); const [loading, setLoading] = useState(true); @@ -43,6 +45,7 @@ export function ProjectCreate() { { + if (path.endsWith('/api/config/login-providers')) { + return respond(200, { github: true, google: false, gitlab: gitlabEnabled }); + } if (path.endsWith('/api/github/installations')) return respond(200, installations); if (path.endsWith('/api/github/repositories')) { return respond(200, { repositories: [], failedInstallations: [] }); } + if (path.endsWith('/api/gitlab/projects')) return respond(200, { projects: GITLAB_PROJECTS }); + if (path.endsWith('/api/gitlab/branches')) { + // The real GET /api/gitlab/branches route returns a bare array (c.json(branches)), + // matching the listGitLabBranches client contract — not an object wrapper. + return respond(200, [{ name: 'main' }, { name: 'feature/agent-ready' }]); + } if (path.endsWith('/api/config/artifacts-enabled')) return respond(200, { enabled: artifactsEnabled }); if (path.endsWith('/api/agents')) return respond(200, { agents: AGENTS }); @@ -69,7 +101,19 @@ async function setupMocks( // Project creation (POST) — registered after the catch-all so it wins for /api/projects. await page.route('**/api/projects', (route) => { if (route.request().method() === 'POST') { - return route.fulfill({ status: 201, json: CREATED_PROJECT }); + const body = route.request().postDataJSON() as { repoProvider?: string } | null; + return route.fulfill({ + status: 201, + json: + body?.repoProvider === 'gitlab' + ? { + ...CREATED_PROJECT, + repository: + 'platform-experiments/a-very-long-gitlab-project-name-that-wraps-cleanly', + repoProvider: 'gitlab', + } + : CREATED_PROJECT, + }); } return route.fulfill({ status: 200, json: { projects: [], total: 0, hasMore: false } }); }); @@ -123,6 +167,37 @@ test.describe('Project onboarding wizard', () => { await assertNoOverflow(page); }); + test('captures the GitLab provider and project selection path', async ({ page }) => { + await setupMocks(page, { gitlabEnabled: true }); + await gotoWizard(page); + + await page.getByRole('button', { name: /Get started/ }).click(); + await page.getByRole('button', { name: /Continue/ }).click(); + await expect(page.getByRole('heading', { name: /Where should your code live/ })).toBeVisible(); + await expect(page.getByText('Connect a GitLab project')).toBeVisible(); + await screenshot(page, 'onboarding-gitlab-01-provider'); + await assertNoOverflow(page); + + await page.getByText('Connect a GitLab project').click(); + await page.getByRole('button', { name: /Continue/ }).click(); + await expect(page.getByRole('heading', { name: 'Connect your code' })).toBeVisible(); + await page.getByLabel('GitLab project').fill('platform-experiments'); + await expect( + page.getByText('platform-experiments/a-very-long-gitlab-project-name-that-wraps-cleanly') + ).toBeVisible(); + await screenshot(page, 'onboarding-gitlab-02-project-list'); + await assertNoOverflow(page); + + await page + .getByText('platform-experiments/a-very-long-gitlab-project-name-that-wraps-cleanly') + .click(); + await expect(page.getByPlaceholder('Project name')).toHaveValue( + 'a-very-long-gitlab-project-name-that-wraps-cleanly' + ); + await screenshot(page, 'onboarding-gitlab-03-selected-project'); + await assertNoOverflow(page); + }); + test('setup steps show Create/Skip in the footer (not inside the card)', async ({ page }) => { await setupMocks(page); await gotoWizard(page); diff --git a/apps/web/tests/unit/components/project-onboarding-wizard.test.tsx b/apps/web/tests/unit/components/project-onboarding-wizard.test.tsx index 9f029a038..cc057bb70 100644 --- a/apps/web/tests/unit/components/project-onboarding-wizard.test.tsx +++ b/apps/web/tests/unit/components/project-onboarding-wizard.test.tsx @@ -15,6 +15,7 @@ const mockCreateAgentProfile = vi.fn(); const mockCreateTrigger = vi.fn(); const mockListAgents = vi.fn().mockResolvedValue({ agents: [] }); const mockListBranches = vi.fn().mockResolvedValue([{ name: 'main' }, { name: 'develop' }]); +const mockListGitLabBranches = vi.fn().mockResolvedValue([{ name: 'main' }, { name: 'develop' }]); const mockSubmitTask = vi.fn(); vi.mock('../../../src/lib/api', async (importOriginal) => ({ @@ -24,6 +25,7 @@ vi.mock('../../../src/lib/api', async (importOriginal) => ({ createTrigger: (...args: unknown[]) => mockCreateTrigger(...args), listAgents: (...args: unknown[]) => mockListAgents(...args), listBranches: (...args: unknown[]) => mockListBranches(...args), + listGitLabBranches: (...args: unknown[]) => mockListGitLabBranches(...args), submitTask: (...args: unknown[]) => mockSubmitTask(...args), })); @@ -65,6 +67,47 @@ vi.mock('../../../src/components/BranchSelector', () => ({ ), })); +vi.mock('../../../src/components/GitLabProjectSelector', () => ({ + GitLabProjectSelector: ({ + value, + onChange, + onProjectSelect, + id, + }: { + value: string; + onChange: (v: string) => void; + onProjectSelect: (project: { + id: number; + pathWithNamespace: string; + name: string; + private: boolean; + defaultBranch: string; + webUrl: string | null; + httpUrlToRepo: string | null; + }) => void; + id?: string; + }) => ( + { + const pathWithNamespace = e.target.value; + onChange(pathWithNamespace); + onProjectSelect({ + id: 123, + pathWithNamespace, + name: 'project', + private: true, + defaultBranch: 'main', + webUrl: 'https://gitlab.com/group/project', + httpUrlToRepo: 'https://gitlab.com/group/project.git', + }); + }} + /> + ), +})); + const INSTALLATIONS = [ { id: 'inst-1', accountName: 'test-org', accountType: 'Organization' as const }, ]; @@ -101,13 +144,15 @@ function renderWizard(props = {}) { } /** Walk the intro steps (welcome → how-sam-works → provider) to the connect step. */ -async function advanceToConnect(provider: 'github' | 'artifacts' = 'github') { +async function advanceToConnect(provider: 'github' | 'artifacts' | 'gitlab' = 'github') { fireEvent.click(screen.getByRole('button', { name: /Get started/ })); await screen.findByRole('heading', { name: 'How SAM works' }); fireEvent.click(screen.getByRole('button', { name: /Continue/ })); await screen.findByRole('heading', { name: /Where should your code live/ }); if (provider === 'artifacts') { fireEvent.click(screen.getByText('Let SAM host the repository')); + } else if (provider === 'gitlab') { + fireEvent.click(screen.getByText('Connect a GitLab project')); } fireEvent.click(screen.getByRole('button', { name: /Continue/ })); await screen.findByRole('heading', { @@ -124,6 +169,7 @@ describe('ProjectOnboardingWizard', () => { mockCreateTrigger.mockReset(); mockListAgents.mockReset().mockResolvedValue({ agents: MOCK_AGENTS }); mockListBranches.mockReset().mockResolvedValue([{ name: 'main' }, { name: 'develop' }]); + mockListGitLabBranches.mockReset().mockResolvedValue([{ name: 'main' }, { name: 'develop' }]); mockSubmitTask.mockReset(); }); @@ -169,6 +215,16 @@ describe('ProjectOnboardingWizard', () => { expect(screen.queryByText('Let SAM host the repository')).not.toBeInTheDocument(); }); + it('shows the GitLab option only when GitLab is enabled', async () => { + renderWizard({ gitlabEnabled: true }); + fireEvent.click(screen.getByRole('button', { name: /Get started/ })); + await screen.findByRole('heading', { name: 'How SAM works' }); + fireEvent.click(screen.getByRole('button', { name: /Continue/ })); + await screen.findByRole('heading', { name: /Where should your code live/ }); + + expect(screen.getByText('Connect a GitLab project')).toBeInTheDocument(); + }); + /* ─── SAM (Artifacts) connect path ─── */ it('creates an Artifacts project with no GitHub fields and advances to setup', async () => { @@ -236,6 +292,39 @@ describe('ProjectOnboardingWizard', () => { expect(screen.getByText(/Install the GitHub App/)).toBeInTheDocument(); }); + it('creates a GitLab project with the selected project id', async () => { + mockCreateProject.mockResolvedValue({ + ...MOCK_PROJECT, + repoProvider: 'gitlab', + repository: 'group/project', + }); + renderWizard({ gitlabEnabled: true }); + await advanceToConnect('gitlab'); + + fireEvent.change(screen.getByTestId('gitlab-project-selector'), { + target: { value: 'group/project' }, + }); + + await waitFor(() => { + expect(mockListGitLabBranches).toHaveBeenCalledWith(123); + }); + + fireEvent.click(screen.getByRole('button', { name: /Create project/ })); + + await waitFor(() => { + expect(mockCreateProject).toHaveBeenCalledWith( + expect.objectContaining({ + repoProvider: 'gitlab', + gitlabProjectId: 123, + defaultBranch: 'main', + }) + ); + }); + const payload = mockCreateProject.mock.calls[0]![0] as Record; + expect(payload.installationId).toBeUndefined(); + expect(payload.repository).toBeUndefined(); + }); + it('displays name-conflict error from a 409 response', async () => { const mod = await import('../../../src/lib/api'); mockCreateProject.mockRejectedValue( diff --git a/apps/www/src/content/docs/docs/architecture/security.md b/apps/www/src/content/docs/docs/architecture/security.md index 7f2d8da91..abff0db41 100644 --- a/apps/www/src/content/docs/docs/architecture/security.md +++ b/apps/www/src/content/docs/docs/architecture/security.md @@ -30,7 +30,7 @@ Admin-managed integration secrets stored encrypted in D1: | GitHub App private key | Installation tokens for repository access | Runtime D1 → Worker env → unset | | GitHub webhook secret | GitHub App webhook HMAC verification | Runtime D1 → Worker env → unset | | Google login OAuth client secret | Google sign-in (BetterAuth social login) | Runtime D1 → Worker env (`GOOGLE_LOGIN_CLIENT_SECRET`) → unset | -| GitLab OAuth client secret | GitLab sign-in and future repository access | Runtime D1 → Worker env (`GITLAB_CLIENT_SECRET`) → unset | +| GitLab OAuth client secret | GitLab sign-in and repository access | Runtime D1 → Worker env (`GITLAB_CLIENT_SECRET`) → unset | | Google infra OAuth client secret | GCP deployment authorization flows (separate client from login) | Worker env (`GOOGLE_CLIENT_SECRET`) → unset | ### User Credentials diff --git a/apps/www/src/content/docs/docs/guides/self-hosting.mdx b/apps/www/src/content/docs/docs/guides/self-hosting.mdx index 735ad327b..a8449d3e2 100644 --- a/apps/www/src/content/docs/docs/guides/self-hosting.mdx +++ b/apps/www/src/content/docs/docs/guides/self-hosting.mdx @@ -125,7 +125,7 @@ instead of choosing a generic prefix. | `GH_WEBHOOK_SECRET` | Optional GitHub App webhook secret; can be configured in `/setup` instead | | `GOOGLE_LOGIN_CLIENT_ID` | Optional Google **login** OAuth client ID (Sign in with Google); can be configured in `/setup` instead. Register redirect URI `https://api.yourdomain.com/api/auth/callback/google` | | `GOOGLE_LOGIN_CLIENT_SECRET` | Optional Google **login** OAuth client secret; can be configured in `/setup` instead | -| `GITLAB_HOST` | Optional GitLab OAuth host, such as `https://gitlab.com`; can be configured in `/setup` instead. Register redirect URI `https://api.yourdomain.com/api/auth/callback/gitlab` | +| `GITLAB_HOST` | Optional GitLab OAuth host, such as `https://gitlab.com`; can be configured in `/setup` instead. Register redirect URI `https://api.yourdomain.com/api/auth/callback/gitlab` and grant the `read_user` and `api` scopes (`api` is required for repository clone/push and merge-request creation in GitLab-backed workspaces) | | `GITLAB_CLIENT_ID` | Optional GitLab OAuth application ID; can be configured in `/setup` instead | | `GITLAB_CLIENT_SECRET` | Optional GitLab OAuth secret; can be configured in `/setup` instead | | `GOOGLE_CLIENT_ID` | Optional Google **infra/GCP** OAuth client ID for GCP deployment authorization (separate client from login; NOT set via `/setup`) | diff --git a/packages/shared/src/types/gitlab.ts b/packages/shared/src/types/gitlab.ts new file mode 100644 index 000000000..b4850e783 --- /dev/null +++ b/packages/shared/src/types/gitlab.ts @@ -0,0 +1,17 @@ +// ============================================================================= +// GitLab +// ============================================================================= + +export interface GitLabProject { + id: number; + pathWithNamespace: string; + name: string; + private: boolean; + defaultBranch: string; + webUrl: string | null; + httpUrlToRepo: string | null; +} + +export interface GitLabProjectListResponse { + projects: GitLabProject[]; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index fe3be8b41..bafe4a00e 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -44,6 +44,9 @@ export type { RepositoryListResponse, } from './github'; +// GitLab +export type { GitLabProject, GitLabProjectListResponse } from './gitlab'; + // Repo Browse (remote-branch git browser + diff) export type { RepoBranch, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 8843d494c..a7debfd89 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -13,7 +13,7 @@ import type { VMSize, WorkspaceProfile, WorkspaceResponse } from './workspace'; export type ProjectStatus = 'active' | 'detached'; /** Git repository provider for a project. */ -export type RepoProvider = 'github' | 'artifacts'; +export type RepoProvider = 'github' | 'artifacts' | 'gitlab'; /** * Per-project agent defaults. Keys are agent types (claude-code, openai-codex, etc.). @@ -38,9 +38,9 @@ export interface Project { installationId: string | null; repository: string; defaultBranch: string; - /** Repo provider: 'github' (default) or 'artifacts'. */ + /** Repo provider: 'github' (default), 'artifacts', or 'gitlab'. */ repoProvider: RepoProvider; - /** Cloudflare Artifacts repo ID. Null for GitHub-backed projects. */ + /** Cloudflare Artifacts repo ID. Null for GitHub/GitLab-backed projects. */ artifactsRepoId?: string | null; defaultVmSize?: VMSize | null; defaultAgentType?: string | null; @@ -107,14 +107,16 @@ export interface ProjectDetailResponse extends Project { export interface CreateProjectRequest { name: string; description?: string; - /** Required for GitHub projects. Null/omitted for Artifacts. */ + /** Required for GitHub projects. Null/omitted for Artifacts/GitLab. */ installationId?: string; - /** Required for GitHub projects. Auto-generated for Artifacts. */ + /** Required for GitHub projects. Auto-generated for Artifacts, server-derived for GitLab. */ repository?: string; githubRepoId?: number; githubRepoNodeId?: string; + /** Required for GitLab projects. Numeric GitLab project ID selected by the user. */ + gitlabProjectId?: number; defaultBranch?: string; - /** Repo provider: 'github' (default) or 'artifacts'. */ + /** Repo provider: 'github' (default), 'artifacts', or 'gitlab'. */ repoProvider?: RepoProvider; } @@ -357,11 +359,7 @@ export type ProjectMemberOffboardingResourceKind = | 'node' | 'deployment_environment' | 'project_attachment'; -export type ProjectMemberOffboardingCredentialSource = - | 'user' - | 'project' - | 'platform' - | 'unknown'; +export type ProjectMemberOffboardingCredentialSource = 'user' | 'project' | 'platform' | 'unknown'; export type ProjectMemberOffboardingAction = | 'reattach_to_project' | 'break_and_flag' @@ -518,4 +516,8 @@ export const ARTIFACTS_DEFAULTS = { } as const; /** Valid repo provider values. */ -export const VALID_REPO_PROVIDERS: readonly RepoProvider[] = ['github', 'artifacts'] as const; +export const VALID_REPO_PROVIDERS: readonly RepoProvider[] = [ + 'github', + 'artifacts', + 'gitlab', +] as const; diff --git a/packages/shared/src/vm-agent-contract.ts b/packages/shared/src/vm-agent-contract.ts index c445a2a1e..e1b8a91fe 100644 --- a/packages/shared/src/vm-agent-contract.ts +++ b/packages/shared/src/vm-agent-contract.ts @@ -59,6 +59,10 @@ export const CreateWorkspaceAgentRequestSchema = z.object({ workspaceId: z.string().min(1), repository: z.string(), branch: z.string(), + repoProvider: z.enum(['github', 'artifacts', 'gitlab']).optional(), + cloneUrl: z.string().optional(), + repositoryHost: z.string().optional(), + repositoryPath: z.string().optional(), callbackToken: z.string().optional(), gitUserName: z.string().nullish(), gitUserEmail: z.string().nullish(), @@ -94,10 +98,14 @@ export const CreateAgentSessionAgentRequestSchema = z.object({ label: z.string().nullable(), chatSessionId: z.string().optional(), projectId: z.string().optional(), - mcpServers: z.array(z.object({ - url: z.string().url(), - token: z.string(), - })).optional(), + mcpServers: z + .array( + z.object({ + url: z.string().url(), + token: z.string(), + }) + ) + .optional(), }); export type CreateAgentSessionAgentRequest = z.infer; @@ -247,7 +255,9 @@ export const AcpSessionReconciliationResponseSchema = z.object({ sessions: z.array(AcpSessionReconciliationItemSchema), }); -export type AcpSessionReconciliationResponse = z.infer; +export type AcpSessionReconciliationResponse = z.infer< + typeof AcpSessionReconciliationResponseSchema +>; // ============================================================================= // Contract Constants diff --git a/packages/vm-agent/internal/bootstrap/bootstrap.go b/packages/vm-agent/internal/bootstrap/bootstrap.go index fec285426..6d1a0a470 100644 --- a/packages/vm-agent/internal/bootstrap/bootstrap.go +++ b/packages/vm-agent/internal/bootstrap/bootstrap.go @@ -113,6 +113,10 @@ type ProvisionState struct { GitUserName string GitUserEmail string GitHubID string + RepoProvider string + CloneURL string + RepositoryHost string + RepositoryPath string ProjectEnvVars []ProjectRuntimeEnvVar ProjectFiles []ProjectRuntimeFile Lightweight bool // Skip devcontainer build, use fallback image for faster startup @@ -307,6 +311,10 @@ func PrepareWorkspace(ctx context.Context, cfg *config.Config, state ProvisionSt GitUserEmail: strings.TrimSpace(state.GitUserEmail), GitHubID: strings.TrimSpace(state.GitHubID), } + cfg.RepoProvider = strings.TrimSpace(state.RepoProvider) + cfg.CloneURL = strings.TrimSpace(state.CloneURL) + cfg.RepositoryHost = strings.TrimSpace(state.RepositoryHost) + cfg.RepositoryPath = strings.TrimSpace(state.RepositoryPath) // Create a named Docker volume for container-mode workspaces. volumeName := "" @@ -779,13 +787,13 @@ func ensureRepositoryReady(ctx context.Context, cfg *config.Config, state *boots branch = "main" } - repoURL := normalizeRepoURL(cfg.Repository) + repoURL := normalizeRepoURL(firstNonEmptyString(cfg.CloneURL, cfg.Repository)) cloneToken := "" if state != nil { cloneToken = state.GitHubToken } - cloneURL, err := withGitHubToken(repoURL, cloneToken) + cloneURL, err := withGitToken(repoURL, cloneToken, cfg) if err != nil { return fmt.Errorf("failed to prepare clone URL: %w", err) } @@ -1905,7 +1913,7 @@ func writeDefaultDevcontainerConfigForMode(cfg *config.Config, volumeName, credH // containerEnv so the helper is available during devcontainer lifecycle hooks. credLines := "" if credHelperHostPath != "" { - credLines = fmt.Sprintf(",\n \"mounts\": [\"%s\"],\n \"containerEnv\": {\n \"GIT_CONFIG_COUNT\": \"1\",\n \"GIT_CONFIG_KEY_0\": \"credential.helper\",\n \"GIT_CONFIG_VALUE_0\": \"%s\"\n }", credentialHelperMountEntry(credHelperHostPath), credentialHelperContainerPath) + credLines = fmt.Sprintf(",\n \"mounts\": [\"%s\"],\n \"containerEnv\": {\n \"GIT_CONFIG_COUNT\": \"2\",\n \"GIT_CONFIG_KEY_0\": \"credential.helper\",\n \"GIT_CONFIG_VALUE_0\": \"%s\",\n \"GIT_CONFIG_KEY_1\": \"credential.useHttpPath\",\n \"GIT_CONFIG_VALUE_1\": \"true\"\n }", credentialHelperMountEntry(credHelperHostPath), credentialHelperContainerPath) } featuresLine := "" @@ -2065,7 +2073,7 @@ func ensureGitCredentialHelper(ctx context.Context, cfg *config.Config) error { if cfg.Repository == "" { return nil } - if !needsCredentialHelper(cfg.Repository) { + if !needsCredentialHelperForConfig(cfg) { slog.Info("Repository does not need credential helper, skipping setup", "repository", cfg.Repository) return nil } @@ -2225,6 +2233,9 @@ func renderGitCredentialHelperScript(cfg *config.Config) (string, error) { if workspaceID := strings.TrimSpace(cfg.WorkspaceID); workspaceID != "" { query = "?workspaceId=" + url.QueryEscape(workspaceID) } + // Hostnames are case-insensitive; normalize once here and lowercase the + // requested host in the shell so the comparison cannot fail on casing. + allowedGitLabHost := strings.ToLower(strings.TrimSpace(cfg.RepositoryHost)) // When TLS is enabled on the VM agent, the credential helper must use https:// // with -k (skip cert verification) because the TLS cert is issued for the @@ -2248,27 +2259,46 @@ if [ "$action" != "get" ]; then fi requested_host="" +requested_path="" while IFS= read -r line; do [ -z "$line" ] && break case "$line" in host=*) requested_host="${line#host=}" ;; + path=*) requested_path="${line#path=}" ;; esac done case "$requested_host" in ""|github.com|api.github.com|artifacts.cloudflare.net|*.artifacts.cloudflare.net) ;; - *) exit 0 ;; + *) + allowed_gitlab_host=%s + requested_host_lower=$(printf '%%s' "$requested_host" | tr '[:upper:]' '[:lower:]') + if [ -z "$allowed_gitlab_host" ] || [ "$requested_host_lower" != "$allowed_gitlab_host" ]; then + exit 0 + fi + ;; esac credential_query="%s" +url_encode_query_value() { + printf '%%s' "$1" | sed 's/%%/%%25/g; s/&/%%26/g; s/=/%%3D/g; s/?/%%3F/g; s/#/%%23/g; s/+/%%2B/g; s/ /%%20/g' +} if [ -n "$requested_host" ]; then - encoded_host=$(printf '%%s' "$requested_host" | sed 's/%%/%%25/g; s/&/%%26/g; s/=/%%3D/g; s/?/%%3F/g; s/#/%%23/g; s/+/%%2B/g; s/ /%%20/g') + encoded_host=$(url_encode_query_value "$requested_host") if [ -n "$credential_query" ]; then credential_query="${credential_query}&host=${encoded_host}" else credential_query="?host=${encoded_host}" fi fi +if [ -n "$requested_path" ]; then + encoded_path=$(url_encode_query_value "$requested_path") + if [ -n "$credential_query" ]; then + credential_query="${credential_query}&path=${encoded_path}" + else + credential_query="?path=${encoded_path}" + fi +fi resolve_gateway() { ip route 2>/dev/null | awk '/default/ {print $3; exit}' @@ -2289,7 +2319,7 @@ for target in host.docker.internal "$gateway" 172.17.0.1; do done exit 0 -`, query, curlTLSFlag, scheme, cfg.Port), nil +`, shellSingleQuote(allowedGitLabHost), query, curlTLSFlag, scheme, cfg.Port), nil } // sanitizeWorkspaceID strips characters that are not alphanumeric or hyphens @@ -2322,7 +2352,7 @@ const credentialHelperContainerPath = "/usr/local/bin/git-credential-sam" // // Returns the host path of the written file, or empty string if skipped. func writeCredentialHelperToHost(cfg *config.Config) (string, error) { - if !needsCredentialHelper(cfg.Repository) { + if !needsCredentialHelperForConfig(cfg) { slog.Info("Repository does not need credential helper, skipping host-side write", "repository", cfg.Repository) return "", nil } @@ -2409,9 +2439,11 @@ func credentialHelperMountEntry(hostPath string) string { // these values will collide. See tasks/backlog/2026-03-31-git-config-count-collision.md. func credentialHelperContainerEnv() map[string]string { return map[string]string{ - "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "credential.helper", "GIT_CONFIG_VALUE_0": credentialHelperContainerPath, + "GIT_CONFIG_KEY_1": "credential.useHttpPath", + "GIT_CONFIG_VALUE_1": "true", } } @@ -2497,7 +2529,10 @@ func findDevcontainerID(ctx context.Context, cfg *config.Config) (string, error) } func configureGitCredentialHelper(ctx context.Context, containerID, helperPath string) error { - return configureSystemGit(ctx, containerID, "credential.helper", helperPath, "git credential helper") + if err := configureSystemGit(ctx, containerID, "credential.helper", helperPath, "git credential helper"); err != nil { + return err + } + return configureSystemGit(ctx, containerID, "credential.useHttpPath", "true", "git credential useHttpPath") } func configureSystemGit(ctx context.Context, containerID, key, value, label string) error { @@ -3097,6 +3132,10 @@ func normalizeRepoURL(repo string) string { } func withGitHubToken(repoURL, token string) (string, error) { + return withGitToken(repoURL, token, nil) +} + +func withGitToken(repoURL, token string, cfg *config.Config) (string, error) { if token == "" { return repoURL, nil } @@ -3111,19 +3150,34 @@ func withGitHubToken(repoURL, token string) (string, error) { // Only inject credentials for hosts we actually vend tokens for. host := strings.ToLower(u.Host) - if !gitrepo.IsKnownGitHost(host) { + isGitLabHost := cfg != nil && + strings.EqualFold(strings.TrimSpace(cfg.RepoProvider), "gitlab") && + strings.EqualFold(strings.TrimSpace(cfg.RepositoryHost), host) + if !gitrepo.IsKnownGitHost(host) && !isGitLabHost { return repoURL, nil } - // For GitHub repos use "x-access-token" username; for Artifacts use "x". + // For GitHub repos use "x-access-token"; for Artifacts use "x"; for + // GitLab OAuth use the conventional "oauth2" username. username := "x-access-token" if gitrepo.IsArtifactsHost(host) { username = "x" + } else if isGitLabHost { + username = "oauth2" } u.User = url.UserPassword(username, token) return u.String(), nil } +func firstNonEmptyString(vals ...string) string { + for _, val := range vals { + if strings.TrimSpace(val) != "" { + return strings.TrimSpace(val) + } + } + return "" +} + // needsCredentialHelper returns true if the repo requires a git credential // helper for authentication (GitHub or Cloudflare Artifacts). func needsCredentialHelper(repo string) bool { @@ -3139,6 +3193,16 @@ func needsCredentialHelper(repo string) bool { return gitrepo.IsKnownGitHost(host) } +func needsCredentialHelperForConfig(cfg *config.Config) bool { + if cfg == nil { + return false + } + if strings.TrimSpace(cfg.RepositoryHost) != "" && strings.EqualFold(strings.TrimSpace(cfg.RepoProvider), "gitlab") { + return true + } + return needsCredentialHelper(firstNonEmptyString(cfg.CloneURL, cfg.Repository)) +} + func redactSecret(input, secret string) string { if secret == "" { return input diff --git a/packages/vm-agent/internal/bootstrap/bootstrap_test.go b/packages/vm-agent/internal/bootstrap/bootstrap_test.go index b90502180..1e1053be1 100644 --- a/packages/vm-agent/internal/bootstrap/bootstrap_test.go +++ b/packages/vm-agent/internal/bootstrap/bootstrap_test.go @@ -103,6 +103,31 @@ func TestWithGitHubToken(t *testing.T) { } } +func TestWithGitTokenGitLab(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + RepoProvider: "gitlab", + RepositoryHost: "gitlab.com", + } + + urlWithToken, err := withGitToken("https://gitlab.com/group/project.git", "gl_token", cfg) + if err != nil { + t.Fatalf("withGitToken returned error: %v", err) + } + if urlWithToken != "https://oauth2:gl_token@gitlab.com/group/project.git" { + t.Fatalf("unexpected GitLab tokenized url: %s", urlWithToken) + } + + otherURL, err := withGitToken("https://gitlab.example.com/group/project.git", "gl_token", cfg) + if err != nil { + t.Fatalf("withGitToken returned error for other host: %v", err) + } + if otherURL != "https://gitlab.example.com/group/project.git" { + t.Fatalf("expected non-configured GitLab host unchanged, got: %s", otherURL) + } +} + func TestNeedsCredentialHelper(t *testing.T) { t.Parallel() @@ -129,6 +154,24 @@ func TestNeedsCredentialHelper(t *testing.T) { } } +func TestNeedsCredentialHelperForConfigGitLab(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + RepoProvider: "gitlab", + RepositoryHost: "gitlab.com", + CloneURL: "https://gitlab.com/group/project.git", + } + if !needsCredentialHelperForConfig(cfg) { + t.Fatal("expected GitLab config to require a credential helper") + } + + cfg.RepoProvider = "github" + if needsCredentialHelperForConfig(cfg) { + t.Fatal("non-GitLab config with GitLab host must not require a credential helper") + } +} + func TestIsGitHubRepo(t *testing.T) { t.Parallel() @@ -268,6 +311,74 @@ func TestRenderGitCredentialHelperScriptHostFiltering(t *testing.T) { } } +// TestRenderGitCredentialHelperScriptGitLabHostCaseInsensitive verifies the +// GitLab host whitelist in the rendered helper compares hostnames +// case-insensitively (hostnames are case-insensitive per RFC 4343), and that a +// non-matching host is still rejected. +func TestRenderGitCredentialHelperScriptGitLabHostCaseInsensitive(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + wantCurl bool + }{ + {name: "exact case", host: "gitlab.example.com", wantCurl: true}, + {name: "mixed case", host: "GitLab.Example.COM", wantCurl: true}, + {name: "other host rejected", host: "evil.example.com", wantCurl: false}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Port: 8080, + CallbackToken: "callback-token-123", + WorkspaceID: "ws-gl", + RepoProvider: "gitlab", + RepositoryHost: "GitLab.Example.com", // mixed case in config too + } + script, err := renderGitCredentialHelperScript(cfg) + if err != nil { + t.Fatalf("renderGitCredentialHelperScript returned error: %v", err) + } + + tmpDir := t.TempDir() + curlLog := filepath.Join(tmpDir, "curl.log") + curlPath := filepath.Join(tmpDir, "curl") + curlScript := fmt.Sprintf("#!/bin/sh\nlast=\"\"\nfor arg in \"$@\"; do last=\"$arg\"; done\nprintf '%%s\\n' \"$last\" >> %s\nprintf 'protocol=https\\nhost=example.com\\nusername=x\\npassword=token\\n\\n'\n", shellSingleQuote(curlLog)) + if err := os.WriteFile(curlPath, []byte(curlScript), 0o755); err != nil { + t.Fatalf("write curl shim: %v", err) + } + + helperPath := filepath.Join(tmpDir, "git-credential-sam") + if err := os.WriteFile(helperPath, []byte(script), 0o755); err != nil { + t.Fatalf("write helper script: %v", err) + } + cmd := exec.Command("sh", helperPath, "get") + cmd.Stdin = strings.NewReader("protocol=https\nhost=" + tc.host + "\npath=group/project.git\n\n") + cmd.Env = append(os.Environ(), "PATH="+tmpDir+":"+os.Getenv("PATH")) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("helper script failed: %v\n%s", err, output) + } + + logBytes, readErr := os.ReadFile(curlLog) + if !tc.wantCurl { + if readErr == nil && strings.TrimSpace(string(logBytes)) != "" { + t.Fatalf("expected no curl call for %s, got %q", tc.host, string(logBytes)) + } + return + } + if readErr != nil { + t.Fatalf("expected curl call for %s: %v", tc.host, readErr) + } + }) + } +} + func TestRenderGitCredentialHelperScriptTLS(t *testing.T) { t.Parallel() @@ -2805,8 +2916,8 @@ func TestCredentialHelperContainerEnv(t *testing.T) { t.Parallel() env := credentialHelperContainerEnv() - if env["GIT_CONFIG_COUNT"] != "1" { - t.Fatalf("expected GIT_CONFIG_COUNT=1, got %q", env["GIT_CONFIG_COUNT"]) + if env["GIT_CONFIG_COUNT"] != "2" { + t.Fatalf("expected GIT_CONFIG_COUNT=2, got %q", env["GIT_CONFIG_COUNT"]) } if env["GIT_CONFIG_KEY_0"] != "credential.helper" { t.Fatalf("expected GIT_CONFIG_KEY_0=credential.helper, got %q", env["GIT_CONFIG_KEY_0"]) @@ -2814,6 +2925,12 @@ func TestCredentialHelperContainerEnv(t *testing.T) { if env["GIT_CONFIG_VALUE_0"] != credentialHelperContainerPath { t.Fatalf("expected GIT_CONFIG_VALUE_0=%s, got %q", credentialHelperContainerPath, env["GIT_CONFIG_VALUE_0"]) } + if env["GIT_CONFIG_KEY_1"] != "credential.useHttpPath" { + t.Fatalf("expected GIT_CONFIG_KEY_1=credential.useHttpPath, got %q", env["GIT_CONFIG_KEY_1"]) + } + if env["GIT_CONFIG_VALUE_1"] != "true" { + t.Fatalf("expected GIT_CONFIG_VALUE_1=true, got %q", env["GIT_CONFIG_VALUE_1"]) + } } func TestCredentialHelperMountEntry(t *testing.T) { @@ -3202,8 +3319,14 @@ func TestWriteCredentialOverrideConfig(t *testing.T) { if !ok { t.Fatalf("expected containerEnv to be a map, got %T", cfg["containerEnv"]) } - if envMap["GIT_CONFIG_COUNT"] != "1" { - t.Fatalf("expected GIT_CONFIG_COUNT=1, got %v", envMap["GIT_CONFIG_COUNT"]) + if envMap["GIT_CONFIG_COUNT"] != "2" { + t.Fatalf("expected GIT_CONFIG_COUNT=2, got %v", envMap["GIT_CONFIG_COUNT"]) + } + if envMap["GIT_CONFIG_KEY_1"] != "credential.useHttpPath" { + t.Fatalf("expected GIT_CONFIG_KEY_1=credential.useHttpPath, got %v", envMap["GIT_CONFIG_KEY_1"]) + } + if envMap["GIT_CONFIG_VALUE_1"] != "true" { + t.Fatalf("expected GIT_CONFIG_VALUE_1=true, got %v", envMap["GIT_CONFIG_VALUE_1"]) } } @@ -3416,8 +3539,14 @@ func TestWriteDefaultDevcontainerConfigWithCredentialHelper(t *testing.T) { if !ok { t.Fatalf("expected containerEnv to be a map") } - if envMap["GIT_CONFIG_COUNT"] != "1" { - t.Fatalf("expected GIT_CONFIG_COUNT=1") + if envMap["GIT_CONFIG_COUNT"] != "2" { + t.Fatalf("expected GIT_CONFIG_COUNT=2") + } + if envMap["GIT_CONFIG_KEY_1"] != "credential.useHttpPath" { + t.Fatalf("expected GIT_CONFIG_KEY_1=credential.useHttpPath") + } + if envMap["GIT_CONFIG_VALUE_1"] != "true" { + t.Fatalf("expected GIT_CONFIG_VALUE_1=true") } } diff --git a/packages/vm-agent/internal/config/config.go b/packages/vm-agent/internal/config/config.go index 511d0578d..dafe64fd9 100644 --- a/packages/vm-agent/internal/config/config.go +++ b/packages/vm-agent/internal/config/config.go @@ -121,6 +121,10 @@ type Config struct { BootstrapToken string Repository string Branch string + RepoProvider string + CloneURL string + RepositoryHost string + RepositoryPath string WorkspaceDir string BootstrapStatePath string BootstrapMaxWait time.Duration diff --git a/packages/vm-agent/internal/persistence/store.go b/packages/vm-agent/internal/persistence/store.go index 51f88f029..0e599203a 100644 --- a/packages/vm-agent/internal/persistence/store.go +++ b/packages/vm-agent/internal/persistence/store.go @@ -39,6 +39,10 @@ type WorkspaceMetadata struct { ContainerLabelVal string `json:"containerLabelValue"` WorkspaceDir string `json:"workspaceDir"` CallbackToken string `json:"callbackToken,omitempty"` + RepoProvider string `json:"repoProvider,omitempty"` + CloneURL string `json:"cloneUrl,omitempty"` + RepositoryHost string `json:"repositoryHost,omitempty"` + RepositoryPath string `json:"repositoryPath,omitempty"` Lightweight bool `json:"lightweight"` DevcontainerConfigName string `json:"devcontainerConfigName,omitempty"` UpdatedAt string `json:"updatedAt"` @@ -144,6 +148,7 @@ func (s *Store) migrate() error { migrateV6, migrateV7, migrateV8, + migrateV9, } for i := version; i < len(migrations); i++ { @@ -224,10 +229,12 @@ func (s *Store) UpsertWorkspaceMetadata(meta WorkspaceMetadata) error { _, err = s.db.Exec( `INSERT OR REPLACE INTO workspace_metadata - (workspace_id, repository, branch, container_work_dir, container_user, container_label_value, workspace_dir, callback_token, lightweight, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (workspace_id, repository, branch, container_work_dir, container_user, container_label_value, workspace_dir, callback_token, repo_provider, clone_url, repository_host, repository_path, lightweight, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, meta.WorkspaceID, meta.Repository, meta.Branch, meta.ContainerWorkDir, - meta.ContainerUser, meta.ContainerLabelVal, meta.WorkspaceDir, callbackToken, meta.Lightweight, meta.UpdatedAt, + meta.ContainerUser, meta.ContainerLabelVal, meta.WorkspaceDir, callbackToken, + meta.RepoProvider, meta.CloneURL, meta.RepositoryHost, meta.RepositoryPath, + meta.Lightweight, meta.UpdatedAt, ) if err != nil { return fmt.Errorf("upsert workspace metadata: %w", err) @@ -243,11 +250,13 @@ func (s *Store) GetWorkspaceMetadata(workspaceID string) (*WorkspaceMetadata, er var m WorkspaceMetadata err := s.db.QueryRow( - `SELECT workspace_id, repository, branch, container_work_dir, container_user, container_label_value, workspace_dir, callback_token, lightweight, updated_at + `SELECT workspace_id, repository, branch, container_work_dir, container_user, container_label_value, workspace_dir, callback_token, repo_provider, clone_url, repository_host, repository_path, lightweight, updated_at FROM workspace_metadata WHERE workspace_id = ?`, workspaceID, ).Scan(&m.WorkspaceID, &m.Repository, &m.Branch, &m.ContainerWorkDir, - &m.ContainerUser, &m.ContainerLabelVal, &m.WorkspaceDir, &m.CallbackToken, &m.Lightweight, &m.UpdatedAt) + &m.ContainerUser, &m.ContainerLabelVal, &m.WorkspaceDir, &m.CallbackToken, + &m.RepoProvider, &m.CloneURL, &m.RepositoryHost, &m.RepositoryPath, + &m.Lightweight, &m.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } @@ -485,6 +494,17 @@ func migrateV7(db *sql.DB) error { return err } +// migrateV9 adds provider-aware git source metadata for non-GitHub repositories. +func migrateV9(db *sql.DB) error { + _, err := db.Exec(` + ALTER TABLE workspace_metadata ADD COLUMN repo_provider TEXT NOT NULL DEFAULT ''; + ALTER TABLE workspace_metadata ADD COLUMN clone_url TEXT NOT NULL DEFAULT ''; + ALTER TABLE workspace_metadata ADD COLUMN repository_host TEXT NOT NULL DEFAULT ''; + ALTER TABLE workspace_metadata ADD COLUMN repository_path TEXT NOT NULL DEFAULT ''; + `) + return err +} + // UpsertSessionMcpServers replaces all MCP server entries for a session. // Passing an empty slice removes all servers for the session without error. // This is intentionally a full replace (delete + insert) so that the diff --git a/packages/vm-agent/internal/server/git_credential.go b/packages/vm-agent/internal/server/git_credential.go index 813ce0566..a3377fb75 100644 --- a/packages/vm-agent/internal/server/git_credential.go +++ b/packages/vm-agent/internal/server/git_credential.go @@ -17,10 +17,14 @@ import ( ) type gitTokenResponse struct { + Provider string `json:"provider,omitempty"` Token string `json:"token"` ExpiresAt string `json:"expiresAt"` // CloneURL is set for Artifacts-backed projects. Empty for GitHub projects. - CloneURL string `json:"cloneUrl,omitempty"` + CloneURL string `json:"cloneUrl,omitempty"` + Host string `json:"host,omitempty"` + Username string `json:"username,omitempty"` + RepositoryPath string `json:"repositoryPath,omitempty"` } func (s *Server) handleGitCredential(w http.ResponseWriter, r *http.Request) { @@ -36,7 +40,26 @@ func (s *Server) handleGitCredential(w http.ResponseWriter, r *http.Request) { bearerToken := bearerTokenFromHeader(r.Header.Get("Authorization")) requestedHost := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("host"))) - if requestedHost != "" && !gitrepo.IsKnownGitHost(requestedHost) { + requestedPath := strings.TrimSpace(r.URL.Query().Get("path")) + // GitLab-bound workspaces vend a broad user OAuth token, so the exchange is + // fail-closed: the caller must identify both the host and the repository path + // it is requesting credentials for. GitHub/Artifacts keep the empty-allow + // behavior because the gh wrapper flow sends host=github.com with no path. + boundProvider, _ := s.credentialPathBinding(workspaceID) + if strings.EqualFold(boundProvider, "gitlab") && (requestedHost == "" || requestedPath == "") { + slog.Warn("Git credential request refused: gitlab-bound workspace requires host and path", + "workspaceID", workspaceID, + "hasHost", requestedHost != "", + "hasPath", requestedPath != "", + ) + w.WriteHeader(http.StatusNoContent) + return + } + if requestedHost != "" && !s.isAllowedCredentialHostForWorkspace(workspaceID, requestedHost) { + w.WriteHeader(http.StatusNoContent) + return + } + if requestedPath != "" && !s.isAllowedCredentialPathForWorkspace(workspaceID, requestedPath) { w.WriteHeader(http.StatusNoContent) return } @@ -52,19 +75,49 @@ func (s *Server) handleGitCredential(w http.ResponseWriter, r *http.Request) { // Artifacts clone URLs use username "x"; GitHub uses "x-access-token". host := "github.com" username := "x-access-token" + repositoryPath := strings.TrimSpace(resp.RepositoryPath) if resp.CloneURL != "" { if parsed, parseErr := url.Parse(resp.CloneURL); parseErr == nil && parsed.Host != "" { host = parsed.Host if gitrepo.IsArtifactsHost(parsed.Host) { username = "x" } + if repositoryPath == "" { + repositoryPath = strings.Trim(strings.TrimSuffix(parsed.Path, ".git"), "/") + } } } - + if resp.Host != "" { + host = strings.ToLower(strings.TrimSpace(resp.Host)) + } + if resp.Username != "" { + username = strings.TrimSpace(resp.Username) + } + + // Response-side fail-closed gate for GitLab credentials: regardless of what + // the local binding said pre-fetch, a GitLab token is only released when the + // caller supplied a host and path AND both verifiably match the credential + // the control plane resolved. An empty resolved repositoryPath means we + // cannot verify the binding — refuse rather than vend a broad OAuth token. + gitlabResponse := strings.EqualFold(strings.TrimSpace(resp.Provider), "gitlab") + if gitlabResponse && (requestedHost == "" || requestedPath == "" || repositoryPath == "") { + slog.Warn("Git credential response withheld: gitlab credential requires verified host and path", + "workspaceID", workspaceID, + "hasRequestedHost", requestedHost != "", + "hasRequestedPath", requestedPath != "", + "hasResolvedPath", repositoryPath != "", + ) + w.WriteHeader(http.StatusNoContent) + return + } if requestedHost != "" && !credentialHostMatchesRequest(host, requestedHost) { w.WriteHeader(http.StatusNoContent) return } + if requestedPath != "" && repositoryPath != "" && !credentialPathMatchesRequest(repositoryPath, requestedPath) { + w.WriteHeader(http.StatusNoContent) + return + } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) @@ -86,6 +139,63 @@ func credentialHostMatchesRequest(resolvedHost, requestedHost string) bool { return requestedHost == resolvedHost } +func (s *Server) isAllowedCredentialHostForWorkspace(workspaceID, requestedHost string) bool { + requestedHost = strings.ToLower(strings.TrimSpace(requestedHost)) + if requestedHost == "" || gitrepo.IsKnownGitHost(requestedHost) { + return true + } + if runtime, ok := s.getWorkspaceRuntime(workspaceID); ok { + return strings.EqualFold(strings.TrimSpace(runtime.RepositoryHost), requestedHost) + } + return strings.EqualFold(strings.TrimSpace(s.config.RepositoryHost), requestedHost) +} + +func (s *Server) isAllowedCredentialPathForWorkspace(workspaceID, requestedPath string) bool { + requestedPath = strings.TrimSpace(requestedPath) + if requestedPath == "" { + return true + } + // Resolve the provider/path this workspace is bound to. When the workspace is + // not registered in the runtime map (standalone single-workspace mode, or a + // request racing with runtime setup), fall back to the process config instead + // of failing open — mirroring isAllowedCredentialHostForWorkspace. + provider, repositoryPath := s.credentialPathBinding(workspaceID) + if !strings.EqualFold(provider, "gitlab") { + return true + } + // Fail closed: a gitlab-bound workspace with no bound repository path cannot + // verify the request, so refuse rather than vend a broad user OAuth token. + // (The response-side gate re-checks this against the control-plane-resolved + // path, but the pre-fetch gate must not be the weaker of the two.) + if repositoryPath == "" { + slog.Warn("Git credential path check refused: gitlab-bound workspace has no bound repository path", + "workspaceID", workspaceID, + ) + return false + } + return credentialPathMatchesRequest(repositoryPath, requestedPath) +} + +func (s *Server) credentialPathBinding(workspaceID string) (provider, repositoryPath string) { + if runtime, ok := s.getWorkspaceRuntime(workspaceID); ok { + return strings.TrimSpace(runtime.RepoProvider), strings.TrimSpace(runtime.RepositoryPath) + } + return strings.TrimSpace(s.config.RepoProvider), strings.TrimSpace(s.config.RepositoryPath) +} + +func credentialPathMatchesRequest(repositoryPath, requestedPath string) bool { + normalize := func(path string) string { + path = strings.TrimSpace(path) + path = strings.TrimPrefix(path, "/") + path = strings.TrimSuffix(path, ".git") + path = strings.TrimSuffix(path, "/") + return strings.ToLower(path) + } + repositoryPath = normalize(repositoryPath) + requestedPath = normalize(requestedPath) + return repositoryPath != "" && requestedPath != "" && repositoryPath == requestedPath +} + func (s *Server) fetchGitToken(ctx context.Context) (string, error) { resp, err := s.fetchGitTokenResponseForWorkspace(ctx, s.config.WorkspaceID, s.config.CallbackToken) if err != nil { diff --git a/packages/vm-agent/internal/server/git_credential_test.go b/packages/vm-agent/internal/server/git_credential_test.go index 44010647d..7a42a1ff8 100644 --- a/packages/vm-agent/internal/server/git_credential_test.go +++ b/packages/vm-agent/internal/server/git_credential_test.go @@ -165,6 +165,421 @@ func TestHandleGitCredentialRespectsRequestedHostProvider(t *testing.T) { } } +func TestHandleGitCredentialGitLabRespectsRequestedPath(t *testing.T) { + t.Parallel() + + var controlPlaneCalls atomic.Int32 + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + controlPlaneCalls.Add(1) + if r.URL.Path != "/api/workspaces/ws-gitlab/git-token" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer gitlab-callback-token" { + t.Fatalf("unexpected Authorization header: %q", r.Header.Get("Authorization")) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "provider":"gitlab", + "token":"gl_token", + "expiresAt":null, + "cloneUrl":"https://gitlab.com/group/project.git", + "host":"gitlab.com", + "username":"oauth2", + "repositoryPath":"group/project" + }`)) + })) + defer controlPlane.Close() + + s := &Server{ + config: &config.Config{ + ControlPlaneURL: controlPlane.URL, + WorkspaceID: "ws-gitlab", + CallbackToken: "gitlab-callback-token", + }, + workspaces: map[string]*WorkspaceRuntime{ + "ws-gitlab": { + ID: "ws-gitlab", + CallbackToken: "gitlab-callback-token", + RepoProvider: "gitlab", + RepositoryHost: "gitlab.com", + RepositoryPath: "group/project", + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/git-credential?workspaceId=ws-gitlab&host=gitlab.com&path=group/project.git", nil) + req.Header.Set("Authorization", "Bearer gitlab-callback-token") + + rec := httptest.NewRecorder() + s.handleGitCredential(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + want := "protocol=https\nhost=gitlab.com\nusername=oauth2\npassword=gl_token\n\n" + if rec.Body.String() != want { + t.Fatalf("unexpected body:\n%s\nwant:\n%s", rec.Body.String(), want) + } + + wrongPathReq := httptest.NewRequest(http.MethodGet, "/git-credential?workspaceId=ws-gitlab&host=gitlab.com&path=other/project.git", nil) + wrongPathReq.Header.Set("Authorization", "Bearer gitlab-callback-token") + + wrongPathRec := httptest.NewRecorder() + s.handleGitCredential(wrongPathRec, wrongPathReq) + + if wrongPathRec.Code != http.StatusNoContent { + t.Fatalf("expected 204 for wrong path, got %d: %s", wrongPathRec.Code, wrongPathRec.Body.String()) + } + if controlPlaneCalls.Load() != 1 { + t.Fatalf("wrong path should not fetch a second token, got %d calls", controlPlaneCalls.Load()) + } +} + +func TestIsAllowedCredentialPathForWorkspaceFallsBackToConfig(t *testing.T) { + t.Parallel() + + // Standalone / unregistered-workspace mode: the workspace is not in the + // runtime map, so the gate must enforce the process config's GitLab path + // binding rather than failing open. + gitlab := &Server{ + config: &config.Config{ + RepoProvider: "gitlab", + RepositoryPath: "group/project", + }, + workspaces: map[string]*WorkspaceRuntime{}, + } + if !gitlab.isAllowedCredentialPathForWorkspace("ws-unknown", "group/project.git") { + t.Fatal("matching path against config binding should be allowed") + } + if gitlab.isAllowedCredentialPathForWorkspace("ws-unknown", "other/project.git") { + t.Fatal("mismatched path must be blocked via config fallback, not fail open") + } + + // Non-GitLab config: path gating does not apply (GitHub/Artifacts credentials + // are host-scoped), so any requested path is permitted. + github := &Server{ + config: &config.Config{RepoProvider: "github"}, + workspaces: map[string]*WorkspaceRuntime{}, + } + if !github.isAllowedCredentialPathForWorkspace("ws-unknown", "any/path.git") { + t.Fatal("non-gitlab config should not gate on path") + } + + // A registered runtime takes precedence over the process config. + scoped := &Server{ + config: &config.Config{RepoProvider: "gitlab", RepositoryPath: "config/path"}, + workspaces: map[string]*WorkspaceRuntime{ + "ws-a": {ID: "ws-a", RepoProvider: "gitlab", RepositoryPath: "runtime/path"}, + }, + } + if !scoped.isAllowedCredentialPathForWorkspace("ws-a", "runtime/path.git") { + t.Fatal("runtime binding path should be allowed") + } + if scoped.isAllowedCredentialPathForWorkspace("ws-a", "config/path.git") { + t.Fatal("runtime binding must override the config fallback") + } + + // Fail-closed: a gitlab-bound workspace whose bound repository path is + // empty (misconfiguration) must refuse every requested path — never fall + // open to "any path allowed". + emptyRuntime := &Server{ + config: &config.Config{RepoProvider: "gitlab", RepositoryPath: "config/path"}, + workspaces: map[string]*WorkspaceRuntime{ + "ws-empty": {ID: "ws-empty", RepoProvider: "gitlab", RepositoryPath: ""}, + }, + } + if emptyRuntime.isAllowedCredentialPathForWorkspace("ws-empty", "group/project.git") { + t.Fatal("gitlab runtime with empty bound path must fail closed") + } + emptyConfig := &Server{ + config: &config.Config{RepoProvider: "gitlab", RepositoryPath: ""}, + workspaces: map[string]*WorkspaceRuntime{}, + } + if emptyConfig.isAllowedCredentialPathForWorkspace("ws-unknown", "group/project.git") { + t.Fatal("gitlab config fallback with empty bound path must fail closed") + } +} + +// TestHandleGitCredentialGitLabFailClosedWithoutHostOrPath verifies the +// request-side fail-closed gate: a gitlab-bound workspace must supply BOTH +// host and path or the exchange returns 204 without ever contacting the +// control plane. GitHub/Artifacts keep the empty-allow behavior (covered by +// TestHandleGitCredentialSuccess). +func TestHandleGitCredentialGitLabFailClosedWithoutHostOrPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + }{ + {name: "no host and no path", query: ""}, + {name: "host without path", query: "&host=gitlab.com"}, + {name: "path without host", query: "&path=group/project.git"}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var controlPlaneCalls atomic.Int32 + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + controlPlaneCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"provider":"gitlab","token":"gl_token","expiresAt":null,"host":"gitlab.com","username":"oauth2","repositoryPath":"group/project"}`)) + })) + defer controlPlane.Close() + + s := &Server{ + config: &config.Config{ + ControlPlaneURL: controlPlane.URL, + WorkspaceID: "ws-gitlab", + CallbackToken: "gitlab-callback-token", + }, + workspaces: map[string]*WorkspaceRuntime{ + "ws-gitlab": { + ID: "ws-gitlab", + CallbackToken: "gitlab-callback-token", + RepoProvider: "gitlab", + RepositoryHost: "gitlab.com", + RepositoryPath: "group/project", + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/git-credential?workspaceId=ws-gitlab"+tc.query, nil) + req.Header.Set("Authorization", "Bearer gitlab-callback-token") + + rec := httptest.NewRecorder() + s.handleGitCredential(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + if rec.Body.Len() != 0 { + t.Fatalf("expected empty body, got: %s", rec.Body.String()) + } + if controlPlaneCalls.Load() != 0 { + t.Fatalf("gitlab-bound exchange without host+path must not fetch a token, got %d calls", controlPlaneCalls.Load()) + } + }) + } +} + +// TestHandleGitCredentialGitLabResponseFailClosed verifies the response-side +// gate: even when the local binding did NOT identify the workspace as gitlab +// (e.g. unregistered runtime falling back to a github process config), a +// control-plane response with provider=gitlab is only released when the caller +// supplied a host and path and the resolved repositoryPath is verifiable. +func TestHandleGitCredentialGitLabResponseFailClosed(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + tokenBody string + }{ + { + // Local binding says github (no prefetch gitlab gate), but the control + // plane resolves a gitlab credential — must not be released without + // host+path identification. + name: "gitlab response without requested host and path", + query: "", + tokenBody: `{"provider":"gitlab","token":"gl_token","expiresAt":null,"host":"gitlab.com","username":"oauth2","repositoryPath":"group/project"}`, + }, + { + // Host+path supplied but the control plane response carries no + // repositoryPath — the binding cannot be verified, so refuse. + name: "gitlab response without resolved repository path", + query: "&host=gitlab.com&path=group/project.git", + tokenBody: `{"provider":"gitlab","token":"gl_token","expiresAt":null,"host":"gitlab.com","username":"oauth2"}`, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.tokenBody)) + })) + defer controlPlane.Close() + + s := &Server{ + config: &config.Config{ + ControlPlaneURL: controlPlane.URL, + WorkspaceID: "ws-mislabeled", + CallbackToken: "callback-token", + RepoProvider: "github", + RepositoryHost: "gitlab.com", + }, + workspaces: map[string]*WorkspaceRuntime{}, + } + + req := httptest.NewRequest(http.MethodGet, "/git-credential?workspaceId=ws-mislabeled"+tc.query, nil) + req.Header.Set("Authorization", "Bearer callback-token") + + rec := httptest.NewRecorder() + s.handleGitCredential(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "gl_token") { + t.Fatal("gitlab token must not be released without verified host and path") + } + }) + } +} + +// newGitLabMRTestServer builds the Server and WorkspaceRuntime shared by the +// tryCreateGitLabMergeRequest tests, wired to the given TLS test API acting as +// both control plane (token vending) and GitLab host. +func newGitLabMRTestServer(api *httptest.Server) (*Server, *WorkspaceRuntime) { + s := &Server{ + config: &config.Config{ + ControlPlaneURL: api.URL, + }, + httpClient: api.Client(), + workspaces: map[string]*WorkspaceRuntime{ + "ws-gitlab": { + ID: "ws-gitlab", + CallbackToken: "gitlab-callback-token", + }, + }, + } + runtime := &WorkspaceRuntime{ + ID: "ws-gitlab", + Branch: "main", + RepoProvider: "gitlab", + RepositoryHost: strings.TrimPrefix(api.URL, "https://"), + RepositoryPath: "group/project", + } + return s, runtime +} + +func TestTryCreateGitLabMergeRequestUsesWorkspaceToken(t *testing.T) { + t.Parallel() + + var sawTokenRequest atomic.Bool + var sawMergeRequest atomic.Bool + api := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/workspaces/ws-gitlab/git-token": + sawTokenRequest.Store(true) + if r.Method != http.MethodPost { + t.Fatalf("expected token POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer gitlab-callback-token" { + t.Fatalf("unexpected token Authorization header: %q", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"provider":"gitlab","token":"gl_token","expiresAt":null}`)) + case strings.Contains(r.RequestURI, "/api/v4/projects/group%2Fproject/merge_requests"): + sawMergeRequest.Store(true) + if r.Method != http.MethodPost { + t.Fatalf("expected MR POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer gl_token" { + t.Fatalf("unexpected MR Authorization header: %q", r.Header.Get("Authorization")) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("parse MR form: %v", err) + } + if r.Form.Get("source_branch") != "sam/feature" { + t.Fatalf("unexpected source_branch: %q", r.Form.Get("source_branch")) + } + if r.Form.Get("target_branch") != "main" { + t.Fatalf("unexpected target_branch: %q", r.Form.Get("target_branch")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"web_url":"https://gitlab.example/group/project/-/merge_requests/7","iid":7}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.RequestURI) + } + })) + defer api.Close() + + s, runtime := newGitLabMRTestServer(api) + + mrURL, iid := s.tryCreateGitLabMergeRequest(runtime, "sam/feature") + + if mrURL != "https://gitlab.example/group/project/-/merge_requests/7" { + t.Fatalf("unexpected MR URL: %q", mrURL) + } + if iid != 7 { + t.Fatalf("unexpected MR iid: %d", iid) + } + if !sawTokenRequest.Load() { + t.Fatal("expected token request") + } + if !sawMergeRequest.Load() { + t.Fatal("expected merge request") + } +} + +// TestTryCreateGitLabMergeRequestFallsBackToExistingOn409 verifies that when +// GitLab rejects the MR create with 409 (an MR already exists for the source +// branch), the agent looks up the existing open MR and returns its URL/IID +// instead of reporting a failure. +func TestTryCreateGitLabMergeRequestFallsBackToExistingOn409(t *testing.T) { + t.Parallel() + + var sawCreateAttempt atomic.Bool + var sawLookup atomic.Bool + api := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/workspaces/ws-gitlab/git-token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"provider":"gitlab","token":"gl_token","expiresAt":null}`)) + case strings.Contains(r.RequestURI, "/api/v4/projects/group%2Fproject/merge_requests") && r.Method == http.MethodPost: + sawCreateAttempt.Store(true) + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message":["Another open merge request already exists for this source branch"]}`)) + case strings.Contains(r.RequestURI, "/api/v4/projects/group%2Fproject/merge_requests") && r.Method == http.MethodGet: + sawLookup.Store(true) + if r.Header.Get("Authorization") != "Bearer gl_token" { + t.Fatalf("unexpected lookup Authorization header: %q", r.Header.Get("Authorization")) + } + q := r.URL.Query() + if q.Get("state") != "opened" { + t.Fatalf("unexpected state param: %q", q.Get("state")) + } + if q.Get("source_branch") != "sam/feature" { + t.Fatalf("unexpected source_branch param: %q", q.Get("source_branch")) + } + if q.Get("target_branch") != "main" { + t.Fatalf("unexpected target_branch param: %q", q.Get("target_branch")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"web_url":"https://gitlab.example/group/project/-/merge_requests/9","iid":9}]`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.RequestURI) + } + })) + defer api.Close() + + s, runtime := newGitLabMRTestServer(api) + + mrURL, iid := s.tryCreateGitLabMergeRequest(runtime, "sam/feature") + + if mrURL != "https://gitlab.example/group/project/-/merge_requests/9" { + t.Fatalf("unexpected MR URL: %q", mrURL) + } + if iid != 9 { + t.Fatalf("unexpected MR iid: %d", iid) + } + if !sawCreateAttempt.Load() { + t.Fatal("expected MR create attempt") + } + if !sawLookup.Load() { + t.Fatal("expected existing-MR lookup after 409") + } +} + func TestHandleGitCredentialAllowsLocalExchangeWithoutCallbackBearer(t *testing.T) { t.Parallel() diff --git a/packages/vm-agent/internal/server/server.go b/packages/vm-agent/internal/server/server.go index 6ab3266e9..226997a02 100644 --- a/packages/vm-agent/internal/server/server.go +++ b/packages/vm-agent/internal/server/server.go @@ -10,6 +10,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -143,6 +144,10 @@ type WorkspaceRuntime struct { ID string Repository string Branch string + RepoProvider string + CloneURL string + RepositoryHost string + RepositoryPath string Status string CreatedAt time.Time UpdatedAt time.Time @@ -1483,6 +1488,11 @@ type gitPushResult struct { Error string } +type gitlabMergeRequestResponse struct { + WebURL string `json:"web_url"` + IID int `json:"iid"` +} + func (s *Server) runWorkspaceGitCommand(containerID, workDir, user string, args ...string) (string, error) { timeout := s.config.GitExecTimeout if timeout <= 0 { @@ -1563,7 +1573,7 @@ func (s *Server) gitPushWorkspaceChanges(workspaceID string, skipPR bool) gitPus // Try to create a PR via gh (best-effort) — skip in conversation mode if !skipPR { - prURL, prNumber := s.tryCreatePR(containerID, workDir, user) + prURL, prNumber := s.tryCreateReviewRequest(workspaceID, containerID, workDir, user, result.BranchName) result.PrURL = prURL result.PrNumber = prNumber } @@ -1571,6 +1581,136 @@ func (s *Server) gitPushWorkspaceChanges(workspaceID string, skipPR bool) gitPus return result } +func (s *Server) tryCreateReviewRequest(workspaceID, containerID, workDir, user, sourceBranch string) (string, int) { + if runtime, ok := s.getWorkspaceRuntime(workspaceID); ok { + switch strings.ToLower(strings.TrimSpace(runtime.RepoProvider)) { + case "gitlab": + return s.tryCreateGitLabMergeRequest(runtime, sourceBranch) + case "artifacts": + slog.Info("Review request creation skipped for Artifacts workspace", "workspaceID", workspaceID) + return "", 0 + } + } + return s.tryCreatePR(containerID, workDir, user) +} + +func (s *Server) tryCreateGitLabMergeRequest(runtime *WorkspaceRuntime, sourceBranch string) (string, int) { + if runtime == nil { + return "", 0 + } + host := strings.TrimSpace(runtime.RepositoryHost) + repositoryPath := strings.TrimSpace(runtime.RepositoryPath) + targetBranch := strings.TrimSpace(runtime.Branch) + if host == "" || repositoryPath == "" || sourceBranch == "" || targetBranch == "" { + slog.Warn("GitLab MR creation skipped: repository metadata missing", "workspaceID", runtime.ID) + return "", 0 + } + + // Bound the entire MR flow (token fetch + create + 409 lookup) so a dead or + // slow GitLab host cannot stall task completion. Mirrors tryCreatePR. + timeout := s.config.GitExecTimeout + if timeout <= 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + resp, err := s.fetchGitTokenResponseForWorkspace(ctx, runtime.ID, "") + if err != nil { + slog.Warn("GitLab MR creation skipped: token fetch failed", "workspaceID", runtime.ID, "error", err) + return "", 0 + } + + form := url.Values{} + form.Set("source_branch", sourceBranch) + form.Set("target_branch", targetBranch) + form.Set("title", fmt.Sprintf("SAM task changes from %s", sourceBranch)) + endpoint := fmt.Sprintf( + "https://%s/api/v4/projects/%s/merge_requests", + host, + url.PathEscape(repositoryPath), + ) + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + endpoint, + strings.NewReader(form.Encode()), + ) + if err != nil { + slog.Warn("GitLab MR create request build failed", "workspaceID", runtime.ID, "error", err) + return "", 0 + } + req.Header.Set("Authorization", "Bearer "+resp.Token) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + httpResp, err := s.controlPlaneHTTPClient(0).Do(req) + if err != nil { + slog.Warn("GitLab MR create failed", "workspaceID", runtime.ID, "error", err) + return "", 0 + } + defer httpResp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(httpResp.Body, 8*1024)) + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + if httpResp.StatusCode == http.StatusConflict { + if existingURL, existingIID := s.getExistingGitLabMergeRequest(ctx, runtime, host, repositoryPath, sourceBranch, targetBranch, resp.Token); existingURL != "" { + return existingURL, existingIID + } + } + slog.Warn("GitLab MR create returned non-success", "workspaceID", runtime.ID, "status", httpResp.StatusCode, "body", redactTaskCallbackDiagnosticText(strings.TrimSpace(string(body)))) + return "", 0 + } + var payload gitlabMergeRequestResponse + if err := json.Unmarshal(body, &payload); err != nil { + slog.Warn("GitLab MR create response decode failed", "workspaceID", runtime.ID, "error", err) + return "", 0 + } + return payload.WebURL, payload.IID +} + +func (s *Server) getExistingGitLabMergeRequest(ctx context.Context, runtime *WorkspaceRuntime, host, repositoryPath, sourceBranch, targetBranch, token string) (string, int) { + params := url.Values{} + params.Set("state", "opened") + params.Set("source_branch", sourceBranch) + params.Set("target_branch", targetBranch) + params.Set("per_page", "1") + endpoint := fmt.Sprintf( + "https://%s/api/v4/projects/%s/merge_requests?%s", + host, + url.PathEscape(repositoryPath), + params.Encode(), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + slog.Warn("GitLab MR lookup request build failed", "workspaceID", runtime.ID, "error", err) + return "", 0 + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + httpResp, err := s.controlPlaneHTTPClient(0).Do(req) + if err != nil { + slog.Warn("GitLab MR lookup failed", "workspaceID", runtime.ID, "error", err) + return "", 0 + } + defer httpResp.Body.Close() + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + slog.Warn("GitLab MR lookup returned non-success", "workspaceID", runtime.ID, "status", httpResp.StatusCode) + return "", 0 + } + body, _ := io.ReadAll(io.LimitReader(httpResp.Body, 8*1024)) + var payload []gitlabMergeRequestResponse + if err := json.Unmarshal(body, &payload); err != nil { + slog.Warn("GitLab MR lookup response decode failed", "workspaceID", runtime.ID, "error", err) + return "", 0 + } + if len(payload) == 0 { + return "", 0 + } + return payload[0].WebURL, payload[0].IID +} + // tryCreatePR attempts to create a GitHub PR using gh CLI inside the container. // Returns (prURL, prNumber) on success, or ("", 0) on failure. func (s *Server) tryCreatePR(containerID, workDir, user string) (string, int) { diff --git a/packages/vm-agent/internal/server/standalone_git.go b/packages/vm-agent/internal/server/standalone_git.go index d1aa26999..1f8c6defa 100644 --- a/packages/vm-agent/internal/server/standalone_git.go +++ b/packages/vm-agent/internal/server/standalone_git.go @@ -14,32 +14,61 @@ const standaloneGitBinaryPath = "/usr/bin/git" // standaloneGitCredentialHelperScript is a git credential helper for standalone // (cf-container) mode. Unlike the devcontainer helper, the agent runs in the -// SAME container as the vm-agent, and the per-session GH_TOKEN is injected into -// the agent process environment (see resolveAgentEnvVars). git spawns credential -// helpers with its own environment, so the helper simply serves that token for -// GitHub hosts. Scoped to GitHub so it never leaks the token to other hosts. +// SAME container as the vm-agent. +// +// GitHub keeps the fast path: the per-session GH_TOKEN is injected into the +// agent process environment (see resolveAgentEnvVars), and git credential +// helpers inherit that environment. +// +// GitLab cannot use GH_TOKEN. It needs a fresh, path-bound token exchange through +// the local vm-agent /git-credential endpoint. The endpoint performs the +// workspace/provider/path authorization checks before returning credentials. const standaloneGitCredentialHelperScript = `#!/bin/sh [ "${1:-get}" = "get" ] || exit 0 host="" +path="" while IFS= read -r line; do [ -z "$line" ] && break case "$line" in host=*) host="${line#host=}" ;; + path=*) path="${line#path=}" ;; esac done case "$host" in - github.com|api.github.com) ;; - *) exit 0 ;; + github.com|api.github.com) + [ -n "${GH_TOKEN:-}" ] || exit 0 + printf 'username=x-access-token\npassword=%s\n' "$GH_TOKEN" + exit 0 + ;; esac -[ -n "${GH_TOKEN:-}" ] || exit 0 -printf 'username=x-access-token\npassword=%s\n' "$GH_TOKEN" + +[ -n "$host" ] || exit 0 +[ -n "$path" ] || exit 0 +[ -n "${SAM_WORKSPACE_ID:-}" ] || exit 0 + +url_encode_query_value() { + printf '%s' "$1" | sed 's/%/%25/g; s/&/%26/g; s/=/%3D/g; s/?/%3F/g; s/#/%23/g; s/+/%2B/g; s/ /%20/g' +} + +endpoint="${SAM_GIT_CREDENTIAL_ENDPOINT:-}" +if [ -z "$endpoint" ]; then + port="${VM_AGENT_PORT:-8080}" + endpoint="http://127.0.0.1:${port}/git-credential" +fi + +workspace_id=$(url_encode_query_value "$SAM_WORKSPACE_ID") +encoded_host=$(url_encode_query_value "$host") +encoded_path=$(url_encode_query_value "$path") + +curl -fsS --max-time 5 \ + "${endpoint}?workspaceId=${workspace_id}&host=${encoded_host}&path=${encoded_path}" 2>/dev/null || true ` // ConfigureStandaloneGitCredentialHelper installs a git credential helper that -// serves the injected GH_TOKEN for GitHub hosts and registers it in the system -// git config. This lets the agent's `git` commands (clone, ls-remote, fetch, -// push) authenticate in standalone mode. Failures are non-fatal — the agent can -// still run without git access. +// serves GitHub credentials from GH_TOKEN and delegates GitLab/non-GitHub +// credentials to the local vm-agent exchange. This lets the agent's `git` +// commands (clone, ls-remote, fetch, push) authenticate in standalone mode. +// Failures are non-fatal — the agent can still run without git access. func ConfigureStandaloneGitCredentialHelper() { if err := os.WriteFile(standaloneGitCredentialHelperPath, []byte(standaloneGitCredentialHelperScript), 0o755); err != nil { slog.Warn("standalone git: failed to write credential helper; agent git auth unavailable", "error", err) @@ -57,6 +86,14 @@ func ConfigureStandaloneGitCredentialHelper() { return } } + if out, err := exec.Command(standaloneGitBinaryPath, "config", "--system", "credential.useHttpPath", "true").CombinedOutput(); err != nil { + slog.Warn("standalone git: system credential.useHttpPath config failed, trying global", + "error", err, "output", strings.TrimSpace(string(out))) + if out2, err2 := exec.Command(standaloneGitBinaryPath, "config", "--global", "credential.useHttpPath", "true").CombinedOutput(); err2 != nil { + slog.Warn("standalone git: global credential.useHttpPath config failed; GitLab git auth may be unavailable", + "error", err2, "output", strings.TrimSpace(string(out2))) + } + } slog.Info("standalone git: credential helper configured", "path", standaloneGitCredentialHelperPath) } diff --git a/packages/vm-agent/internal/server/standalone_git_test.go b/packages/vm-agent/internal/server/standalone_git_test.go index 248e6bb46..274449435 100644 --- a/packages/vm-agent/internal/server/standalone_git_test.go +++ b/packages/vm-agent/internal/server/standalone_git_test.go @@ -1,6 +1,8 @@ package server import ( + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" @@ -11,6 +13,11 @@ import ( // runStandaloneCredScript writes the helper script to a temp file and runs it // with the given argv[1] and stdin, returning trimmed stdout. func runStandaloneCredScript(t *testing.T, arg, stdin, ghToken string) string { + t.Helper() + return runStandaloneCredScriptWithEnv(t, arg, stdin, map[string]string{"GH_TOKEN": ghToken}) +} + +func runStandaloneCredScriptWithEnv(t *testing.T, arg, stdin string, env map[string]string) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "git-credential-sam") @@ -19,7 +26,10 @@ func runStandaloneCredScript(t *testing.T, arg, stdin, ghToken string) string { } cmd := exec.Command("/bin/sh", path, arg) cmd.Stdin = strings.NewReader(stdin) - cmd.Env = append(os.Environ(), "GH_TOKEN="+ghToken) + cmd.Env = os.Environ() + for key, value := range env { + cmd.Env = append(cmd.Env, key+"="+value) + } out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("run script: %v (out=%q)", err, out) @@ -43,6 +53,64 @@ func TestStandaloneGitCredentialHelperRejectsNonGitHubHost(t *testing.T) { } } +func TestStandaloneGitCredentialHelperDelegatesGitLabToLocalExchange(t *testing.T) { + t.Parallel() + + var gotWorkspaceID string + var gotHost string + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/git-credential" { + t.Fatalf("request path = %q, want /git-credential", r.URL.Path) + } + gotWorkspaceID = r.URL.Query().Get("workspaceId") + gotHost = r.URL.Query().Get("host") + gotPath = r.URL.Query().Get("path") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("username=oauth2\npassword=gl_token\n")) + })) + t.Cleanup(server.Close) + + out := runStandaloneCredScriptWithEnv(t, "get", "protocol=https\nhost=gitlab.com\npath=group/project.git\n\n", map[string]string{ + "SAM_WORKSPACE_ID": "ws-gitlab", + "SAM_GIT_CREDENTIAL_ENDPOINT": server.URL + "/git-credential", + "GH_TOKEN": "ghs_should_not_be_used", + }) + + if !strings.Contains(out, "username=oauth2") || !strings.Contains(out, "password=gl_token") { + t.Fatalf("expected delegated gitlab creds, got %q", out) + } + if gotWorkspaceID != "ws-gitlab" { + t.Fatalf("workspaceId query = %q, want ws-gitlab", gotWorkspaceID) + } + if gotHost != "gitlab.com" { + t.Fatalf("host query = %q, want gitlab.com", gotHost) + } + if gotPath != "group/project.git" { + t.Fatalf("path query = %q, want group/project.git", gotPath) + } +} + +func TestStandaloneGitCredentialHelperRequiresPathForGitLab(t *testing.T) { + t.Parallel() + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + t.Cleanup(server.Close) + + out := runStandaloneCredScriptWithEnv(t, "get", "protocol=https\nhost=gitlab.com\n\n", map[string]string{ + "SAM_WORKSPACE_ID": "ws-gitlab", + "SAM_GIT_CREDENTIAL_ENDPOINT": server.URL + "/git-credential", + }) + if out != "" { + t.Fatalf("expected no creds without path, got %q", out) + } + if called { + t.Fatal("credential endpoint should not be called without a path") + } +} + func TestStandaloneGitCredentialHelperNoTokenNoOutput(t *testing.T) { t.Parallel() out := runStandaloneCredScript(t, "get", "protocol=https\nhost=github.com\n\n", "") diff --git a/packages/vm-agent/internal/server/workspace_provisioning.go b/packages/vm-agent/internal/server/workspace_provisioning.go index fb9b8090d..53fc8a09f 100644 --- a/packages/vm-agent/internal/server/workspace_provisioning.go +++ b/packages/vm-agent/internal/server/workspace_provisioning.go @@ -73,6 +73,10 @@ func (s *Server) provisionWorkspaceRuntime(ctx context.Context, runtime *Workspa cfg.WorkspaceID = runtime.ID cfg.Repository = strings.TrimSpace(runtime.Repository) cfg.Branch = strings.TrimSpace(runtime.Branch) + cfg.RepoProvider = strings.TrimSpace(runtime.RepoProvider) + cfg.CloneURL = strings.TrimSpace(runtime.CloneURL) + cfg.RepositoryHost = strings.TrimSpace(runtime.RepositoryHost) + cfg.RepositoryPath = strings.TrimSpace(runtime.RepositoryPath) cfg.WorkspaceDir = strings.TrimSpace(runtime.WorkspaceDir) cfg.ContainerLabelValue = strings.TrimSpace(runtime.ContainerLabelValue) cfg.ContainerWorkDir = strings.TrimSpace(runtime.ContainerWorkDir) @@ -113,6 +117,10 @@ func (s *Server) provisionWorkspaceRuntime(ctx context.Context, runtime *Workspa GitUserName: runtime.GitUserName, GitUserEmail: runtime.GitUserEmail, GitHubID: runtime.GitHubID, + RepoProvider: runtime.RepoProvider, + CloneURL: runtime.CloneURL, + RepositoryHost: runtime.RepositoryHost, + RepositoryPath: runtime.RepositoryPath, ProjectEnvVars: runtimeAssets.EnvVars, ProjectFiles: runtimeAssets.Files, Lightweight: runtime.Lightweight, @@ -156,6 +164,10 @@ func (s *Server) recoverWorkspaceRuntime(ctx context.Context, runtime *Workspace cfg.WorkspaceID = runtime.ID cfg.Repository = strings.TrimSpace(runtime.Repository) cfg.Branch = strings.TrimSpace(runtime.Branch) + cfg.RepoProvider = strings.TrimSpace(runtime.RepoProvider) + cfg.CloneURL = strings.TrimSpace(runtime.CloneURL) + cfg.RepositoryHost = strings.TrimSpace(runtime.RepositoryHost) + cfg.RepositoryPath = strings.TrimSpace(runtime.RepositoryPath) cfg.WorkspaceDir = strings.TrimSpace(runtime.WorkspaceDir) cfg.ContainerLabelValue = strings.TrimSpace(runtime.ContainerLabelValue) cfg.ContainerWorkDir = strings.TrimSpace(runtime.ContainerWorkDir) @@ -184,6 +196,10 @@ func (s *Server) recoverWorkspaceRuntime(ctx context.Context, runtime *Workspace state.ProjectFiles = runtimeAssets.Files state.Lightweight = runtime.Lightweight state.DevcontainerConfigName = runtime.DevcontainerConfigName + state.RepoProvider = runtime.RepoProvider + state.CloneURL = runtime.CloneURL + state.RepositoryHost = runtime.RepositoryHost + state.RepositoryPath = runtime.RepositoryPath _, err := prepareWorkspaceForRuntime(recoveryCtx, &cfg, state, nil) if err != nil { diff --git a/packages/vm-agent/internal/server/workspace_routing.go b/packages/vm-agent/internal/server/workspace_routing.go index aefd1e60b..575479140 100644 --- a/packages/vm-agent/internal/server/workspace_routing.go +++ b/packages/vm-agent/internal/server/workspace_routing.go @@ -34,6 +34,10 @@ type workspaceRuntimeOpts struct { GitUserName string GitUserEmail string GitHubID string + RepoProvider string + CloneURL string + RepositoryHost string + RepositoryPath string Lightweight bool DevcontainerConfigName string DevcontainerCache DevcontainerCacheCredentials @@ -221,6 +225,22 @@ func (s *Server) upsertWorkspaceRuntime(workspaceID, repository, branch, status, if opt.GitHubID != "" { runtime.GitHubID = opt.GitHubID } + if opt.RepoProvider != "" { + runtime.RepoProvider = opt.RepoProvider + metadataChanged = true + } + if opt.CloneURL != "" { + runtime.CloneURL = opt.CloneURL + metadataChanged = true + } + if opt.RepositoryHost != "" { + runtime.RepositoryHost = opt.RepositoryHost + metadataChanged = true + } + if opt.RepositoryPath != "" { + runtime.RepositoryPath = opt.RepositoryPath + metadataChanged = true + } runtime.Lightweight = opt.Lightweight if opt.DevcontainerConfigName != "" { runtime.DevcontainerConfigName = opt.DevcontainerConfigName @@ -242,6 +262,7 @@ func (s *Server) upsertWorkspaceRuntime(workspaceID, repository, branch, status, effectiveBranch := branch var persistedWorkspaceDir, persistedContainerWorkDir, persistedContainerLabelValue, persistedContainerUser string var persistedCallbackToken string + var persistedRepoProvider, persistedCloneURL, persistedRepositoryHost, persistedRepositoryPath string var persistedLightweight bool var persistedDevcontainerConfigName string @@ -264,6 +285,10 @@ func (s *Server) upsertWorkspaceRuntime(workspaceID, repository, branch, status, persistedContainerLabelValue = meta.ContainerLabelVal persistedContainerUser = meta.ContainerUser persistedCallbackToken = meta.CallbackToken + persistedRepoProvider = meta.RepoProvider + persistedCloneURL = meta.CloneURL + persistedRepositoryHost = meta.RepositoryHost + persistedRepositoryPath = meta.RepositoryPath persistedLightweight = meta.Lightweight persistedDevcontainerConfigName = meta.DevcontainerConfigName } @@ -292,6 +317,10 @@ func (s *Server) upsertWorkspaceRuntime(workspaceID, repository, branch, status, ID: workspaceID, Repository: effectiveRepo, Branch: effectiveBranch, + RepoProvider: firstNonEmpty(opt.RepoProvider, persistedRepoProvider), + CloneURL: firstNonEmpty(opt.CloneURL, persistedCloneURL), + RepositoryHost: firstNonEmpty(opt.RepositoryHost, persistedRepositoryHost), + RepositoryPath: firstNonEmpty(opt.RepositoryPath, persistedRepositoryPath), Status: status, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), @@ -469,6 +498,10 @@ func (s *Server) persistWorkspaceMetadata(runtime *WorkspaceRuntime) { ContainerLabelVal: runtime.ContainerLabelValue, WorkspaceDir: runtime.WorkspaceDir, CallbackToken: runtime.CallbackToken, + RepoProvider: runtime.RepoProvider, + CloneURL: runtime.CloneURL, + RepositoryHost: runtime.RepositoryHost, + RepositoryPath: runtime.RepositoryPath, Lightweight: runtime.Lightweight, DevcontainerConfigName: runtime.DevcontainerConfigName, }); err != nil { diff --git a/packages/vm-agent/internal/server/workspaces.go b/packages/vm-agent/internal/server/workspaces.go index d7ae95486..94e3d67bc 100644 --- a/packages/vm-agent/internal/server/workspaces.go +++ b/packages/vm-agent/internal/server/workspaces.go @@ -468,6 +468,10 @@ type createWorkspaceRequest struct { WorkspaceID string `json:"workspaceId"` Repository string `json:"repository"` Branch string `json:"branch"` + RepoProvider string `json:"repoProvider,omitempty"` + CloneURL string `json:"cloneUrl,omitempty"` + RepositoryHost string `json:"repositoryHost,omitempty"` + RepositoryPath string `json:"repositoryPath,omitempty"` CallbackToken string `json:"callbackToken,omitempty"` GitUserName string `json:"gitUserName,omitempty"` GitUserEmail string `json:"gitUserEmail,omitempty"` @@ -507,6 +511,10 @@ func createWorkspaceRuntimeOptions(body createWorkspaceRequest, devcontainerConf GitUserName: strings.TrimSpace(body.GitUserName), GitUserEmail: strings.TrimSpace(body.GitUserEmail), GitHubID: strings.TrimSpace(body.GitHubID), + RepoProvider: strings.TrimSpace(body.RepoProvider), + CloneURL: strings.TrimSpace(body.CloneURL), + RepositoryHost: strings.TrimSpace(body.RepositoryHost), + RepositoryPath: strings.TrimSpace(body.RepositoryPath), Lightweight: body.Lightweight, DevcontainerConfigName: devcontainerConfigName, DevcontainerCache: DevcontainerCacheCredentials{ diff --git a/tasks/active/2026-07-08-gitlab-repo-workspace-wip.md b/tasks/active/2026-07-08-gitlab-repo-workspace-wip.md new file mode 100644 index 000000000..191f5854a --- /dev/null +++ b/tasks/active/2026-07-08-gitlab-repo-workspace-wip.md @@ -0,0 +1,142 @@ +# GitLab Repository And Workspace Support WIP + +## Status + +Active WIP stacked PR branch on top of #1545 (`sam/gitlab-platform-config-wip`). + +## Constraints + +- DRAFT PR. DO NOT MARK READY. DO NOT MERGE. +- DO NOT DEPLOY TO STAGING, DO NOT RUN STAGING VERIFICATION, AND DO NOT MUTATE STAGING. Other agents are actively using staging. +- Skip /do Phase 6 by explicit user instruction and document that staging was intentionally skipped. +- Stop /do Phase 7 after updating the draft PR and observing/reporting CI; never merge. +- Do not commit a task file directly to main. Keep task records/state on this existing feature branch because the stack is intentionally unmerged. +- Avoid force-push unless absolutely unavoidable; preserve linear stacked ancestry with normal branch merges. +- Do not alter or merge PR #1545; consume its published branch head only. +- Do not create, update, or deploy any staging environment. +- Use the platform-level GitLab OAuth configuration added in the base PR. + +## Context + +PR #1545 adds the runtime platform configuration foundation for GitLab OAuth: + +- GitLab OAuth host/client ID/client secret can be stored in the platform settings/credentials store. +- GitLab sign-in is visible when the provider is configured. +- Environment fallbacks remain available for self-hosters. + +This follow-up implements repository/workspace support around that foundation. + +The linked SAM idea is `01KV7ZFD6HZS5N7J45VA798KN1`, "GitLab integration using platform-level config". The important security invariant from that idea is that GitLab OAuth tokens are broader than GitHub App installation tokens, so they must not be exported as static workspace environment variables. Git credentials should be minted through the VM credential-helper path, checked against the exact GitLab host/project path, and refreshed on demand. + +## Scope + +- Allow GitLab-backed projects to be created from the project onboarding flow. +- Verify the signed-in user has GitLab project access at project creation and task start. +- Let VM workspaces clone GitLab projects and push branches using the existing callback-based credential helper. +- Support GitLab project browsing through the existing provider-agnostic repo browser routes. +- Keep GitHub and Artifacts behavior unchanged. + +## Implementation Plan + +1. Extend shared contracts: + - Add `gitlab` to `RepoProvider` and `VALID_REPO_PROVIDERS`. + - Extend create-project request fields with GitLab project metadata. + - Extend VM agent create-workspace request with optional provider metadata. + +2. Add persistence: + - Add an additive D1 migration for `project_gitlab_repositories`. + - Store project ID, GitLab host, numeric project ID, path with namespace, web URL, HTTPS clone URL, default branch, and timestamps. + - Mirror the Drizzle schema. + +3. Add GitLab service layer: + - Resolve host/API base URL from `getGitLabOAuthConfig`. + - Retrieve user OAuth tokens through BetterAuth, not by reading `accounts` directly. + - Implement project search/list, branch list, project lookup, access verification, tree/file/raw/compare helpers. + +4. Add API routes: + - Add authenticated GitLab routes for project and branch selection. + - Extend project creation for `repoProvider: "gitlab"`. + - Extend task-start access guards to re-verify GitLab repository access. + - Extend workspace `git-token` to mint GitLab credentials only for the bound project metadata. + +5. Add workspace runtime support: + - Thread GitLab provider metadata through task runner config, workspace creation, and node-agent request payloads. + - Extend VM repo URL normalization so GitLab clone URLs are preserved. + - Extend VM credential helper host/path checks and configure `credential.useHttpPath` for GitLab. + - Keep `GH_TOKEN` injection and `gh` wrapper behavior GitHub-only. + +6. Add UI support: + - Add GitLab as a project onboarding provider. + - Add a GitLab project selector backed by the new API routes. + - Preserve current GitHub and Artifacts flows. + +7. Validate: + - Add focused API tests around GitLab project creation/access and git-token behavior. + - Add Go tests for VM credential-helper and create-workspace contract. + - Run local typecheck/test/build checks. + - Run local specialist review skills relevant to API/env/security/Go/UI. + - Do not deploy or verify on staging. + +## Acceptance Criteria + +- GitLab provider can be selected during project creation when platform GitLab OAuth is configured. +- A GitLab project stores durable repository metadata and is returned as `repoProvider: "gitlab"`. +- Task starts against GitLab projects re-check current GitLab access. +- Workspaces for GitLab projects receive enough metadata to clone with HTTPS and authenticate through the credential helper. +- The VM agent does not persist or export static GitLab OAuth tokens. +- GitLab repo browsing supports branch/tree/file/raw/compare paths through the existing project repo routes. +- Draft PR is opened on top of #1545, with staging and merge explicitly skipped. + +## Validation Notes + +- Local API/web/shared typechecks pass. +- Focused API tests pass for GitLab metadata normalization and GitLab/GitHub repo browsing. +- Focused web unit tests pass for GitLab onboarding state and payload propagation. +- Focused VM Go tests pass for bootstrap credential helper behavior, persistence, workspace metadata, git credential host/path checks, and GitLab MR creation. +- Project onboarding Playwright audit passes on iPhone SE and desktop viewports. +- Full `packages/vm-agent/internal/server` Go package test passes after installing Docker locally and running `dockerd` on `/tmp/sam-docker.sock`. +- Staging validation is intentionally skipped by user instruction. +- Refreshed onto foundation SHA `570f4a5090fe05e5b382b585cd2ea8be5ec0d2be` with merge commit `e06e820b2`. +- Renumbered the GitLab sidecar migration from stale slot `0088` to additive slot `0091`. +- Added route-level tests for GitLab workspace token exchange, exact identity drift rejection, and task/project access re-verification. +- Full repository typecheck passed. The exhaustive test run passed 402/403 API files and 5,905/5,906 API tests before exposing one stale provider-count assertion; the repaired provider contract and all focused GitLab boundary tests pass. +- Full repository build passed. +- Full VM-agent `go test -race ./...`, touched-package coverage, and `go vet ./...` passed on Go 1.25.0. +- Local mocked Playwright audit passed 10/10 across iPhone SE (375x667) and desktop (1280x800). + +## Review Notes + +- GitLab OAuth tokens are only returned through the workspace callback `git-token` flow and are not persisted/exported as static workspace env vars. +- VM credential responses are constrained by host and GitLab repository path, with `credential.useHttpPath=true` configured. +- GitLab repository metadata now stores the bare host required by Git/VM paths while continuing to use the platform config origin for OAuth/API calls. +- Known WIP deferrals: GitLab-specific member/invite repository access routes and GitLab webhook/task trigger support are not included in this stacked PR. + +## Phase 5 Specialist Review (2026-07-11 continuation) + +All 8 required local reviewers were re-run successfully (prior session could not launch them due to a `bwrap` sandbox failure). In-scope findings fixed in this continuation: + +- **task-completion-validator / test-engineer (CRITICAL/HIGH):** `POST /api/projects` GitLab branch had no direct route-level behavioral test. Added `apps/api/tests/unit/routes/project-gitlab-creation.test.ts` (9 scenarios: happy path + sidecar persistence, missing id 400, no-token 401, insufficient-access 403, not-found 404, non-default branch exists/absent, duplicate 409, sidecar-insert rollback). +- **constitution-validator / cloudflare-specialist (HIGH/MED, Principle XI):** `gitlabFetch` had no timeout. Added `GITLAB_API_TIMEOUT_MS` env (default `DEFAULT_GITLAB_API_TIMEOUT_MS=30_000`) via shared `fetchWithTimeout`; documented in `env.ts` and `.env.example`. +- **security-auditor / go-specialist (HIGH):** `isAllowedCredentialPathForWorkspace` failed open (`return true`) for unregistered workspaces. Now falls back to `s.config` RepoProvider/RepositoryPath, mirroring `isAllowedCredentialHostForWorkspace` (preserves standalone mode, does not fail open). Added `TestIsAllowedCredentialPathForWorkspaceFallsBackToConfig`. +- **test-engineer (H1):** Added `verifyGitLabProjectAccess` error-branch tests (