Skip to content

feat: project source-control bindings for member intent starts#333

Open
JWThewes wants to merge 7 commits into
mainfrom
feat/project-source-control-auth
Open

feat: project source-control bindings for member intent starts#333
JWThewes wants to merge 7 commits into
mainfrom
feat/project-source-control-auth

Conversation

@JWThewes

Copy link
Copy Markdown
Contributor

Closes #332

Problem

Members could not start intents without a personal GitHub/GitLab connection. The intent created and started fine (201/202), but the run failed asynchronously: the orchestrator resolved the starting user's personal token (no_connection), and init-ws hard-failed with checkout_failed / intent_branch_push_failed. Raw git tokens were also snapshotted into AgentCore payloads and durable execution history (no delete API, 90-day retention).

Approach

Per-user runtime auth is replaced with project-level source-control bindings:

  • Bind once, per project — an owner/admin chooses the auth type at step 1 of the create-space flow: GitHub App (installation discovered per repo; no personal connection needed — repo discovery runs on App-JWT-minted metadata-read installation tokens via new /github/app/status|repos routes), or explicitly confirmed GitHub OAuth / GitLab OAuth delegation of the binder's connection. Bindings live in a new DynamoDB table holding only opaque credential references, never tokens, and auto-invalidate when the delegator disconnects, loses scopes, or leaves the project.
  • Fail fast — intent starts validate bindings up front and return 409 SOURCE_CONTROL_NOT_READY with per-repo reasons instead of failing mid-run.
  • Token-free runtime — AgentCore git operations obtain short-lived credentials from a dedicated credential-broker lambda (IAM-scoped to the runtime role; validates execution liveness and repo membership) through a throwaway GIT_ASKPASS helper. Tokens no longer enter invocation payloads or durable history.
  • One provider surface — provider API operations (branches, trees, PRs, issues) route through a source-control service lambda that never returns tokens; the per-provider API proxy routes and the platform-wide GitHub auth mode are removed.

Testing

  • Backend: all workspace suites green (intents, source-control, credential-broker, agentcore, orchestrator, trackers, shared, github/gitlab).
  • Frontend: full vitest suite green (54 files / 397 tests) incl. new modal tests for the App path without a personal connection and the unconfigured-App fallback; tsc -b clean.
  • Terraform: validate + fmt -check -recursive clean (backend-config init errors pre-exist).

Notes for reviewers

  • The create-space flow now offers GitHub App vs OAuth at step 1 when both are configured; the App path works end to end for users who never connected GitHub.
  • A failed bind leaves the project unbound (launch guard blocks starts) instead of deleting it.
  • Incident response: invalidate the affected bindings / revoke the token at the provider — unbound/invalid bindings block repository-backed starts. There is deliberately no global kill switch.

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
Comment thread lambda/credential-broker/index.js Fixed
Comment thread lambda/shared/source-control-bindings.js Fixed
Comment thread lambda/source-control/index.js Fixed
Comment thread lambda/source-control/index.js Fixed
Comment thread lambda/source-control/index.js Fixed
…ng (#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.
JWThewes added 2 commits July 22, 2026 00:01
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/.

@jeromevdl jeromevdl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitlab token refresh

When a GitLab access token is near expiry, two git operations can try to refresh it at the same time.
lambda/shared/git-token.js:125 reads the stored token and refreshes it, but does not prevent another request from doing the same. GitLab replaces the refresh token on a successful refresh. That means the first request succeeds and saves the new token pair, while the second request uses the now-invalid old refresh token and gets invalid_grant.

This PR can trigger that situation because it asks the credential broker for credentials separately for each git operation (lambda/agentcore/git-auth.js:42). Parallel construction lanes can therefore refresh the same GitLab connection simultaneously. The failed request is treated as a broken credential and invalidates the project’s source-control binding (lambda/shared/source-control-bindings.js:281), blocking repository work until an owner rebinds it.

Please make refresh single-flight per GitLab connection, or re-read the stored token when a refresh fails before marking the binding invalid. Add a test that issues two credential-broker requests concurrently for an expired GitLab token.

Project-bound GitLab API operations lost the existing 401 refresh-and-retry behavior.

The GitLab provider retries only when ctx.onRefresh is provided (lambda/shared/git-providers/gitlab.js:54). The new source-control path invokes providers with only { token } (lambda/source-control/index.js:365), so an early/clock-skew/revoked access-token 401 is not refreshed. Instead it invalidates the binding as provider_unauthorized (lambda/shared/source-control-bindings.js:277). The old personal GitLab handler still has onRefresh; the PR’s project-bound path does not.

Validation

✅ Tested with gitlab end-to-end, clone => branch => PR all worked, didn't face any of the edge case above

JWThewes added 3 commits July 22, 2026 13:39
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).
…po 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.
@JWThewes

Copy link
Copy Markdown
Contributor Author

@jeromevdl I addressed your comments and also fixed the GH App issue. after pulling this, the previously-failed binding should verify on the next "Bind and verify" click there's no config change needed on the GitHub App side

@JWThewes
JWThewes requested a review from jeromevdl July 22, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Members cannot start intents without a personal GitHub/GitLab connection

3 participants