feat: remote agent backend providers - #134
Merged
Merged
Conversation
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
force-pushed
the
feat/remote-agent-providers
branch
from
March 20, 2026 19:37
fa1c9e6 to
2e40cda
Compare
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
force-pushed
the
feat/remote-agent-providers
branch
from
March 20, 2026 21:41
775ffd9 to
0af8630
Compare
…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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Deploy agents to remote infrastructure via external provider binaries. Stop them with a chat message. Status from relay presence.
Architecture
sprout-backend-<id>) deploys infra + harness. Two operations:infoanddeploy. That's it.!shutdownfrom owner in normal @mention flow. Exits gracefully. Presence goes offline.Changes
Relay (
sprout-relay)owner_pubkeyfield inPOST /api/tokens(NIP-98 bootstrap only, first mint only)ensure_userfor owner FK constraint (matchessprout-adminpattern)agent_owner_pubkeyexposed inGET /api/users/me/profileand/{pubkey}/profileHarness (
sprout-acp)!shutdownfrom owner: kind:9, exact content,#ptag mention, owner pubkey check!shutdownfalls through to normal prompt handling (not dropped)Desktop Backend (Tauri)
BackendKindenum:Local | Provider { id, config }backend.rs): invoke, deploy, discover, validate, secret redactionBackendKindstop_managed_agentrejects remote agents (frontend handles via WebSocket)get_managed_agent_logrejects remote agentslast_erroron recordlast_erroron recordusers:readadded to default agent token scopes (for owner lookup)Desktop Frontend (TypeScript)
config_schemahandleStop: remote →sendChannelMessage("!shutdown"), local → existing stop commandhandleDelete: remote → sends!shutdownfirst, orphan warning if no channel availablechannelAgents: skip restart for remote (harness auto-discovers channels via membership notifications)"deployed"status propagated through all frontend checks (sort, badge, model picker, etc.)Design Decisions
info+deployonly!shutdown@mentionTest Results
cargo check --workspace✅cargo clippy --workspace --all-targets -- -D warnings✅cargo fmt --all -- --check✅pnpm typecheck✅pnpm build✅pnpm check✅ (biome)Deferred (v2)
statusandlogsoperationsstart_on_app_launchsupport for remote agents