Skip to content

feat: remote agent backend providers - #134

Merged
tlongwell-block merged 34 commits into
mainfrom
feat/remote-agent-providers
Mar 21, 2026
Merged

feat: remote agent backend providers#134
tlongwell-block merged 34 commits into
mainfrom
feat/remote-agent-providers

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

Summary

Deploy agents to remote infrastructure via external provider binaries. Stop them with a chat message. Status from relay presence.

Architecture

Desktop (user key U)                Relay                     Remote
│ 1. generate agent key A           │                         │
│ 2. mint token + set owner ───────▶│ agent_owner = U         │
│ 3. invoke provider deploy ────────┼────────────────────────▶│ sprout-acp ──WS──▶ Relay
│                                   │                         │
│ stop: @A "!shutdown" via WS ─────▶│────forward────────────▶│ owner? → exit
  • Desktop owns user identity. Creates agent identity. Sets ownership during token mint. Delegates infra to a provider binary. Stops agents via relay message.
  • Provider (sprout-backend-<id>) deploys infra + harness. Two operations: info and deploy. That's it.
  • Harness learns its owner at startup. Recognizes !shutdown from owner in normal @mention flow. Exits gracefully. Presence goes offline.
  • Relay mediates everything. No new event kinds. No new subscriptions.

Changes

Relay (sprout-relay)

  • owner_pubkey field in POST /api/tokens (NIP-98 bootstrap only, first mint only)
  • Ownership conflict detection (409 CONFLICT if different owner already set)
  • Orphaned tokens revoked on owner assignment failure
  • ensure_user for owner FK constraint (matches sprout-admin pattern)
  • agent_owner_pubkey exposed in GET /api/users/me/profile and /{pubkey}/profile

Harness (sprout-acp)

  • Queries agent owner at startup via user profile endpoint
  • Handles !shutdown from owner: kind:9, exact content, #p tag mention, owner pubkey check
  • Non-owner !shutdown falls through to normal prompt handling (not dropped)
  • Reuses existing graceful shutdown path (finish turn → presence offline → exit)

Desktop Backend (Tauri)

  • BackendKind enum: Local | Provider { id, config }
  • Provider IPC module (backend.rs): invoke, deploy, discover, validate, secret redaction
  • Lifecycle routing: create/start branch on BackendKind
  • Provider config validated before token minting (no irreversible side effects on validation failure)
  • stop_managed_agent rejects remote agents (frontend handles via WebSocket)
  • get_managed_agent_log rejects remote agents
  • Redeploy failure persists last_error on record
  • Create deploy failure persists last_error on record
  • Restore/shutdown skip remote agents (durable)
  • users:read added to default agent token scopes (for owner lookup)

Desktop Frontend (TypeScript)

  • CreateAgentDialog: "Run on" selector, trust warning with binary path, config form from config_schema
  • Agent table: Deploy/Redeploy + Shutdown actions for remote agents, PresenceDot next to name
  • Two-axis status: control-plane badge (deployed/not_deployed/running/stopped) + live presence dot (online/away/offline)
  • handleStop: remote → sendChannelMessage("!shutdown"), local → existing stop command
  • handleDelete: remote → sends !shutdown first, orphan warning if no channel available
  • channelAgents: skip restart for remote (harness auto-discovers channels via membership notifications)
  • "deployed" status propagated through all frontend checks (sort, badge, model picker, etc.)
  • Logs hidden for remote agents (row click + menu item)
  • Auto-start toggle hidden for remote agents
  • Section heading: "Managed agents" (was "Managed locally")
  • Channel-add dialog text updated for remote agents

Design Decisions

# Decision Rationale
1 Provider protocol: info + deploy only Maximum minimal. Provider owns installation.
2 Stop via !shutdown @mention Relay-mediated. No new event kinds. No provider stop operation.
3 Ownership during first token mint Atomic with identity creation. NIP-98 proves key custody.
4 Two status axes Control-plane (did we deploy?) is separate from live (is it connected?).
5 Remote agents are durable Desktop exit does not stop them. Presence shows if alive.
6 Nothing provider-specific in repo Sprout is OSS. Provider binaries are external.

Test Results

  • cargo check --workspace
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo fmt --all -- --check
  • pnpm typecheck
  • pnpm build
  • pnpm check ✅ (biome)
  • Unit tests (sprout-core, sprout-auth): 92/92 passed ✅

Deferred (v2)

  • Audit trail for force-deleted orphaned deployments
  • Provider status and logs operations
  • Provider versioning / protocol negotiation
  • start_on_app_launch support for remote agents
  • Integration test with external provider binary

Deploy agents to remote infrastructure via external provider binaries.
Stop them with a chat message. Status from relay presence.

Architecture: Desktop discovers sprout-backend-* binaries in PATH, invokes
them via JSON-over-stdio (info + deploy), and delegates all infra work.
Agents are stopped by the owner sending '!shutdown' @mention through the
relay. Presence heartbeats provide live online/offline status.

Changes across relay, harness, desktop backend, and desktop frontend:

Relay: owner_pubkey in token mint (NIP-98 only, first mint, revoke on
failure/conflict). agent_owner_pubkey in user profile endpoints.

Harness: queries owner at startup, handles !shutdown from owner (kind:9,
exact content, #p tag, owner check). Non-owner falls through to prompt.

Desktop backend: BackendKind enum, provider IPC module, lifecycle routing,
config validation before mint, stop/log reject remote, last_error on
deploy failure, restore/shutdown skip remote.

Desktop frontend: Run-on selector in CreateAgentDialog, trust warning,
config form from schema, Deploy/Redeploy + Shutdown actions, PresenceDot,
logs hidden for remote, !shutdown on delete, channelAgents skip restart
for remote, deployed status propagated through all checks.

Design: provider protocol is info + deploy only. Stop is relay-mediated
(!shutdown @mention from owner). Two status axes: control-plane
(deployed/not_deployed) and live (presence). Remote agents are durable.
Nothing provider-specific in this repo.
@tlongwell-block
tlongwell-block force-pushed the feat/remote-agent-providers branch from fa1c9e6 to 2e40cda Compare March 20, 2026 19:37
Resolve conflicts in check-file-sizes.mjs (combined override entries from
both branches, bumped tauri.ts limit to 1100 to cover remote-agent + canvas)
and tauri.ts (kept both BackendProvider and Canvas type imports).
invoke_provider: replace manual try_wait loop with wait_with_output on a
dedicated thread. The OS drains stdout+stderr concurrently, preventing
pipe buffer deadlocks from chatty providers.

AgentsView: relay agents endpoint returns channel *names*, not UUIDs.
sendChannelMessage needs UUIDs. Add channel name→UUID resolution via
useChannelsQuery so !shutdown actually reaches the relay.

probe_backend_provider: validate the requested binary path against
discovered sprout-backend-* candidates via canonicalize(). Prevents
arbitrary binary execution from a compromised frontend or IPC.

validate_provider_config: use word-boundary matching for forbidden key
detection instead of substring. 'keyboard_layout' no longer rejected.
Every other field in ManagedAgent uses camelCase (personaId, relayUrl,
lastStartedAt). backend_agent_id was the only snake_case outlier.

RawManagedAgent keeps snake_case (matches Rust serde output).
fromRawManagedAgent maps it to backendAgentId.
set_agent_owner now uses a conditional UPDATE:
  UPDATE users SET agent_owner_pubkey = $1
  WHERE pubkey = $2 AND agent_owner_pubkey IS NULL

Returns Ok(true) if set, Ok(false) if already owned. This eliminates
the TOCTOU race where two concurrent bootstrap mints could both succeed
and last-write-wins would silently steal ownership.

tokens.rs: simplified — the DB handles the 'only if NULL' check
atomically, so the separate get_agent_channel_policy read before write
is no longer needed for the happy path. Conflict check (existing owner
differs from requested) still reads after Ok(false).

sprout-admin: warns if agent already has an owner.

New test: test_set_agent_owner_already_owned verifies second set returns
false and original owner is preserved.
…Case secrets

Extract shared deploy_to_provider() and build_deploy_payload() from
duplicated logic in create_managed_agent and start_managed_agent.

invoke_provider: on timeout, kill the child process by PID via SIGKILL
(Unix) or taskkill (Windows). Previously the child was moved into a
detached thread and would keep running after timeout.

validate_provider_config: add split_config_key() that splits on
separators (_-.) AND camelCase boundaries. Now catches apiKey,
accessToken, clientSecret in addition to snake_case variants.

Removes the 'let _ = relay_url' suppression.
…UIDs

invoke_provider: replace unsafe PID-based kill with Arc<Mutex<Option<Child>>>.
The worker thread takes() ownership for wait_with_output; the timeout path
calls kill()+wait() on the original handle. No raw PIDs, no unsafe, no
PID-reuse risk.

split_config_key: handle consecutive uppercase runs as acronyms.
'APIKey' → ['api','key'], 'apiKEY' → ['api','key'], 'accessTOKEN' →
['access','token']. Previously these split per-character and bypassed
the forbidden-word check.

Relay /api/agents: return channel_ids (UUIDs) alongside channel names.
The DB query now aggregates c.id::text in addition to c.name. Frontend
uses channelIds directly for sendChannelMessage — no name→UUID lookup
needed, eliminating the channel-name-uniqueness ambiguity entirely.
invoke_provider: drain stdout/stderr on dedicated threads (preventing
pipe deadlocks), then wait() on the child through Arc<Mutex<Child>>.
The child is never taken/moved, so the timeout path can always call
kill()+wait(). No raw PIDs, no unsafe, no ownership race.

Relay /api/agents: resolve channel UUIDs from the accessible_channels
list (which already has both name and id) instead of a separate
string_agg in the DB query. This avoids the ordering mismatch between
two independent string_agg calls with different ORDER BY keys.

Reverts the channel_ids DB column addition — the relay handler now
builds channel_ids from the name→id map it already has.
The previous mutex-based approach deadlocked: the wait thread held the
mutex while blocked in wait(), so the timeout path could never acquire
it to call kill().

New design: drain stdout/stderr on background threads (prevents pipe
deadlocks), then poll try_wait() on the main thread with a deadline.
The child stays on the main thread — kill() is always directly reachable
on timeout. No mutex, no unsafe, no PID reuse, no ownership issues.

This is the original try_wait pattern from the PR, but now correct
because the pipe handles are drained concurrently on separate threads.
…heck

get_bot_members: return channels as json_agg of {name, id} objects
instead of string_agg of names. Each entry has a paired name↔UUID —
no separate aggregation ordering issues, no name→UUID mapping needed.

Relay /api/agents: filter bot channels by accessible channel IDs
directly from the paired entries. Eliminates the name_to_id HashMap
and the accessible_names HashSet — uses accessible_ids instead.

tokens.rs: propagate DB errors in the Ok(false) ownership-check branch
instead of swallowing them with .ok(). A transient read failure now
returns 500, not a false 409 CONFLICT.

invoke_provider doc comment: updated to match the current try_wait
implementation (was stale, referenced Arc<Mutex<Child>>).
When a provider binary exits without producing JSON on stdout, the error
now includes the exit code (e.g. 'exit code 1, empty stderr') instead of
just 'no JSON response. stderr:'. This makes it much easier to debug
provider scripts that fail silently due to set -euo pipefail.
The running relay may not have the json_agg channel_ids field yet
(requires relay redeployment). When channelIds is empty, resolve
the channel name from the channels query as a fallback. This makes
the !shutdown flow work with both old and new relay versions.
@tlongwell-block
tlongwell-block force-pushed the feat/remote-agent-providers branch from 775ffd9 to 0af8630 Compare March 20, 2026 21:41
…viders

* origin/main:
  feat(desktop): add agent teams (#132)
Picks up feat(desktop): add agent teams (#132).
Bumps AgentsView.tsx file-size override to 650 (teams added ~90 lines).
rustls-webpki 0.103.9 → 0.103.10 fixes RUSTSEC-2026-0049 (CRL
distribution point matching). The 0.101.7 branch is pinned by the
old rustls 0.21 stack (transitive via rust-s3) and has no fix
available — added to deny.toml ignore list.
…validation

- invoke_provider: fail on non-zero exit before parsing JSON (prevents
  accepting partial output from crashed providers)
- tokens.rs: log revocation failures instead of silently discarding
  (two let _ = paths now surface errors via tracing::error)
- agents.rs: remove duplicate validate_provider_config call in Phase 3
  (already validated in Pre-Phase 2, config hasn't changed)
- Document request_id as advisory for provider-side logging (not
  validated in response — stdin/stdout is 1:1 per process)
…tatus model

- sprout-acp: retry owner lookup 3x with exponential backoff so a
  transient relay failure during startup doesn't permanently disable
  remote !shutdown for the process lifetime
- discover_provider_candidates: filter to executable files only (Unix
  mode bits check) so the UI doesn't advertise non-runnable binaries
- runtime.rs: document the two-axis status model (control-plane
  deployed/not_deployed vs. live presence online/away/offline) and why
  'deployed' persists after !shutdown (infrastructure still exists)
- agents.rs: document redeploy idempotency expectation for providers
  (update-in-place or no-op; explicit undeploy deferred to v2)
…rd delete

- Provider-backed agents now require mintToken=true (Rust rejects at
  create, TS forces in UI). Without a minted token, ownership is never
  established and !shutdown is silently ignored — the agent becomes
  uncontrollable. Belt and suspenders: backend validates, frontend
  prevents the invalid state from being submitted.

- invoke_provider: all error paths (stdin failure, try_wait error,
  timeout) now go through cleanup that kills/waits the child and joins
  drain threads. Prevents zombie provider processes on early failures.

- Delete of deployed remote agents: if presence is online/away, sends
  !shutdown then requires explicit user confirmation before removing the
  local record. If presence is already offline, proceeds directly. If
  agent has no channel, warns about orphaning (existing behavior).
…config

- Rust: reject provider-backed agents unless token scopes include
  messages:read, messages:write, channels:read, users:read. Without
  users:read the harness can't query its owner and !shutdown is ignored.
- TS: derive effectiveMintToken = isProviderMode || mintToken and use it
  for canSubmit validation, toggle rendering, token section visibility,
  and submit payload. Fixes state bug where switching to provider mode
  with minting previously off would hide the token section but still
  mint a token with stale/empty scopes.
- TS: initialize providerConfig from config_schema defaults when probe
  succeeds, so unchanged defaults are included in the submit payload.
- TS: coerceConfigValues converts string form values to schema-declared
  types (number, boolean) before submit. Providers with typed config
  no longer receive '3' instead of 3.
- Bump CreateAgentDialog file size limit to 650 (was 600).
…nnel fallback

- CreateAgentDialog: canSubmit no longer gates provider-mode creation on
  local ACP/MCP prerequisites (isSpawnSupported check skipped when
  isProviderMode). Providers deploy remotely — local spawn infra is
  irrelevant.
- invoke_provider: join_with_timeout helper wraps thread joins in a
  channel-based timeout. If a provider daemonizes or leaves descendants
  holding stdout/stderr open, the caller proceeds after 5s instead of
  hanging forever. Cleanup helper uses 2s bounded joins.
- AgentsView: channel-name→UUID fallback (for old relays without
  channel_ids) now requires a unique match. If multiple channels share
  the same name, returns null so the UI surfaces a clear error instead
  of sending !shutdown to the wrong channel.
Rewrite invoke_provider to stream stdout lines and stderr chunks over
mpsc channels instead of read_to_end + join. Reader threads send data
as it arrives; the caller collects lines during the try_wait loop and
drains remaining buffered output after the child exits (2s timeout).

This eliminates both problems with the previous design:
- No thread leaks: reader threads are not joined — they terminate when
  the pipe closes or the desktop process exits. No wrapper threads.
- No false failures: stdout lines are captured as they arrive, so a
  valid JSON response is available even if a descendant holds the pipe
  open after the provider exits.

Stderr is capped at 64KB to bound memory. The cleanup path (stdin
failure, timeout, wait error) kills the child and returns immediately
— reader threads die when the pipe handles are closed by the OS.
… sync_channel

- Stdout: replaced BufRead::lines() with raw chunk reads. Chunks are
  appended to a buffer during the try_wait loop. JSON parsing tries
  each line first, then the entire trimmed buffer — captures responses
  even without a trailing newline. Post-exit drain continues until
  channel disconnects or 2s deadline (not first timeout).

- Stderr: replaced unbounded mpsc with sync_channel(8). The bounded
  channel applies backpressure at the producer — the reader thread
  blocks when 8 chunks are in-flight. Consumer drains during the
  try_wait loop with a STDERR_CAP (64KB) byte limit enforced at
  collection time. No unbounded memory accumulation even for
  long-running or malicious providers.
Add resolve_provider_binary() — the single gateway for provider binary
resolution. It validates the ID against [a-z0-9][a-z0-9_-]*, looks up
only in discover_provider_candidates(), and returns the canonical path.

Replace all resolve_command(format!("sprout-backend-{id}")) calls in
create, start, and deploy_to_provider with resolve_provider_binary().
This closes the trust boundary gap where a compromised frontend/IPC
caller could steer execution to an arbitrary binary via a crafted
backend.id. The probe command already had this protection; now all
execution paths share the same resolver.
Backend now rejects deletion of deployed remote agents (backend_agent_id
is Some) unless the caller passes force_remote_delete: true. This turns
'don't orphan remote infra' from a UI convention into a backend
invariant — a buggy or compromised IPC caller cannot silently orphan a
live remote deployment.

Frontend passes force_remote_delete: true only after the user has gone
through the existing presence-aware confirmation flow (shutdown sent +
orphan warning acknowledged).

API surface: delete_managed_agent gains an optional force_remote_delete
parameter (defaults to false/null). E2E mock updated.
…te invariant in mock

- invoke_provider: stdout_buf now capped at STDOUT_CAP (1MB) during
  both the try_wait loop and post-exit drain. Prevents a buggy or
  malicious provider from OOM-ing the desktop process. Same pattern
  as the existing STDERR_CAP (64KB) enforcement.

- sprout-admin mint_token: when --owner-pubkey is given and ownership
  was already set, now verifies the existing owner matches. If it
  doesn't, fails with an error instead of silently minting a token
  for a non-owner (which would create a broken shutdown relationship).

- E2E mock: handleDeleteManagedAgent now rejects deletion of deployed
  provider-backed agents unless forceRemoteDelete is true, matching
  the backend invariant. Regressions in the force flag wiring will
  now be caught in frontend tests.
…nning copy

- Delete flow: offline presence no longer skips confirmation. 'Deployed'
  means infrastructure exists even when the process is offline — always
  confirm before removing the only management handle. Three distinct
  paths: online/away (send !shutdown + confirm), offline (confirm with
  'deployment may still exist' warning), no channel (orphan warning).

- SecretRevealDialog: 'ready and deployed' for provider agents instead
  of 'ready and running'. Deployed is control-plane state, not liveness.

- ManagedAgentsSection: rename isRunning → isActive to reflect that the
  variable covers both 'local process running' and 'remote infra
  deployed'. No semantic change to action gating — just accurate naming.
…nfig fields

- sprout-acp: owner lookup is now lazy. Startup does a single attempt;
  if it fails, the shutdown handler resolves the owner on-demand when a
  candidate !shutdown message arrives. A relay outage during startup no
  longer permanently disables remote shutdown — the next !shutdown
  attempt triggers a fresh lookup.

- CreateAgentDialog: canSubmit now checks that all required fields from
  the provider's config_schema are filled before allowing submission.
  Previously, required fields were marked with * but not enforced,
  allowing users to create agents with missing config that would only
  fail deep in the deploy call (after token mint + record creation).
…app_launch

Token mint reorder (relay post_tokens):
- Owner validation, ensure_user, and set_agent_owner now happen BEFORE
  token creation. If ownership fails, no token exists — eliminates the
  orphaned-token failure mode entirely. No more best-effort revocation.
- ensure_user failure for owner is now a hard error (was warn-and-continue).

Shutdown-required scope enforcement:
- Relay post_tokens: when owner_pubkey is set, rejects mint if scopes
  don't include users:read, messages:read, messages:write, channels:read.
  Without these the harness can't resolve its owner or receive !shutdown.
- sprout-admin mint-token: same enforcement when --owner-pubkey is given.
- Desktop create flow: already enforced (unchanged).
- UI: required scopes shown as locked with '(required)' label and
  disabled state in provider mode. Cannot be toggled off.

start_on_app_launch normalization:
- Backend: forces false for provider-backed agents during record creation.
- UI: toggle disabled in provider mode with explanatory text:
  'Remote agents are managed by their provider and don't auto-start
  with the desktop app.'
- Submit payload: hardcoded false in provider mode.

Tests:
- resolve_provider_binary: rejects path traversal, empty, uppercase,
  spaces, shell metacharacters. Accepts valid ID format.
…comparator

- Relay post_tokens: scope validation now runs BEFORE set_agent_owner
  and ensure_user. No side effects on validation failure. Also checks
  scopes when the agent already has an owner in the DB (re-mint path),
  not just when owner_pubkey is in the request. Prevents stripping
  shutdown-critical scopes via token rotation.

- sprout-admin: scope validation moved before ensure_user/set_agent_owner
  within the owner block. Same no-side-effects-on-failure guarantee.

- AgentsView sort: replaced broken comparator (running vs deployed both
  returned -1) with numeric score function. Both 'running' and 'deployed'
  are equivalent for sorting — active agents sort before inactive.
…dation writes, bind cached binary to provider ID

- Relay post_tokens: owner lookup for scope enforcement now fails closed
  (DB error → assume owned → enforce scopes). Was silently treating DB
  errors as 'unowned', bypassing scope enforcement.

- sprout-admin: scope enforcement now triggers on BOTH explicit
  --owner-pubkey AND already-owned agents (DB check). Previously only
  enforced with --owner-pubkey, allowing re-mints to strip shutdown
  scopes. Also fails closed on DB lookup error.

- sprout-admin: ensure_user for agent row moved AFTER all validation.
  No DB writes on validation failure.

- deploy_to_provider: cached binary path validation now checks BOTH
  'is a discovered candidate' AND 'belongs to this provider_id'. A
  tampered record cannot redirect deploys to a different provider.
…rigger fast polling

- Relay post_tokens: ensure_user + set_agent_owner now only run when
  owner_pubkey is explicitly provided. Self-mints without owner_pubkey
  no longer silently assign ownership. This preserves the semantics
  that omitting owner_pubkey means 'don't set agent owner' and prevents
  unintended controllability scope enforcement on future re-mints.

- useManagedAgentsQuery: fast polling (2s) only triggers for local
  'running' agents. 'deployed' is static control-plane state with no
  local state transitions to watch — presence polling handles the live
  signal for remote agents separately. Eliminates perpetual fast
  polling for offline deployed agents.
canSubmit now requires probedProvider to be non-null when isProviderMode
is true. Without a successful probe, required fields and config schema
are unknown — submitting would skip all provider-specific validation
and create a broken remote-agent record (ownership + token minted but
deploy fails on missing/invalid config).
@tlongwell-block
tlongwell-block merged commit 168c1ca into main Mar 21, 2026
7 checks passed
@tlongwell-block
tlongwell-block deleted the feat/remote-agent-providers branch March 21, 2026 00:32
tlongwell-block added a commit that referenced this pull request Mar 21, 2026
…tocol

* origin/main:
  feat: remote agent backend providers (#134)
  feat(desktop): replace PNG persona export with JSON format (#144)
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