Skip to content

Commit 00235d1

Browse files
authored
feat: project source-control bindings for member intent starts (#333)
* feat: project source-control bindings for member intent starts (#332) Members could not start intents without a personal GitHub/GitLab connection: the orchestrator resolved the starting user's token and init-ws failed asynchronously (checkout_failed / intent_branch_push_failed). Replace per-user runtime auth with project-level source-control bindings: - Owners/admins bind each repository once, choosing GitHub App (per-repo installation discovery) or explicitly confirmed github-oauth / gitlab-oauth delegation at step 1 of the create-space flow; the App path needs no personal connection at all (repo discovery runs on App-JWT-minted metadata-read installation tokens via /github/app/*). - Bindings hold opaque credential refs in a new DynamoDB table, never tokens, and auto-invalidate when the delegator disconnects, loses scopes, or leaves the project. - Intent starts validate bindings up front (409 SOURCE_CONTROL_NOT_READY with per-repo reasons) instead of failing mid-run. - AgentCore runtime is token-free: git operations obtain short-lived credentials from a credential-broker lambda (IAM-scoped to the runtime role, validates execution liveness) via a throwaway GIT_ASKPASS helper; tokens no longer enter invocation payloads or durable history. - Provider API operations (branches, PRs, issues) route through a source-control service lambda; per-provider API proxy routes and the platform-wide GitHub auth mode are removed. Also bumps js-yaml 4.2.0 -> 4.3.0 in the lockfile (GHSA-52cp-r559-cp3m, pre-existing advisory that now trips the pre-commit audit). Closes #332 * fix: address CodeQL findings on source-control logging and repo parsing (#332) - Clear-text logging (js/clear-text-logging, alerts 17-20): error objects in the credential-resolution path can carry provider-derived text. Logging sites in credential-broker and source-control now emit only allowlisted error-code constants via loggableErrorCode() and no longer log error.message. - Polynomial regex (js/polynomial-redos): canonicalRepo trimmed leading and trailing slashes with /^\/+|\/+$/g over caller-supplied repo refs; replaced with a linear-time scan. * feat(source-control): allow GitHub App bindings without workflows:write An installation lacking Workflows: Read & write previously failed binding verification outright. There are valid reasons to withhold that permission, so treat it as recommended instead of required: the binding verifies, the reduced capability (workflows: none) is recorded on the binding, token mints skip the ungranted permission (GitHub rejects mints requesting more than the installation grants), and the project settings page shows a warning that the agent cannot touch .github/workflows/. * fix(git): survive concurrent GitLab token refreshes + restore 401 retry Review feedback on #333 (two issues, one root cause: a transient credential failure permanently invalidated the project binding). 1. GitLab refresh race: refresh tokens are one-time-use, and parallel construction lanes each request credentials separately, so two concurrent refreshes of the same connection burned each other — the loser got invalid_grant, which the broker escalated to binding invalidation. - ensureFreshGitToken now single-flights refreshes per connection within a container (shared in-flight promise). - On invalid_grant, refreshGitlabToken re-reads the stored pair before failing: if it rotated, another request (possibly another container) won the race — return its token instead of throwing. Only a genuinely revoked (un-rotated) pair still fails. 2. Project-bound GitLab operations lost the personal handler's 401 refresh-and-retry: providers were invoked with a bare {token} ctx, so an early/clock-skew 401 invalidated the binding as provider_unauthorized. resolveBindingCredential now returns a refresh callback for gitlab-oauth bindings (force-refresh past the rejected token, race-safe via staleToken) and the source-control lambda wires it into ctx.onRefresh for both operations and live validation. The broker response shape is unchanged. Tests: concurrent-refresh single-flight, cross-container race recovery, genuine revocation still failing, staleToken force/rotation semantics, and the requested end-to-end test firing two concurrent broker requests for an expired GitLab token (one refresh, both succeed, no binding invalidation). * style: format credential-broker race test * fix(source-control): stop gating GitHub App bindings on user-style repo permissions Bind-and-verify failed with INSUFFICIENT_REPOSITORY_ACCESS for a GitHub App installed on all repositories with Contents: Read & write. GET /repos returns user-authority-shaped `permissions` (push/admin) that are absent or all-false for an installation token, so access.canWrite is not a reliable signal on the App path. Write authority is already proven before the probe runs: validateGitHubAppInstallation requires contents:write on the installation, and the repo-scoped contents:write mint succeeds only when GitHub grants it. Keep the GET /repos probe (it still fails the binding when the installation cannot see the repository — 404/403) but stop requiring canWrite from it on github-app bindings, in both verifyGitHubAppBinding and the live validate-project check.
1 parent a3c895b commit 00235d1

87 files changed

Lines changed: 6974 additions & 4396 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/getting-started/setup.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ The deployment takes 15-30 minutes. Neptune DB cluster creation takes the longes
9898

9999
### Bootstrap the first platform administrator
100100

101-
The **Admin** page (user management, agent settings and default models, provider OAuth apps, GitHub auth mode, migrations) and workflow/building-block authoring require membership in the Cognito **`platform-admin`** group. Bootstrap the first administrator via the CLI (users must sign out and back in to pick up the group); afterwards, additional admins can be granted or revoked in the UI under **Admin → Users**:
101+
The **Admin** page (user management, agent settings and default models, provider OAuth/App configuration, migrations) and workflow/building-block authoring require membership in the Cognito **`platform-admin`** group. Bootstrap the first administrator via the CLI (users must sign out and back in to pick up the group); afterwards, additional admins can be granted or revoked in the UI under **Admin → Users**:
102102

103103
```bash
104104
aws cognito-idp admin-add-user-to-group \
@@ -115,12 +115,9 @@ For GitHub and GitLab a single OAuth app serves both the code host and that prov
115115

116116
#### GitHub (code host + GitHub Issues)
117117

118-
GitHub supports two platform-wide authentication modes, switchable at runtime in **Admin → Source Control → GitHub**:
118+
Configure OAuth and GitHub App independently. They remain enabled simultaneously, and each project chooses its authentication type.
119119

120-
- **OAuth mode** (default): each user connects their own GitHub account; commits, PRs and comments are attributed to that user.
121-
- **GitHub App mode**: the platform authenticates as a GitHub App installation (a bot); users don't connect personal accounts, and the repo picker lists the repositories the App is installed on.
122-
123-
For **OAuth mode**:
120+
For **GitHub OAuth**:
124121

125122
1. Open [GitHub Developer Settings → OAuth Apps → New OAuth App](https://github.com/settings/developers).
126123
Choose an **OAuth App**, _not_ a GitHub App — this mode expects OAuth App semantics.
@@ -132,12 +129,16 @@ For **OAuth mode**:
132129

133130
The connection requests `repo`, `workflow`, and `read:user`. Existing users must click **Reauthorize GitHub** after upgrading from a version that did not request `workflow`; GitHub requires that scope before the engine can push changes under `.github/workflows/`.
134131

135-
For **GitHub App mode**:
132+
For **GitHub App**:
136133

137-
1. Create a [GitHub App](https://github.com/settings/apps) with repository permissions **Contents: Read & write**, **Pull requests: Read & write**, **Workflows: Read & write**, and **Issues: Read-only**. No callback URL or webhook is needed.
134+
1. Create a [GitHub App](https://github.com/settings/apps) with repository permissions **Contents: Read & write**, **Pull requests: Read & write**, **Issues: Read & write**, and metadata read access. No callback URL or webhook is needed. **Workflows: Read & write** is recommended but optional — without it a binding still verifies, but the agent cannot create or modify files under `.github/workflows/` and the project settings page shows a warning.
138135
2. Generate a **private key** (PEM) and note the **App ID**.
139-
3. Install the App on the organization/repositories the platform should access, and note the **Installation ID** (the number at the end of the installation's settings URL).
140-
4. In the deployed app, open **Admin → Source Control → GitHub**, paste the App ID, Installation ID and private key, select **GitHub App (bot)** and click **Save**. The platform validates the configuration live against GitHub before the mode switches. Switching back to OAuth mode is the same toggle.
136+
3. Install the App on each personal account or organization whose repositories projects may bind.
137+
4. In the deployed app, open **Admin → Source Control → GitHub**, paste the App ID and private key, then click **Save**. Installation IDs are discovered per repository when a project is bound.
138+
139+
### Project-bound source control
140+
141+
Existing projects must be explicitly bound by an owner/admin before intents can run against their repositories. During a credential incident, invalidate the affected project bindings (or revoke the token at the provider) — unbound or invalid bindings block repository-backed starts via the launch guard.
141142

142143
#### GitLab (code host + GitLab Issues)
143144

docs/using-the-platform/git-integration.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ AIDLC Collaborative integrates with external systems on two independent axes:
55
- **Code host** — GitHub or GitLab. The repository is cloned into the agent workspace and all code changes flow back as a pull request (GitHub) or merge request (GitLab).
66
- **Issue trackers** — GitHub Issues, GitLab Issues, and Jira Cloud. An intent can be started from any tracker issue; the issue's title, body, and comments become the intent's brief for the agent.
77

8-
A project can bind to _one_ code host and to _zero or more_ trackers. Both are configured per project in **Project Settings**.
8+
A project can attach one or more repositories and zero or more trackers. Repository authorization is configured explicitly per project in **Project Settings**.
99

1010
GitHub and GitLab each span both axes: a single connection serves as the code host **and** backs that provider's issue tracker (GitHub Issues / GitLab Issues), so you authenticate once per provider. Jira Cloud is a tracker only.
1111

@@ -15,18 +15,21 @@ Before users can connect their accounts, an administrator registers OAuth apps w
1515

1616
The status of each provider is visible in **Admin → Trackers**. Until a provider shows **Configured**, the corresponding **Connect** button in Project Settings stays disabled with a hint pointing back to the admin panel.
1717

18-
### GitHub authentication mode
18+
### Project-bound authentication
1919

20-
GitHub supports two platform-wide authentication modes, switchable at runtime by a platform admin in **Admin → Source Control → GitHub**:
20+
GitHub OAuth and GitHub App configuration remain available at the same time. There is no platform-wide runtime mode:
2121

22-
- **OAuth mode** (default) — each user connects their own GitHub account (described below). All git activity is attributed to the individual user.
23-
- **GitHub App mode** — the platform authenticates as a GitHub App installation (a bot). Users don't connect personal GitHub accounts at all; the repo picker lists the repositories the App is installed on, and commits/PRs/comments are attributed to the App. Which repositories are reachable is controlled by the App installation on GitHub — installing/uninstalling repos there takes effect immediately.
22+
- A project owner or admin selects **GitHub OAuth** or **GitHub App** for that project's GitHub repositories.
23+
- A project may use only one GitHub authentication type, but different projects may choose differently.
24+
- OAuth delegation is explicit. The owner/admin can delegate only their own connected identity and must confirm that the project may act through it.
25+
- For GitHub App bindings, the platform discovers and stores the installation for each repository. No global installation ID is configured.
26+
- GitLab repositories use an explicitly delegated GitLab OAuth connection.
2427

25-
Switching modes takes effect for new work right away; in-flight runs finish under the mode they started with. Switching to App mode is validated live against GitHub (App ID + installation + private key) before it lands, so a broken configuration can never strand the platform.
28+
Every repository is verified before any binding is written. Existing repository-backed projects remain unbound after upgrade and cannot start until an owner/admin completes this step. Repository-free projects are unaffected.
2629

2730
## Connecting your account
2831

29-
Each user connects their own GitHub / GitLab / Atlassian account once; the connection is reused across every project that needs that provider. (In GitHub App mode there is nothing to connect for GitHub — the section below applies to OAuth mode.)
32+
Each user connects their own GitHub / GitLab / Atlassian account once. A personal connection is available for repository discovery, but a project uses it only after an owner/admin explicitly delegates it.
3033

3134
- **GitHub**: from the dashboard (or the project-creation flow), click **Connect GitHub** and approve the OAuth flow. The connection requests `repo`, `workflow`, and `read:user` so the engine can also push workflow-file changes. After upgrading an older connection that lacks `workflow`, click **Reauthorize GitHub** when prompted. The button stays disabled if your administrator hasn't configured GitHub OAuth credentials yet.
3235
- **GitLab**: choose **GitLab** as the provider in the project-creation flow, then click **Connect GitLab** and approve the OAuth flow. The required `api` scope covers repository writes, including `.gitlab-ci.yml`; GitLab has no separate workflow-file scope. The button stays disabled until your administrator has configured GitLab OAuth credentials. GitLab access tokens are short-lived; the platform refreshes them automatically using the stored refresh token, so you don't need to reconnect periodically.
@@ -38,10 +41,11 @@ A connection is scoped to its provider: connecting GitHub does not satisfy a Git
3841

3942
1. Click **Create new Project** in the project overview.
4043
2. Choose the code host — **GitHub** or **GitLab**.
41-
3. The platform checks for an active connection to that provider and prompts you to connect if one is missing.
42-
4. Pick the repository (GitHub) or project (GitLab) that should back the collaborative project.
44+
3. For GitHub, choose the authentication type: **GitHub App** (uses the platform App's installations — no personal connection needed) or **My GitHub OAuth identity** (delegates your own connection). GitLab always delegates your OAuth identity. On the OAuth paths the platform prompts you to connect if no active connection exists.
45+
4. Pick the repository (GitHub) or project (GitLab) that should back the collaborative project. On the App path the picker lists the repositories the App is installed on; on the OAuth paths it lists your own.
46+
5. Confirm the binding (OAuth delegation requires an explicit confirmation). If verification fails, the project is created unbound — rebind it in **Project Settings → Repositories** before starting intents.
4347

44-
The repository is cloned into the agent workspace and becomes available to the agents while an intent executes. Additional repositories can be added later in **Project Settings → Source Control**.
48+
The repository is cloned into the agent workspace and becomes available to the agents while an intent executes. Additional repositories can be added later in **Project Settings → Repositories**; the project binding must then be reverified.
4549

4650
## Branches
4751

@@ -81,7 +85,7 @@ The Jira and GitLab Issues integrations are **read-only** — the agent never wr
8185

8286
## Reconnecting a tracker
8387

84-
If a provider refresh token is revoked (for example, a user logs out of Atlassian or GitLab, or a workspace admin revokes the app), the tracker panel surfaces a **Reconnect** banner for that provider. The binding is preserved — only the user's authentication needs renewing — so reconnecting restores access without losing the project↔tracker relationship.
88+
If an OAuth token or refresh token is revoked, every dependent project source-control binding is marked invalid. Reconnect the personal account, then have a project owner/admin explicitly rebind the affected repositories. Removing the delegating member or uninstalling a GitHub App installation also invalidates dependent bindings.
8589

8690
For GitLab specifically, routine token expiry does **not** require reconnecting: access tokens are refreshed automatically from the stored refresh token. A reconnect is only needed if that refresh token itself is revoked.
8791

docs/using-the-platform/platform-settings.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Everything the agent runtime needs to run:
2222

2323
Platform-wide code-host configuration:
2424

25-
- **GitHub**the OAuth app credentials plus the runtime-switchable **authentication mode**: **OAuth** (each user connects their own account; activity attributed to the user) or **GitHub App** (the platform acts as an App installation — a bot; users connect nothing). Switching to App mode is validated live against GitHub before it lands. See [Git integration → GitHub authentication mode](git-integration.md#github-authentication-mode).
25+
- **GitHub**GitHub OAuth app status and GitHub App identity/private-key configuration are shown simultaneously. Projects choose OAuth or App independently; there is no global mode or installation ID. See [Git integration → Project-bound authentication](git-integration.md#project-bound-authentication).
2626
- **GitLab** — the GitLab OAuth app credentials.
2727
- **Default PR strategy****Intent PR** opens only the final intent-to-base review; **PR per unit** also opens draft unit-to-intent reviews and integrates them in dependency order. Projects may inherit or override this default. The default is **Intent PR**.
2828

frontend/src/components/CreateProjectModal.test.tsx

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import userEvent from '@testing-library/user-event';
66
// in jsdom without network or Radix portal complexity. The GitRepoSelect stub
77
// exposes a button that selects one repo, standing in for the provider-backed
88
// picker.
9+
// Per-test switch: the "App path without OAuth" test flips this to false.
10+
let oauthConnected = true;
911
vi.mock('../hooks/useGitProviderStatus', () => ({
1012
useGitProviderStatus: () => ({
11-
status: { connected: true },
13+
status: { connected: oauthConnected },
1214
loading: false,
1315
error: null,
1416
refresh: () => {},
@@ -23,7 +25,10 @@ vi.mock('./GitRepoSelect', () => ({
2325
),
2426
}));
2527
vi.mock('../services/projects', () => ({
26-
projectsService: { create: vi.fn().mockResolvedValue({ id: 'p1' }) },
28+
projectsService: {
29+
create: vi.fn().mockResolvedValue({ id: 'p1' }),
30+
delete: vi.fn().mockResolvedValue(undefined),
31+
},
2732
}));
2833
vi.mock('../services/workflows', () => ({
2934
workflowsService: {
@@ -36,14 +41,39 @@ vi.mock('../services/workflows', () => ({
3641
},
3742
}));
3843
vi.mock('../services/trackers', () => ({ trackersService: { addToProject: vi.fn() } }));
44+
vi.mock('../services/gitProvider', async (importOriginal) => {
45+
const actual = await importOriginal<typeof import('../services/gitProvider')>();
46+
return {
47+
...actual,
48+
githubAppService: {
49+
getStatus: vi.fn().mockResolvedValue({ configured: true }),
50+
listRepos: vi.fn().mockResolvedValue([]),
51+
},
52+
};
53+
});
54+
vi.mock('../services/sourceControl', () => ({
55+
sourceControlService: {
56+
bind: vi.fn().mockResolvedValue({ ready: true, repositories: [] }),
57+
},
58+
}));
3959

4060
import { CreateProjectModal } from './CreateProjectModal';
4161
import { projectsService } from '../services/projects';
62+
import { sourceControlService } from '../services/sourceControl';
4263
import { workflowsService } from '../services/workflows';
64+
import { githubAppService } from '../services/gitProvider';
4365

4466
describe('CreateProjectModal', () => {
4567
beforeEach(() => {
4668
vi.clearAllMocks();
69+
oauthConnected = true;
70+
vi.mocked(projectsService.create).mockResolvedValue({ id: 'p1' } as never);
71+
vi.mocked(projectsService.delete).mockResolvedValue(undefined as never);
72+
vi.mocked(sourceControlService.bind).mockResolvedValue({
73+
ready: true,
74+
repositories: [],
75+
});
76+
vi.mocked(githubAppService.getStatus).mockResolvedValue({ configured: true });
4777
});
4878

4979
// The workflow catalog loads on mount; flush that promise inside act so the
@@ -95,7 +125,102 @@ describe('CreateProjectModal', () => {
95125
repos: [{ url: 'acme/widgets', role: 'primary' }],
96126
}),
97127
);
128+
expect(sourceControlService.bind).toHaveBeenCalledWith('p1', {
129+
github: { authType: 'github-app' },
130+
});
131+
expect(onCreated).toHaveBeenCalled();
132+
expect(onClose).toHaveBeenCalled();
133+
});
134+
135+
it('requires explicit confirmation before delegating OAuth', async () => {
136+
const user = userEvent.setup();
137+
await renderModal(
138+
<CreateProjectModal onClose={() => {}} onCreated={() => {}} initialProvider="github" />,
139+
);
140+
141+
// Step 1: switch from the default App path to OAuth delegation.
142+
await user.click(screen.getByRole('radio', { name: /My GitHub OAuth identity/ }));
143+
await user.click(screen.getByRole('button', { name: 'Next' }));
144+
await user.click(screen.getByRole('button', { name: 'select-repo' }));
145+
await user.click(screen.getByRole('button', { name: 'Next' }));
146+
147+
const createButton = screen.getByRole('button', { name: 'Create Space' });
148+
expect(createButton).toBeDisabled();
149+
await user.click(
150+
screen.getByRole('checkbox', {
151+
name: /I confirm that this space may use my connected identity/,
152+
}),
153+
);
154+
expect(createButton).toBeEnabled();
155+
await user.click(createButton);
156+
157+
await waitFor(() =>
158+
expect(sourceControlService.bind).toHaveBeenCalledWith('p1', {
159+
github: { authType: 'github-oauth', confirmDelegation: true },
160+
}),
161+
);
162+
});
163+
164+
it('lets an unconnected user create a space via the GitHub App path', async () => {
165+
oauthConnected = false;
166+
const user = userEvent.setup();
167+
const onCreated = vi.fn();
168+
await renderModal(
169+
<CreateProjectModal onClose={() => {}} onCreated={onCreated} initialProvider="github" />,
170+
);
171+
172+
// App is configured, so Next is enabled despite connected=false.
173+
const nextButton = screen.getByRole('button', { name: 'Next' });
174+
await waitFor(() => expect(nextButton).toBeEnabled());
175+
await user.click(nextButton);
176+
await user.click(screen.getByRole('button', { name: 'select-repo' }));
177+
await user.click(screen.getByRole('button', { name: 'Next' }));
178+
const createBtn = await screen.findByRole('button', { name: 'Create Space' });
179+
await waitFor(() => expect(createBtn).toBeEnabled());
180+
await user.click(createBtn);
181+
182+
await waitFor(() =>
183+
expect(sourceControlService.bind).toHaveBeenCalledWith('p1', {
184+
github: { authType: 'github-app' },
185+
}),
186+
);
98187
expect(onCreated).toHaveBeenCalled();
188+
});
189+
190+
it('blocks the App path and falls back to OAuth when the App is unconfigured', async () => {
191+
vi.mocked(githubAppService.getStatus).mockResolvedValue({ configured: false });
192+
oauthConnected = false;
193+
await renderModal(
194+
<CreateProjectModal onClose={() => {}} onCreated={() => {}} initialProvider="github" />,
195+
);
196+
197+
// App option disabled; selection fell back to OAuth, which is not
198+
// connected — so Next stays disabled.
199+
await waitFor(() => expect(screen.getByRole('radio', { name: /GitHub App/ })).toBeDisabled());
200+
expect(screen.getByRole('radio', { name: /My GitHub OAuth identity/ })).toBeChecked();
201+
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
202+
});
203+
204+
it('keeps a newly created project (unbound) when binding verification fails', async () => {
205+
vi.mocked(sourceControlService.bind).mockRejectedValueOnce(
206+
new Error('GitHub App is not installed for acme/widgets'),
207+
);
208+
const user = userEvent.setup();
209+
const onCreated = vi.fn();
210+
const onClose = vi.fn();
211+
await renderModal(
212+
<CreateProjectModal onClose={onClose} onCreated={onCreated} initialProvider="github" />,
213+
);
214+
215+
await user.click(screen.getByRole('button', { name: 'Next' }));
216+
await user.click(screen.getByRole('button', { name: 'select-repo' }));
217+
await user.click(screen.getByRole('button', { name: 'Next' }));
218+
await user.click(await screen.findByRole('button', { name: 'Create Space' }));
219+
220+
// The project survives unbound; the launch guard blocks repository-backed
221+
// starts until an owner rebinds it in project settings.
222+
await waitFor(() => expect(onCreated).toHaveBeenCalled());
223+
expect(projectsService.delete).not.toHaveBeenCalled();
99224
expect(onClose).toHaveBeenCalled();
100225
});
101226
});

0 commit comments

Comments
 (0)