Skip to content

feat(coding-agent): managed-acp provider + per-user GitHub token provisioning (draft)#967

Draft
aatchison wants to merge 4 commits into
mainfrom
coding-agent-broker-provider
Draft

feat(coding-agent): managed-acp provider + per-user GitHub token provisioning (draft)#967
aatchison wants to merge 4 commits into
mainfrom
coding-agent-broker-provider

Conversation

@aatchison

@aatchison aatchison commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Draft / RFC. Managed-acp provider for a self-hosted Cline coding agent, plus (P3b) the user-facing github_connect / github_status assistant tools. Opening for design review. Authored from an external integration repo, so committed via git rather than /thunderpush.

Related: the broker, workspace operator, agent (Cline + ACP shim) container, and ArgoCD deploy manifests this provider talks to all live in thunderbird/thunderbolt-coding-agent — see its broker POST /github/provision endpoint (the contract this PR's provision.ts calls) and the multi-user GitHub-auth design doc.

What

Adds a coding-agent managed-acp provider (mirrors the Haystack provider) that surfaces a self-hosted Cline workspace agent. On WS connect the backend authenticates the developer (Better-Auth bearer), asks a broker to provision a per-user GitHub App user-to-server token (so the agent commits/opens PRs as the developer, not a shared bot), then proxies ACP frames to the workspace shim.

  • coding-agent/provision.tsPOST /github/provision to the broker with a service token + the Better-Auth user.id. Timeout + retry on transient 5xx/network; 409→not_connected, 501→disabled, other→failed. The GitHub token never transits Thunderbolt — the broker writes it straight into the workspace Secret.
  • coding-agent/provider.ts — advertises one managed-acp websocket agent at /v1/coding-agent/ws, only when CODING_AGENT_WORKSPACE_WS_URL is set.
  • coding-agent/proxy.ts — a bounded, lifecycle-guarded bidirectional WS bridge to the workspace shim (ports the discipline of backend/src/proxy/ws.ts: closing flag, connect timeout, capped pre-connect queue).
  • coding-agent/routes.tsWS /v1/coding-agent/ws: auth + provision in open() wrapped so a broker/network failure closes the socket (Elysia onError doesn't cover WS callbacks). Close codes: 4001 unauthorized, 4002 github-not-connected (UI should prompt connect), 4003 provisioning failed / not configured.
  • configCODING_AGENT_WORKSPACE_WS_URL / CODING_AGENT_BROKER_URL / CODING_AGENT_SERVICE_TOKEN; superRefine pairs broker URL ↔ service token.

⚠️ Known limitation (single shared workspace)

Per-user provisioning is wired, but all sessions currently proxy to one shared CODING_AGENT_WORKSPACE_WS_URL — per-user workspace routing doesn't exist yet, so concurrent users would share one workspace and the last-provisioned token. A startup WARN makes this explicit. Single-user / PoC-safe only; per-user workspace routing is the next increment.

Tests

bun test — provision (retry/timeout/disabled/mapping), provider (advertisement gating), proxy (buffering/overflow/timeout/dispose/error/reserved-codes), and a WS routes auth-gate + provision-close-code suite mirroring haystack/routes.test.ts. P3b adds: github.test.ts (broker-client mapping incl. bad-body / never-carries-end-user-token), github-routes.test.ts (DTOs + 401 + asserts the broker is called with the session user.id, driven via .handle() against injected seams — no server/WS spin, CI-5x-safe), and integrations/coding-agent/tools.test.ts (no-arg schema + message shaping). tsc + eslint clean on all new files.

P3b — github_connect / github_status assistant tools

Adds the user-facing GitHub-connect surface for the built-in assistant (the design's "MCP management plane"), reusing this PR's broker base URL + service token:

  • backend/coding-agent/github.ts — read-only broker client (GET /github/authorize-url, GET /github/status), mirroring provision.ts: service-token auth + x-tb-user-id, timeout/retry, 501→disabled, body-free failed reasons so a broker error body never reaches logs.
  • backend/coding-agent/github-routes.ts — authenticated GET /v1/coding-agent/github/{authorize-url,status}. The developer's user.id is resolved server-side from the Better-Auth session ({ auth: true }) and forwarded to the broker — it's never a request/tool argument, so the model invoking the tool cannot act as another user. Returns a discriminated DTO (configured / ok / disabled / failed) in all reachable cases; mounted alongside the WS route in backend/src/index.ts.
  • src/integrations/coding-agent/{api,tools}.tsgithub_connect (returns Connect your GitHub: <url>) and github_status (connected / not-connected) ToolConfigs with no model-supplied args, calling the backend via the authenticated HttpClient. Registered in getAvailableTools (always offered — backend reports configured: false cleanly when the broker isn't wired).

Why the identity can't be spoofed: the only place user.id enters is the resolved session on the backend; the tool schema is an empty object, and the client never sends an id. This matches the WS provider's identity model.

Runtime/deploy note: these tools assume the Thunderbolt backend can reach the broker at CODING_AGENT_BROKER_URL with CODING_AGENT_SERVICE_TOKEN (same wiring as provisioning) — verify in-cluster reachability before relying on them in a deployment.

Relationship to native Thunderbolt Workspaces (THU-549/550)

Heads-up on a terminology collision: this PR's "workspace" means a compute sandbox (a pod running Cline + a repo checkout), which is a different axis from the native Workspaces tenancy primitive being built in THU-549 (Foundation) / THU-550 (data layer) — that one is a team/trust-domain with workspace_id scoping, membership, and invite-by-email.

They're complementary, not conflicting:

  • GitHub identity stays per-user.id (a GitHub account is personal, not per-team), so the provisioning model here is unaffected by Workspaces.
  • The per-user sandbox routing noted under the single-workspace limitation should, when built, key the sandbox off (user.id, workspaceId) and depend on the THU-550 primitive rather than inventing a parallel tenancy. Tracked separately.
  • feat(THU-591): expose ACP agent commands in the chat slash menu #964 (available_commands_update → slash menu) surfaces this agent's commands for free once Cline advertises them.

Not in this PR

Per-user sandbox routing (to integrate with native Workspaces / THU-550) — still the single-shared-workspace limitation above.

🤖 Generated with Claude Code

aatchison and others added 2 commits June 10, 2026 10:49
…connect

Integrate the self-hosted Cline coding agent as a managed-acp agent. On WS
connect the backend authenticates the developer (Better-Auth bearer), asks the
coding-agent broker to provision a per-user GitHub token (so Cline acts AS the
developer — their commits/PRs), then proxies ACP frames to the workspace shim.

- coding-agent/provision.ts: provisionWorkspaceToken() — POST /github/provision
  to the broker with a service token + the Better-Auth user.id; maps 409 →
  not_connected, other non-2xx → failed. The user-to-server token never transits
  Thunderbolt; the broker writes it straight into the workspace Secret.
- coding-agent/provider.ts: advertises a single managed-acp websocket agent
  pointing at /v1/coding-agent/ws, only when CODING_AGENT_WORKSPACE_WS_URL is set.
- coding-agent/proxy.ts: a thin bidirectional WS bridge to the workspace shim
  (buffers client frames until upstream open). Injectable upstream for tests.
- coding-agent/routes.ts: WS /v1/coding-agent/ws — auth + provision in open()
  (close 4001 unauthorized, 4002 github-not-connected so the UI can prompt
  connect, 4003 provisioning/config failure), then bridge.
- config: CODING_AGENT_WORKSPACE_WS_URL / CODING_AGENT_BROKER_URL /
  CODING_AGENT_SERVICE_TOKEN. Mounted in index.ts next to Haystack.

Mirrors the Haystack managed-acp provider's auth-in-open() pattern. 12 tests
(provision mapping, provider advertisement gating, proxy buffering/piping/
teardown); type-check + eslint clean.

Note: committed with git directly rather than /thunderpush (authored from an
external integration repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, limits, tests

Actions the multi-lens review of the P3a patch. Ports the discipline of the
existing universal relay (backend/src/proxy/ws.ts).

Lifecycle / concurrency (proxy.ts, routes.ts):
- single `closing` flag guards every upstream listener; `open` flush aborts if
  already closing; `message`/`send` ignored after teardown (no send on a closed
  socket).
- client-disconnect-during-await no longer leaks the upstream: routes tracks
  `clientClosed` on ws.data and disposes if the client left during provisioning.
- connect timeout bounds a hung upstream; bounded pre-connect queue (count+bytes)
  closes 4008 on overflow instead of growing unboundedly.

Errors / resilience (provision.ts, routes.ts):
- provision fetch now has a timeout (AbortSignal) and retries transient 5xx /
  network errors; 409/501/other-4xx are terminal (no retry).
- open() wraps provision + proxy construction in try/catch and closes the socket
  (Elysia onError does NOT cover WS callbacks) — a broker outage or bad upstream
  URL closes cleanly instead of leaking an unhandled rejection / hung socket.

Contract / correctness:
- upstream close codes pass through (reserved 1005/1006/1015 → 1011); 4003 is now
  reserved strictly for pre-session provisioning failure, not mid-session drops.
- broker 501 maps to `disabled` (proceed read-only), distinct from `failed`.
- exhaustive ProvisionResult switch with a `never` default.

Security / observability:
- upstream-provided close reason is logged server-side but never relayed to the
  client (generic reason only).
- one reused logger (not per-connection); structured logs on every provision
  outcome and upstream close/error.

Types / config:
- single typed `wsData(ws)` accessor replaces scattered `as unknown as` casts;
  literal-union event types on the upstream socket.
- settings: trim the coding-agent fields; superRefine pairs brokerUrl<->token so
  a half-config fails at boot, not opaquely at connect.

Honesty: routes doc + a startup WARN make the single-shared-workspace caveat
explicit (per-user provisioning, shared workspace — single-user/PoC only).

Tests (12 -> 33): proxy buffering/overflow/timeout/dispose/error/reserved-code;
provision retry/timeout/disabled/network; NEW routes.test.ts WS auth-gate (4001)
+ provision close-codes (4002/4003) mirroring haystack/routes.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Semgrep Security Scan

No security issues found.

@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown

Preview environment deployed 🚀

Service URL
Marketing / blog / docs https://thunderbolt-pr-967.preview.thunderbolt.io
App https://app-pr-967.preview.thunderbolt.io
API https://api-pr-967.preview.thunderbolt.io
Keycloak https://auth-pr-967.preview.thunderbolt.io
PowerSync https://powersync-pr-967.preview.thunderbolt.io

Stack: preview-pr-967 · Commit: af0de221d01f2635966d697cb00a54929bda1e25

Auto-destroys on PR close/merge. Login via the bundled Keycloak realm — demo@thunderbolt.io / demo by default.

@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown

PR Metrics

Metric Value
Lines changed (prod code) +984 / -0
JS bundle size (gzipped) 🟢 732.1 KB → 737.1 KB (+5.0 KB, +0.7%)
Test coverage 🟢 77.17% → 78.19% (+1.0%)
Performance (preview) Preview not ready — Render deploy may have timed out
Accessibility
Best Practices
SEO

Updated Fri, 12 Jun 2026 16:27:46 GMT · run #1875

… fix CI 5x timeout

The backend CI job runs the suite 5x with a 10-min cap; the prior routes.test.ts
spun a DB-backed Elysia server per close-code (5 tests x 5 runs = 25 app spins)
and tipped it over the timeout.

- Refactor routes.ts: extract the open/message/close bodies into exported,
  deps-injected handlers (handleCodingAgentOpen/Message/Close + CodingAgentOpenCtx
  + a structural ws type). Thread createUpstream into the proxy and add an
  injectable `provision` seam. Replace `let result` with a `tryProvision` helper
  (const, early-return) per the style guide.
- Rewrite routes.test.ts as fast unit tests (no bound port): auth 4001 (no
  bearer / unauthenticated), provision ok/disabled/not_connected(4002)/throw
  (4003)/exhaustive-default(4003)/not-configured(4003), client-closed-during-open
  abort, upstream-construct-failure(4003), message no-op/verbatim/JSON, close
  dispose, + a createCodingAgentRoutes construction/ WARN-branch test. Whole
  suite now runs in ~2s with zero server spins.
- proxy.test.ts: cover all reserved abnormal close codes (1005/1006/1015 -> 1011)
  with a positive control, and the queueBytes byte-budget overflow path.

Coverage: provider/provision 100%, proxy 100% lines, routes.ts 90% lines (only
the Elysia delegation boilerplate uncovered — exercised repo-wide by the haystack
WS suite). 49 tests; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the broker's per-user GitHub OAuth authorize URL + connection status
to the built-in assistant so a developer can connect GitHub from chat.

Backend (reuses #967's broker URL + service-token wiring):
- github.ts: read-only broker client (GET /github/authorize-url, /github/status)
  mirroring provision.ts — service-token auth + x-tb-user-id, timeout/retry,
  body-free reasons so a broker error body never reaches logs.
- github-routes.ts: authenticated GET /coding-agent/github/{authorize-url,status}.
  The developer's user.id is resolved server-side from the Better-Auth session
  ({ auth: true }) and forwarded to the broker — never a request/tool argument,
  so the model cannot act as another user. Returns a discriminated DTO
  (configured/ok/disabled/failed) in all reachable cases.
- mounted in backend/src/index.ts alongside the existing coding-agent routes.

Frontend:
- src/integrations/coding-agent/{api,tools}.ts: github_connect / github_status
  ToolConfigs (no-arg schemas) calling the backend via the authenticated
  HttpClient; registered in getAvailableTools (always offered — backend reports
  configured:false cleanly when the broker isn't wired).

Tests: unit-only against injected deps (no server/WS spin) — broker client
mapping, route DTOs + that the broker is called with the session user.id, and
the client tools' message shaping. type-check + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant