+ );
+}
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 (
+ 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 (