Files
clawmates/crates/cm-api/src/lib.rs
T
Omar SobhandClaude Opus 5.5 72fb0dcb6f
deploy / test (push) Failing after 1m52s
deploy / build (push) Skipped
feat(llm-proxy): mission containers reach their models through a key-adding proxy (flagged)
Container-tier missions held the platform's provider keys in their environment,
readable by an agent with Bash and public egress. With CLAWMATES_LLM_PROXY=1
(and CLAWMATES_LLM_PROXY_SECRET), a container instead holds a per-mission token
where each key was, ANTHROPIC_BASE_URL points at the server's proxy on :8089
(not published, not routed by Traefik), and the GLM/Kimi hops' base URLs are
rewritten in the mission's config copy. The proxy verifies the token
(HMAC(secret, mission_id) — stateless, survives redeploys), refuses it unless
the mission is running, swaps in the real credential and streams the response.

Spiked first on gw-04: Claude Code on a subscription OAuth token, given only a
placeholder and a base URL, sent nothing but POST /v1/messages there and
answered once the placeholder was swapped. Off by default; no behaviour change
until enabled.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-23 12:35:01 -05:00

696 lines
25 KiB
Rust

//! REST API for Clawmates (spec §13). One route resource per module.
pub mod agent_lifecycle;
pub mod agent_names;
pub mod auto_merge;
pub mod benchmark_runner;
pub mod beszel;
pub mod brain_seed;
pub mod cleanup_sweeper;
pub mod container_exec;
pub mod corpus;
mod error;
pub mod evaluator;
pub mod evaluator_tools;
pub mod judge_quota;
mod extract;
pub mod fleet;
pub mod fleet_herdr;
pub mod harvest;
pub mod level_up;
pub mod library;
pub mod live_bus;
mod mcp_door;
mod mcp_skills;
pub mod microvm_client;
pub mod microvm_executor;
pub mod microvm_turn_executor;
pub mod continuous_research;
pub mod delivery_secrets;
pub mod llm_proxy;
pub mod mission_delivery;
pub mod podcast;
pub mod mission_events;
pub mod mission_fs;
pub mod mission_memory;
pub mod mission_gc;
pub mod mission_orchestrator;
pub mod mission_schedule;
pub mod mission_outputs;
pub mod mission_plan;
pub mod mission_refiner;
pub mod mission_roster;
pub mod mission_runtime;
pub mod mission_workspace;
pub mod node_rules;
pub mod papers;
pub mod phase_config;
pub mod phase_runner;
pub mod phase_summarizer;
pub mod quota;
mod recursive_exec;
pub mod repo_digest;
pub mod root_copy;
mod routes;
pub mod runtime_preflight;
pub mod runtime_provision;
pub mod security_scan;
pub mod session_executor;
pub mod container_tool_hooks;
pub mod gateway_preflight;
pub mod skill_delivery;
pub mod skill_self_authoring;
pub mod skill_triage;
pub mod skill_use;
pub mod skills_loader;
pub mod subscription;
pub mod swarm;
pub mod task_card_parser;
pub mod task_card_worker;
pub mod team_template_loader;
pub mod tool_versions;
pub mod topology_exec;
pub mod topology_worker;
pub mod validator_preflight;
pub mod vm_placement;
pub mod vm_stop_gate;
pub mod vm_tool_gate;
pub mod vm_tool_tap;
pub mod workflow_registry;
use axum::routing::{delete, get, patch, post};
use axum::Router;
use cm_auth::AuthService;
use cm_runtime::Runtime;
use sqlx::PgPool;
pub use error::ApiError;
pub use extract::Authed;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub auth: AuthService,
pub runtime: Runtime,
/// Secret broker socket; connect flows refuse without it.
pub broker_socket: Option<std::path::PathBuf>,
pub oauth: cm_config::OAuthConfig,
pub billing: cm_config::BillingConfig,
/// Short-lived single-use tickets for the Terminal WebSocket.
/// Local blob-store root (Some on the Local backend) so the Files app can
/// reconcile its index with files the Terminal wrote into the drives.
pub file_root: Option<std::path::PathBuf>,
/// Live control channels to connected fleet-node daemons.
pub node_hub: std::sync::Arc<fleet::NodeHub>,
/// The shelf. Present once the server wires storage; `None` in the
/// bare-`new` path used by tests that never touch blobs.
pub blobs: Option<std::sync::Arc<dyn cm_files::BlobStore>>,
}
impl AppState {
pub fn new(pool: PgPool, runtime: Runtime) -> AppState {
let auth = AuthService::new(pool.clone());
AppState {
pool,
auth,
runtime,
broker_socket: None,
oauth: cm_config::OAuthConfig::default(),
billing: cm_config::BillingConfig::default(),
file_root: None,
node_hub: std::sync::Arc::new(fleet::NodeHub::new()),
blobs: None,
}
}
pub fn with_file_root(mut self, root: Option<std::path::PathBuf>) -> AppState {
self.file_root = root;
self
}
/// The shelf — where the paper library stores PDFs.
pub fn with_blobs(mut self, blobs: std::sync::Arc<dyn cm_files::BlobStore>) -> AppState {
self.blobs = Some(blobs);
self
}
pub fn with_oauth(mut self, oauth: cm_config::OAuthConfig) -> AppState {
self.oauth = oauth;
self
}
/// SaaS: give each new hosted-identity sign-in its own workspace.
pub fn with_per_signup_workspace(mut self, enabled: bool) -> AppState {
self.auth = self.auth.with_per_signup_workspace(enabled);
self
}
pub fn with_billing(mut self, billing: cm_config::BillingConfig) -> AppState {
self.billing = billing;
self
}
/// Optional form of [`AppState::with_auth_verifier`] for call chains.
pub fn pipe_auth_verifier(
self,
verifier: Option<std::sync::Arc<cm_auth::JwtVerifier>>,
) -> AppState {
match verifier {
Some(verifier) => self.with_auth_verifier(verifier),
None => self,
}
}
/// Hosted-identity session JWTs (Clerk / OIDC) for the Authed
/// extractor, alongside local sessions.
pub fn with_auth_verifier(
mut self,
verifier: std::sync::Arc<cm_auth::JwtVerifier>,
) -> AppState {
self.auth = self.auth.with_verifier(verifier);
self
}
pub fn with_broker(mut self, socket: std::path::PathBuf) -> AppState {
self.broker_socket = Some(socket);
self
}
/// Share a pre-built node hub (so the fleet driver provider and the routes
/// use the same live channels).
pub fn with_node_hub(mut self, hub: std::sync::Arc<fleet::NodeHub>) -> AppState {
self.node_hub = hub;
self
}
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(routes::health::healthz))
.route("/api/quota", get(quota::get_quota))
.route("/api/world/live", get(routes::world::world_live))
.route("/api/world/replay", get(routes::world::world_replay))
.route("/api/nodes", get(routes::nodes::list))
.route("/api/fleet/capacity", get(routes::nodes::capacity))
.route("/api/fleet/backends", get(routes::nodes::backends))
.route("/api/judge/quota", get(routes::nodes::judge_quota))
.route("/api/nodes/pair", post(routes::nodes::pair))
.route("/api/nodes/live", get(routes::nodes::live))
.route("/api/nodes/agent", get(routes::nodes::agent_ws))
.route("/api/nodes/{id}/exec-test", post(routes::nodes::exec_test))
.route(
"/api/nodes/{id}/sandbox-check",
post(routes::nodes::sandbox_check),
)
.route(
"/api/nodes/{id}/terminal/ticket",
post(routes::nodes::terminal_ticket),
)
.route(
"/api/nodes/{id}/terminal/ws",
get(routes::nodes::terminal_ws),
)
.route(
"/api/nodes/{id}/metrics",
get(routes::beszel::node_metrics_get),
)
.route("/api/nodes/{id}/tools", get(routes::nodes::tools))
.route(
"/api/nodes/{id}/tools/{tool}/update",
post(routes::nodes::tool_update),
)
.route(
"/api/nodes/{id}/herdr/session",
get(routes::nodes::herdr_session),
)
.route("/api/nodes/{id}", delete(routes::nodes::remove))
.route(
"/api/fleet/beszel",
get(routes::beszel::status)
.post(routes::beszel::connect)
.delete(routes::beszel::disconnect),
)
.route(
"/api/fleet/rules",
get(routes::beszel::rules_list).post(routes::beszel::rules_create),
)
.route(
"/api/fleet/rules/{id}",
patch(routes::beszel::rules_patch).delete(routes::beszel::rules_delete),
)
.route(
"/api/fleet/tailscale",
get(routes::tailscale::status)
.post(routes::tailscale::connect)
.delete(routes::tailscale::disconnect),
)
.route(
"/api/fleet/tailscale/devices",
get(routes::tailscale::devices),
)
.route(
"/api/fleet/placement",
get(routes::tailscale::placement_get).put(routes::tailscale::placement_set),
)
.route("/mcp", post(mcp_door::mcp))
.route("/mcp/skills", post(mcp_skills::mcp_skills))
.route("/api/auth/login", post(routes::auth::login))
.route("/api/auth/logout", post(routes::auth::logout))
.route("/api/user/me", get(routes::identity::me))
.route("/api/claws", post(routes::claws::create))
.route("/api/claws/batch-delete", post(routes::claws::batch_delete))
.route("/api/claws/lifecycle", get(routes::claws::lifecycle_census))
.route(
"/api/claws/lifecycle/sweep",
post(routes::claws::lifecycle_sweep),
)
.route("/api/claws/{id}", patch(routes::claws::patch))
.route("/api/claws/{id}", delete(routes::claws::delete))
.route("/api/claws/{id}/model", patch(routes::claws::set_model))
.route(
"/api/claws/{id}/access",
axum::routing::put(routes::claws::set_access),
)
.route(
"/api/claws/{id}/runtime-config",
get(routes::claws::runtime_config),
)
.route(
"/api/claws/{id}/compartments",
get(routes::claws::compartments),
)
.route(
"/api/claws/{id}/brain",
get(routes::claws::brain).patch(routes::claws::edit_brain),
)
.route(
"/api/claws/{id}/brain/push",
axum::routing::post(routes::claws::push_brain),
)
.route(
"/api/brainhub/pull",
axum::routing::post(routes::claws::pull_brain),
)
.route("/api/brainhub/search", get(routes::claws::brainhub_search))
.route(
"/api/brainhub/preview",
get(routes::claws::brainhub_preview),
)
.route(
"/api/brainhub/enhance",
axum::routing::post(routes::claws::enhance_brain),
)
.route(
"/api/claws/{id}/brain/revisions",
axum::routing::get(routes::claws::brain_revisions),
)
.route(
"/api/claws/{id}/brain/rollback",
axum::routing::post(routes::claws::brain_rollback),
)
.route(
"/api/planner/chat",
axum::routing::post(routes::planner::planner_chat),
)
.route(
"/api/planner/scaffold",
axum::routing::post(routes::planner::planner_scaffold),
)
.route(
"/api/claws/{id}/brain/apply",
axum::routing::post(routes::claws::apply_brain),
)
.route("/api/terminal/{id}/ticket", post(routes::terminal::ticket))
.route(
"/api/terminal/{id}/tabs",
get(routes::terminal::get_tabs).put(routes::terminal::save_tabs),
)
.route("/api/terminal/{id}/ws", get(routes::terminal::ws))
.route("/api/claw-chat/threads", get(routes::claw_chat::threads))
.route("/api/claw-chat/messages", get(routes::claw_chat::messages))
.route(
"/api/claw-chat/rooms",
get(routes::claw_chat::rooms).post(routes::claw_chat::create_room),
)
.route(
"/api/claw-chat/rooms/{threadId}/participants",
post(routes::claw_chat::add_participant),
)
.route(
"/api/claw-chat/rooms/{threadId}/participants/{clawId}",
delete(routes::claw_chat::remove_participant),
)
// A2A: operator settings/tokens (session-authed) + public ingress.
.route(
"/api/a2a/settings",
get(routes::a2a::get_settings).post(routes::a2a::settings),
)
.route(
"/api/a2a/tokens",
get(routes::a2a::list_tokens).post(routes::a2a::mint_token),
)
.route("/api/a2a/tokens/{id}", delete(routes::a2a::revoke_token))
.route(
"/api/a2a/{workspace}/.well-known/agents-card.json",
get(routes::a2a::discovery_catalog),
)
.route(
"/api/a2a/{workspace}/{alias}/.well-known/agent-card.json",
get(routes::a2a::discovery_card),
)
.route("/api/a2a/{workspace}/{alias}", post(routes::a2a::task))
.route(
"/api/claws/settings/full",
get(routes::claws::settings_full),
)
.route("/api/sessions", get(routes::sessions::list))
.route("/api/sessions", post(routes::sessions::create))
.route("/api/sessions/history", get(routes::sessions::history))
.route("/api/gateway", post(routes::gateway::gateway))
.route("/api/library/runs", post(routes::library::run))
.route("/api/library/items", get(routes::library::list))
.route("/api/routines", get(routes::routines::list))
.route("/api/routines", post(routes::routines::create))
.route("/api/routines/runs", get(routes::routines::runs))
.route("/api/skills", get(routes::skills::list))
.route("/api/skills/install", post(routes::skills::install))
.route("/api/skills/uninstall", post(routes::skills::uninstall))
.route("/api/openclaw/files", get(routes::files::openclaw_files))
.route(
"/api/openclaw/files/content",
get(routes::files::file_content),
)
.route("/api/shared-drive/files", get(routes::files::shared_files))
.route("/api/slack/events", post(routes::slack::events))
.route(
"/api/claws/{clawId}/browser/viewport.png",
get(routes::browser::viewport),
)
.route("/api/apps", get(routes::apps::directory))
.route("/api/apps/connect", post(routes::apps::connect))
.route("/api/apps/oauth/start", post(routes::oauth::start))
.route("/api/apps/oauth/callback", get(routes::oauth::callback))
.route("/api/apps/disconnect", post(routes::apps::disconnect))
.route("/api/approvals", get(routes::approvals::list))
.route("/api/approvals/{id}", get(routes::approvals::get))
.route(
"/api/approvals/{id}/approve",
post(routes::approvals::approve),
)
.route(
"/api/approvals/{id}/reject",
post(routes::approvals::reject),
)
.route("/api/team/claws", get(routes::team::claws))
.route("/api/team/members", get(routes::team::members))
.route("/api/team/orgchart", get(routes::team::orgchart))
.route("/api/team/leaderboard", get(routes::team::leaderboard))
.route("/api/team/credits", get(routes::team::credits))
.route("/api/team/usage", get(routes::billing::usage))
.route("/api/credits/redeem", post(routes::billing::redeem))
.route("/api/credits/checkout", post(routes::billing::checkout))
.route("/api/billing/config", get(routes::billing::billing_config))
.route("/api/billing/stripe", post(routes::billing::stripe_webhook))
.route("/api/team/permissions", get(routes::team::permissions))
.route("/api/topologies", get(routes::topology::catalog))
.route(
"/api/topologies/classify",
post(routes::topology::classify_graph),
)
.route("/api/topologies/build", post(routes::topology::build_graph))
.route(
"/api/topologies/compare",
post(routes::topology::compare_topologies),
)
.route("/api/topologies/run", post(routes::topology::run_topology))
.route("/api/swarm/run", post(routes::topology::run_swarm))
.route("/api/hooks/{token}", post(routes::webhooks::trigger_hook))
.route(
"/api/teams/{id}/webhooks",
get(routes::webhooks::list_webhooks).post(routes::webhooks::create_webhook),
)
.route(
"/api/teams",
get(routes::teams::list_teams).post(routes::teams::create_team),
)
.route(
"/api/teams/from-claws",
post(routes::teams::create_team_from_claws),
)
.route(
"/api/teams/{id}",
get(routes::teams::get_team)
.patch(routes::teams::patch_team)
.delete(routes::teams::delete_team),
)
.route(
"/api/teams/{id}/name",
axum::routing::patch(routes::teams::rename_team),
)
.route("/api/teams/{id}/run", post(routes::teams::run_team))
.route(
"/api/teams/{id}/runtime-config",
axum::routing::patch(routes::teams::set_runtime_config),
)
.route(
"/api/teams/auto-provision",
post(routes::teams::auto_provision),
)
.route(
"/api/companies",
get(routes::companies::list_companies).post(routes::companies::create_company),
)
.route(
"/api/companies/{id}",
get(routes::companies::get_company)
.patch(routes::companies::patch_company)
.delete(routes::companies::delete_company),
)
.route(
"/api/companies/{id}/name",
axum::routing::patch(routes::companies::rename_company),
)
.route(
"/api/companies/{id}/run",
post(routes::companies::run_company),
)
.route(
"/api/orgs",
get(routes::orgs::list_orgs).post(routes::orgs::create_org),
)
.route(
"/api/orgs/{id}",
get(routes::orgs::get_org).delete(routes::orgs::delete_org),
)
.route(
"/api/orgs/{id}/name",
axum::routing::patch(routes::orgs::rename_org),
)
.route("/api/orgs/{id}/run", post(routes::orgs::run_org))
// Team templates — Slice 3 read-only surface. Server upserts
// builtins from TOML on boot; workspace-authored templates
// land here in a later slice.
.route("/api/team-templates", get(routes::team_templates::list))
.route("/api/team-templates/{id}", get(routes::team_templates::get))
// Skills catalog — Slice 3.5a. Layered by 3.5b's MCP server.
// Mounted at /api/skills-catalog to avoid collision with the
// legacy /api/skills surface until Slice 9 drops it.
.route("/api/skills-catalog", get(routes::skills_catalog::list))
.route("/api/skills-catalog/{id}", get(routes::skills_catalog::get))
.route(
"/api/claws/{id}/skill-bundle",
get(routes::skills_catalog::effective_for_agent),
)
// Missions — Slice 1 skeleton. Runs in parallel with the
// legacy research/loops routes until Slice 9's cutover.
.route(
"/api/missions",
get(routes::missions::list).post(routes::missions::create),
)
// The roster grouped by mission — what "My Workforce" renders.
.route("/api/workforce", get(routes::missions::workforce))
// The workflow recipe catalog (templates/workflows/*.toml). Serving it
// lets the client stop mirroring the phase composition table inline.
.route("/api/workflows", get(routes::missions::list_workflows))
// The private podcast feed. Token in the query string, not a header:
// no podcast app can set headers. See `routes::podcast`.
.route("/api/podcast/feed.xml", get(routes::podcast::feed))
.route("/api/podcast/episodes", get(routes::podcast::list_episodes))
.route("/api/podcast/subscription", get(routes::podcast::subscription))
.route(
"/api/podcast/episodes/{file}",
get(routes::podcast::episode_audio),
)
.route(
"/api/missions/{id}",
get(routes::missions::get)
.patch(routes::missions::update_meta)
.delete(routes::missions::delete),
)
.route(
"/api/missions/{id}/status",
axum::routing::patch(routes::missions::set_status),
)
.route("/api/missions/{id}/refine", post(routes::missions::refine))
// Draft-less sibling: the wizard polishes a description before any
// mission exists, so there is no id to route on. Declared BEFORE the
// `{id}` routes would otherwise be ambiguous — axum matches literal
// segments first, but keeping them adjacent makes the pair obvious.
.route(
"/api/missions/refine-draft",
post(routes::missions::refine_draft),
)
.route(
"/api/missions/{id}/merge",
post(routes::missions::merge_branch),
)
.route(
"/api/missions/{id}/artifacts/{artifact_id}/content",
get(routes::missions::artifact_content),
)
.route(
"/api/missions/{id}/artifacts/{artifact_id}/download",
get(routes::missions::artifact_download),
)
// Slice 5: let a model size the mission's team. Proposing, listing and
// deciding are separate verbs because only the last one spends money.
// W1/#13: let a model author the phases, on the same propose → review →
// approve shape as the roster above.
.route(
"/api/missions/{id}/plan-proposals",
get(routes::mission_plan::list).post(routes::mission_plan::suggest),
)
.route(
"/api/missions/{id}/plan-proposals/{pid}/decide",
post(routes::mission_plan::decide),
)
.route(
"/api/missions/{id}/team-proposals",
get(routes::mission_roster::list).post(routes::mission_roster::suggest),
)
.route(
"/api/missions/{id}/team-proposals/{pid}/decide",
post(routes::mission_roster::decide),
)
.route(
"/api/missions/{id}/herdr-dispatch",
post(routes::missions::herdr_dispatch),
)
.route(
"/api/missions/{id}/description",
patch(routes::missions::set_description),
)
.route("/api/missions/{id}/runs", get(routes::missions::list_runs))
.route(
"/api/missions/{id}/documents",
get(routes::missions::list_documents),
)
.route(
"/api/missions/{id}/documents/{run_id}/{index}",
get(routes::missions::get_document),
)
.route(
"/api/missions/{id}/phases/{phase_id}/retry",
post(routes::missions::retry_phase),
)
.route(
"/api/missions/{id}/phases/{phase_id}/summary",
get(routes::missions::get_phase_summary),
)
.route(
"/api/missions/{id}/phases/{phase_id}/evaluations",
get(routes::missions::list_phase_evaluations),
)
.route(
"/api/missions/{id}/skill-use",
get(routes::missions::skill_use),
)
.route(
"/api/missions/{id}/teams",
get(routes::missions::list_teams),
)
.route(
"/api/missions/{id}/benchmark",
post(routes::missions::trigger_benchmark),
)
.route(
"/api/missions/{id}/security-scan",
post(routes::missions::trigger_security_scan),
)
// Level-up (Slice 8.5)
.route(
"/api/level-up-proposals",
get(routes::level_up::list_pending),
)
.route(
"/api/level-up-proposals/{id}",
get(routes::level_up::get_proposal),
)
.route(
"/api/level-up-proposals/{id}/apply",
post(routes::level_up::apply_proposal),
)
.route(
"/api/level-up-proposals/{id}/reject",
post(routes::level_up::reject_proposal),
)
.route(
"/api/claws/{id}/level-up",
post(routes::level_up::propose_for_agent),
)
.route(
"/api/teams/{id}/level-up",
post(routes::level_up::propose_for_team),
)
// (research + loops + wizard_repo routes retired in Slice 9
// cleanup — missions is the single workflow surface. Probe
// is kept below if still referenced by any tool.)
.route("/api/structure/stats", get(routes::structure::stats))
.route(
"/api/structure/orphan-counts",
get(routes::structure::orphan_counts),
)
.route(
"/api/structure/ensure-chain",
post(routes::structure::ensure_chain),
)
.route(
"/api/structure/reify-orphans",
post(routes::structure::reify_orphans),
)
.route("/api/structure/{level}/{id}", get(routes::structure::node))
.route("/api/topology-runs", get(routes::topology::list_runs))
.route("/api/topology-runs/{id}", get(routes::topology::get_run))
.route(
"/api/topology-runs/{id}/events",
get(routes::topology::run_events_sse),
)
.route(
"/api/topology-runs/{id}/cancel",
post(routes::topology::cancel_run),
)
.route(
"/api/topology-runs/{id}/output",
get(routes::topology::get_run_output),
)
// Repos tier — provider connections + cached repo list.
.route(
"/api/repos/connections",
get(routes::repos::list_connections).post(routes::repos::create_connection),
)
.route(
"/api/repos/connections/{id}",
get(routes::repos::get_connection)
.patch(routes::repos::update_connection)
.delete(routes::repos::delete_connection),
)
.route(
"/api/repos/connections/{id}/sync",
post(routes::repos::sync_now),
)
.route("/api/repos", get(routes::repos::list_repos))
.route("/api/repos/{id}", get(routes::repos::get_repo))
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state)
}