diff --git a/.changeset/at-mention-search.md b/.changeset/at-mention-search.md new file mode 100644 index 00000000..64054799 --- /dev/null +++ b/.changeset/at-mention-search.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-daemon': minor +'smooai-smooth-web': minor +--- + +`@` mention search in the smooth-web composer, mirroring the th code TUI (th-58b5fe). +Typing `@` opens an autocomplete popup (files + path expansion from the workspace), +backed by a new ungated `GET /search?q=` endpoint on the daemon (workspace files via +the pruned ripgrep walk, bounded by a walk budget). The endpoint is mounted via a new +`LocalServer.serve_routes()` seam. `@` and the `/smooth-mode` slash popup coexist — +exactly one shows, decided by which token the caret sits in. Pearls in mentions are a +documented follow-up (the `kind` field already accepts "pearl"). diff --git a/.changeset/daemon-converges-on-operator.md b/.changeset/daemon-converges-on-operator.md new file mode 100644 index 00000000..b466fcc1 --- /dev/null +++ b/.changeset/daemon-converges-on-operator.md @@ -0,0 +1,11 @@ +--- +'smooai-smooth-daemon': patch +--- + +EPIC th-c89c2a: the daemon converges fully on the operator — no second agent loop. +`th daemon` (default and `operator`) now runs only smooth-operator's local flavor, +made durable by a new sqlite `StorageAdapter` (survives restart, no Postgres) wired +through the operator's `.storage()` seam. Deleted the bespoke `serve_persistent` +path and its 13 modules (server/wire/runner/coordinator/scheduler/permission/ +sqlite/approval/event/hook/session/messages) plus the dead `:4400` bind. The daemon +is now just config (egress + LLM creds) + operator + durable storage. diff --git a/.changeset/daemon-conversation-rename.md b/.changeset/daemon-conversation-rename.md new file mode 100644 index 00000000..d92b80ee --- /dev/null +++ b/.changeset/daemon-conversation-rename.md @@ -0,0 +1,18 @@ +--- +"@smooai/smooth": patch +--- + +feat: rename conversations from the Big Smooth daemon PWA sidebar + +Each sidebar row gains a rename affordance (a hover pencil icon, or double-click +the row) that opens an inline text input in place of the title. Enter commits, +Esc (or blur) cancels. The rename is applied optimistically to the row, then the +server's canonical (sanitized) title is reconciled onto it from the +`rename_conversation` reply. + +Wires a new `renameConversation(id, title)` into the `useOperator` hook, which +sends the server's `rename_conversation` WS action +(`{action, requestId, conversationId, title}`) and reconciles the echoed +`{conversationId, title}`. Pairs with the server-side auto-title + rename +(pearl th-d5b446): auto-generated titles now appear in the sidebar, and users can +override them. diff --git a/.changeset/daemon-conversation-sidebar.md b/.changeset/daemon-conversation-sidebar.md new file mode 100644 index 00000000..6bc40a77 --- /dev/null +++ b/.changeset/daemon-conversation-sidebar.md @@ -0,0 +1,27 @@ +--- +"@smooai/smooth": minor +--- + +feat: conversation sidebar + resume + new-chat in the Big Smooth daemon PWA + +The smooth-web SPA (`crates/smooth-web/web`) was a single, ephemeral chat — one +fresh session per load, no history. It now has a toggleable slide-in sidebar +that lists your recent conversations and lets you jump between them. Pearl +th-d5b446. + +- **operator.ts** speaks three more actions of the smooth-operator WS protocol: + `list_conversations` (populates `conversations` for the sidebar, refreshed + after every turn), `create_conversation_session` with a `conversationId` (to + **resume**), and `get_conversation_messages` (loads a resumed conversation's + history into `messages`). New hook surface: `conversations`, + `activeConversationId`, `refreshConversations()`, `resumeConversation(id)`, + `newConversation()`. +- **App.tsx** gains a menu-toggle + Aurora-Glass `Sidebar`: overlays with a + backdrop on mobile, docks on desktop, highlights the active conversation, and + carries a prominent "New chat" button. The Smooth brand mark moved into the + sidebar header. No sidebar interaction leaves the current single-chat + behaviour unchanged. + +Back-compat: history is rendered as text-only (past tool chips aren't +reconstructed), and the message/`updatedAt` shapes are read defensively pending +final reconciliation with the server-side action. diff --git a/.changeset/daemon-embed-operator-local-flavor.md b/.changeset/daemon-embed-operator-local-flavor.md new file mode 100644 index 00000000..f5f34160 --- /dev/null +++ b/.changeset/daemon-embed-operator-local-flavor.md @@ -0,0 +1,17 @@ +--- +'smooai-smooth-daemon': minor +'smooai-smooth-cli': patch +--- + +EPIC th-c89c2a: the daemon can host the OPERATOR's local deployment flavor +in-process. New `smooth_daemon::serve_local_flavor(addr)` boots +smooth-operator's `LocalServer` (lean build — `default-features = false` drops +all cloud adapters: AWS SDK / tokio-postgres / redis / nats; in-memory storage + +backplane), gated by an auto-provisioned local token (`SMOOTH_LOCAL_TOKEN` env → +`~/.smooth/operator-token` mode 600, generated on first run). Because the daemon +*runs the operator*, it speaks the canonical schema-driven WS protocol by +construction — the official widget and the polyglot SDK clients work natively. +Exposed as `th daemon operator [--addr 127.0.0.1:8787]`. Additive: runs +alongside the bespoke `/ws` path while the embed is validated; that bespoke +surface retires once parity lands. (Depends on two local-flavor seams added +upstream in smooth-operator: `LocalTokenVerifier` + `LocalServerBuilder::auth`.) diff --git a/.changeset/daemon-live-operator-e2e.md b/.changeset/daemon-live-operator-e2e.md new file mode 100644 index 00000000..64c00433 --- /dev/null +++ b/.changeset/daemon-live-operator-e2e.md @@ -0,0 +1,15 @@ +--- +'smooai-smooth-daemon': minor +--- + +EPIC th-c89c2a: integration + live-LLM E2E for the operator local flavor +(`tests/live_operator_e2e.rs`). Boots the local flavor in-process the way +`serve_local_flavor` does (LocalTokenVerifier + the exposed `local_tool_provider` ++ a real gateway config) and drives the canonical WS protocol with a real +client. The gated test (`SMOOTH_AGENT_E2E=1` + `SMOOAI_GATEWAY_KEY`) runs a real +LLM turn that makes the agent call the kernel-sandboxed `bash` tool and round-trips +its output back — proving protocol + agent loop + sandboxed tool execution + the +live gateway end-to-end. The always-on test documents a finding: the operator's +`/ws` degrades a missing/invalid token to an **anonymous** connection rather than +rejecting it, so `LocalTokenVerifier` does NOT gate connections (only ACL scope) — +the loopback bind is the real gate today. Module docs corrected accordingly. diff --git a/.changeset/daemon-logs-to-stderr.md b/.changeset/daemon-logs-to-stderr.md new file mode 100644 index 00000000..a97615ce --- /dev/null +++ b/.changeset/daemon-logs-to-stderr.md @@ -0,0 +1,10 @@ +--- +'smooai-smooth-cli': patch +--- + +`th daemon` subcommands (including `th daemon operator`) now log to **stderr** +instead of the TUI's `~/.smooth/log/th.log`. Daemon subcommands are +long-running services, so their tracing output should be visible in the +foreground and captured by the service supervisor (launchd/systemd via +`th service`) — not buried in the file the TUI shares. (EPIC th-c89c2a; closes +th-9eb87d — the operator's info logs were never silent, just file-routed.) diff --git a/.changeset/daemon-reachability-and-presence.md b/.changeset/daemon-reachability-and-presence.md new file mode 100644 index 00000000..65e23957 --- /dev/null +++ b/.changeset/daemon-reachability-and-presence.md @@ -0,0 +1,23 @@ +--- +'smooai-smooth-daemon': minor +'smooai-smooth-web': minor +--- + +EPIC th-c89c2a: make the daemon trivially reachable and give it a face + a voice. + +- **Same-origin serve (th-a28904):** the daemon serves the smooth-web SPA at its + own origin via a new `LocalServer.serve_spa()` seam, injecting the auth token + into `index.html` (`window.__SMOOTH_TOKEN__`, read first by `operator.ts`), so + `http://127.0.0.1:8787/` works with no `?api`/`?token` query string. +- **Tailscale auto-serve (th-ce286d):** on startup, when Tailscale is present and + the node is up, the daemon exposes itself over the *tailnet* via `tailscale + serve` (never `funnel` — tailnet-private), with a `SMOOTH_TAILSCALE_SERVE=0` + opt-out and shutdown teardown. +- **Big Smooth persona (th-5f059b):** the daemon installs a personal-assistant + system prompt via the operator's new `.persona()` seam, replacing the stock + customer-support prompt. +- **smooth-web Presence glow-up (th-f1a1f0, th-833b5f):** the reactive Big Smooth + face is now the room — a haloed greeting on the empty state, a sticky presence + bar in conversation, the approval HITL emanating from him in amber, the + Bricolage Grotesque wordmark, a green liveness dot, and parked tools reading + "awaiting your okay". diff --git a/.changeset/daemon-repoint-operator-main.md b/.changeset/daemon-repoint-operator-main.md new file mode 100644 index 00000000..c8854d04 --- /dev/null +++ b/.changeset/daemon-repoint-operator-main.md @@ -0,0 +1,11 @@ +--- +'smooai-smooth-daemon': patch +--- + +EPIC th-c89c2a: the local-flavor seams landed on smooth-operator `main` (#108), +so the daemon's operator path-deps (`smooth-operator-server`, +`smooth-operator-svc`) now point at the canonical `../smooth-operator` checkout +instead of the temporary `smooth-local-flavor` worktree (now removed). Same +local-dev two-repo path pattern as the engine dep. (Closes th-845d79; full +CI-mergeability still needs git-rev/published deps for the engine + operator — +th-d3537a.) diff --git a/.changeset/daemon-strict-auth.md b/.changeset/daemon-strict-auth.md new file mode 100644 index 00000000..b496a49f --- /dev/null +++ b/.changeset/daemon-strict-auth.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-daemon': minor +--- + +EPIC th-c89c2a: the local flavor now enables the operator's **strict-auth** mode +(`LocalServerBuilder::strict_auth(true)` in `serve_local_flavor`), so a `/ws` +connection with a missing/invalid token is **rejected (HTTP 401)** instead of +degrading to an anonymous connection. This closes the gap the live e2e surfaced +(th-6d1863): `LocalTokenVerifier` now genuinely gates connections — a stray local +process or tailnet peer can't drive the agent. The widget + SDK clients carry the +token, so they're unaffected. The e2e test now asserts tokenless `/ws` is rejected +and a valid token is accepted (and the live LLM turn still runs). diff --git a/.changeset/daemon-token-iteration-headroom.md b/.changeset/daemon-token-iteration-headroom.md new file mode 100644 index 00000000..5a9610ea --- /dev/null +++ b/.changeset/daemon-token-iteration-headroom.md @@ -0,0 +1,18 @@ +--- +"@smooai/smooth": patch +--- + +fix(daemon): give Big Smooth chat assistant-grade token/iteration headroom + +The daemon's chat path resolved config via `local_config()` → +`ServerConfig::from_env()`, silently inheriting the customer-support **chat +widget** defaults (`512` max_tokens / `6` max_iterations). A reasoning model +like `deepseek-v4-flash` spends its entire 512-token budget on +`reasoning_content` and gets cut off before emitting any `content` — the +"Big Smooth seems hung" empty reply — while 6 iterations makes any +read-a-few-files turn hit "Maximum iterations reached". `resolve_gateway_config` +now gives the daemon `32768` tokens / `50` iterations unless +`SMOOTH_AGENT_MAX_TOKENS` / `SMOOTH_AGENT_MAX_ITERATIONS` explicitly override. + +Also guards `BigSmoothFace` WebGL init so a GPU-unavailable browser degrades +to no 3D face instead of crashing the whole SPA. diff --git a/.changeset/daemon-tool-provider-rebase.md b/.changeset/daemon-tool-provider-rebase.md new file mode 100644 index 00000000..2cc9cd34 --- /dev/null +++ b/.changeset/daemon-tool-provider-rebase.md @@ -0,0 +1,11 @@ +--- +'smooai-smooth-daemon': patch +--- + +EPIC th-c89c2a: the daemon now feeds its kernel-sandboxed tools to the operator +local flavor through the operator's `#68` `ToolProvider` seam instead of the +earlier bespoke `extra_tools` list (which collided with `#68` and has been +dropped upstream). `SandboxedToolProvider::tools_for` returns +`default_tools_with_proxy(workspace, proxy)` per turn; `serve_local_flavor` +installs it via `LocalServerBuilder::tools(provider)`. Tracks the rebase of the +`smooth-local-flavor` operator branch onto `main` (post-HITL, post-#68). diff --git a/.changeset/fast-mode-groq.md b/.changeset/fast-mode-groq.md new file mode 100644 index 00000000..7d777496 --- /dev/null +++ b/.changeset/fast-mode-groq.md @@ -0,0 +1,8 @@ +--- +'smooai-smooth-daemon': minor +--- + +Fix the `SMOOTH_AGENT_MODEL` override (it was a no-op) and add `SMOOTH_FAST_MODE` +— a fast mode that points Big Smooth at `groq-gpt-oss-120b` (current, fast, +strong tool-caller, reasons on the harmony channel so thinking shows cleanly). +Model priority: `SMOOTH_AGENT_MODEL` > fast-mode pin > coding route. diff --git a/.changeset/fix-find-native-operative-test.md b/.changeset/fix-find-native-operative-test.md new file mode 100644 index 00000000..8d6990b6 --- /dev/null +++ b/.changeset/fix-find-native-operative-test.md @@ -0,0 +1,13 @@ +--- +'smooai-smooth-bigsmooth': patch +--- + +Fix `find_native_operative_finds_debug_or_release_build` to accept the +`cargo install`ed binary location. `find_native_operative_binary()` +deliberately falls back to `$CARGO_HOME/bin` / `~/.cargo/bin` (th-92dac3) +when a `target//` build isn't found near the manifest — which is +exactly the case in a worktree using a shared target dir. The test +asserted the path must be under `target/release|debug`, so it failed in +that (valid) setup. The assertion now also accepts a `bin/` install dir +while keeping the guard that the path must not be the +`aarch64-unknown-linux-musl` cross-compile output. diff --git a/.changeset/gate1-bash-enforcement.md b/.changeset/gate1-bash-enforcement.md new file mode 100644 index 00000000..ab3f6362 --- /dev/null +++ b/.changeset/gate1-bash-enforcement.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-tools': patch +--- + +EPIC th-c89c2a (th-515a13): Gate 1 deny is now enforced at the bash tool boundary. +A new `permission` module loads deny/ask/allow rules from `~/.smooth/permissions.toml` +(override `SMOOTH_PERMISSIONS_FILE`, process-global, best-effort) and the `bash` +tool now blocks any command its **deny** rules match — compound-split, so +`ls && rm -rf ~` is caught on the `rm`. A configurable complement to the hardcoded +circuit-breaker; an unconfigured daemon is unchanged (empty rules never deny). +Per-call `Ask`→HITL needs an operator host-hook seam (filed separately); `Ask`/ +`Allow` proceed for now, with the name-based operator HITL covering confirmation. diff --git a/.changeset/gate1-permission-model.md b/.changeset/gate1-permission-model.md new file mode 100644 index 00000000..1eb44ad9 --- /dev/null +++ b/.changeset/gate1-permission-model.md @@ -0,0 +1,11 @@ +--- +'smooai-smooth-policy': patch +--- + +EPIC th-c89c2a (th-515a13): Gate 1 — the deterministic deny/ask/allow permission +rule model. `Decision` (Deny/Ask/Allow), `Matcher` (Claude-Code `Tool(pattern)` +syntax with `:` as a word boundary, e.g. `Bash(rm:*)` ≡ `Bash(rm *)`), and +`PermissionRules` with **deny > ask > allow** precedence and a **fail-safe `Ask` +default** (an unmatched call never silently allows). Pure model + matcher, +exhaustively tested incl. adversarial inputs. The intent layer above the kernel +sandbox; wiring it as a ToolHook on the operator registry is a following slice. diff --git a/.changeset/local-flavor-sandboxed-tools.md b/.changeset/local-flavor-sandboxed-tools.md new file mode 100644 index 00000000..f8b36199 --- /dev/null +++ b/.changeset/local-flavor-sandboxed-tools.md @@ -0,0 +1,15 @@ +--- +'smooai-smooth-daemon': minor +'smooai-smooth-tools': minor +--- + +EPIC th-c89c2a: the operator local flavor now runs with the daemon's +kernel-sandboxed tools. `serve_local_flavor` builds the workspace-confined +fs/grep set + an OS-sandboxed `bash` (egress routed through the goalie proxy +when `SMOOTH_EGRESS_ALLOWLIST` is set) and installs them via the operator's +`LocalServerBuilder::tools` seam — so the agent the operator runs per turn can +act on the workspace under the same kernel-enforced security the bespoke daemon +used. New `smooth_tools::default_tools_with_proxy(workspace, proxy) -> +Vec>` (the registry helper now shares it via `register_arc`); the +egress-proxy start is factored into a reusable `start_egress_proxy()`. Workspace +defaults to the daemon's cwd, overridable with `SMOOTH_WORKSPACE`. diff --git a/.changeset/nuke-bespoke-4400-glue.md b/.changeset/nuke-bespoke-4400-glue.md new file mode 100644 index 00000000..3c69493f --- /dev/null +++ b/.changeset/nuke-bespoke-4400-glue.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-cli': patch +'smooai-smooth-code': patch +'smooai-smooth-policy': patch +--- + +EPIC th-c89c2a: scrub the last bespoke `:4400` glue now that `th daemon` is the +operator. Removed `BigSmoothClient` + the dead headless-capture/SSE paths from +smooth-code (kept the `ServerEvent`/`PriorMessage` wire types the operator client +maps onto); dropped `th access` and pointed `th model`/`th doctor` health checks at +the operator (`:8787`); deleted the orphaned `smooth-credential-helper` crate and +`smooth-policy`'s vestigial `AuthConfig.leader_url`. The tree is `:4400`-free. diff --git a/.changeset/operator-hitl-approvals.md b/.changeset/operator-hitl-approvals.md new file mode 100644 index 00000000..98757061 --- /dev/null +++ b/.changeset/operator-hitl-approvals.md @@ -0,0 +1,10 @@ +--- +'smooai-smooth-code': patch +--- + +EPIC th-c89c2a (th-1ea4f6): write-tool approvals (HITL) on the operator path. When +the operator parks a turn on `write_confirmation_required` (opt-in via +`SMOOTH_AGENT_CONFIRM_TOOLS`), `th code` now surfaces a `⚠ Approve ? — [y]es/ +[n]o` prompt, and `y`/`n`/`Esc` resume or deny the parked turn via a new +`confirm_tool_action` reply. This is the "ask" half of the permission model atop the +operator's kernel-sandboxed tools — replacing the deleted bespoke `:4400` approval UI. diff --git a/.changeset/operator-serves-official-widget.md b/.changeset/operator-serves-official-widget.md new file mode 100644 index 00000000..0fb48479 --- /dev/null +++ b/.changeset/operator-serves-official-widget.md @@ -0,0 +1,11 @@ +--- +'smooai-smooth-daemon': minor +--- + +EPIC th-c89c2a: `th daemon operator` now serves the official +`@smooai/smooth-operator` widget at `/`. `serve_local_flavor` enables the +operator's `serve_widget` seam with the local token, so the browser loads the +widget host page (token injected same-origin) and connects to the operator's own +`/ws?token=…`. One process, one port: the canonical WS protocol **and** a usable +chat UI. Additive — the bespoke control surface stays until the widget reaches +parity (markdown / tool-confirm / session list, tracked upstream). diff --git a/.changeset/permissions-check-cli.md b/.changeset/permissions-check-cli.md new file mode 100644 index 00000000..45682a50 --- /dev/null +++ b/.changeset/permissions-check-cli.md @@ -0,0 +1,8 @@ +--- +'smooai-smooth-daemon': patch +--- + +EPIC th-c89c2a (th-515a13): `smooth-daemon permissions check ""` / `--write +` / `permissions path` — inspect what verdict (DENY/ASK/ALLOW) the Gate-1 +rules give a command or write, so users can author + verify `~/.smooth/ +permissions.toml` before relying on it. Reachable as `th daemon permissions …`. diff --git a/.changeset/phase2-remember-tool.md b/.changeset/phase2-remember-tool.md new file mode 100644 index 00000000..c51b864a --- /dev/null +++ b/.changeset/phase2-remember-tool.md @@ -0,0 +1,15 @@ +--- +'smooai-smooth-tools': patch +'smooai-smooth-daemon': patch +--- + +Phase 2 (EPIC th-c89c2a): close the memory loop with a `remember` tool. The +agent can now persist its own salient facts (`RememberTool` in smooth-tools) +— stable operator preferences, confirmed approaches, current project state, +external references — choosing the `MemoryType` so recall can apply the right +freshness treatment. The daemon registers it on every turn pointed at the same +`Memory` backend the engine recalls from, so a fact the agent remembers is +auto-surfaced on later turns and across restarts. The system prompt now tells +the agent to use it. `run_task`'s tool wiring is extracted into a +`build_tool_registry` helper. Tested: store→recall round-trip, type parsing, +and required-content validation. diff --git a/.changeset/phase2-sqlite-memory.md b/.changeset/phase2-sqlite-memory.md new file mode 100644 index 00000000..1857a7f3 --- /dev/null +++ b/.changeset/phase2-sqlite-memory.md @@ -0,0 +1,15 @@ +--- +'smooai-smooth-daemon': patch +--- + +Phase 2 (EPIC th-c89c2a): add durable, cross-session agent memory. A new +`SqliteMemory` implements the engine's synchronous `Memory` trait against a +`memories` table (sharing the daemon's existing SQLite connection), so an +always-on agent's recall survives restarts — the hermes-style memory the +in-memory engine backend can't persist. `store`/`forget` are direct +row ops; `recall` mirrors `InMemoryMemory`'s keyword scoring (fraction of +query words found in the content, highest first), with `MemoryType` + +metadata round-tripped as JSON. Exposed on `Stores.memory`. Wiring it into +the agent loop (`AgentConfig::with_memory`) is the next slice. Tested: +persist-across-reopen, keyword recall ordering, metadata round-trip, empty +query, and forget. diff --git a/.changeset/phase2-wire-memory-into-agent.md b/.changeset/phase2-wire-memory-into-agent.md new file mode 100644 index 00000000..3bb92b56 --- /dev/null +++ b/.changeset/phase2-wire-memory-into-agent.md @@ -0,0 +1,14 @@ +--- +'smooai-smooth-daemon': patch +--- + +Phase 2 (EPIC th-c89c2a): wire durable memory into the agent loop. `AppState` +now carries an `Arc` (the SQLite-backed store in `persistent`, +in-memory in `new`), threaded into `run_task` and attached via +`AgentConfig::with_memory`. The engine then auto-recalls relevant entries for +each user message and injects them (with a freshness nudge for +Project/Reference types) ahead of the prompt — so a cross-session fact stored +once is recalled on later turns and across restarts. `run_task`'s growing +parameter list is bundled into a `RunDeps` struct. Follow-up: a `remember` +tool / extraction step so the agent populates its own memory. Tested: +persistent state carries durable, recallable memory across a restart. diff --git a/.changeset/phase3-bash-tool-egress-proxy.md b/.changeset/phase3-bash-tool-egress-proxy.md new file mode 100644 index 00000000..59b26b15 --- /dev/null +++ b/.changeset/phase3-bash-tool-egress-proxy.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-tools': patch +--- + +Phase 3 (EPIC th-c89c2a): give the `bash` tool an optional egress proxy. +`BashTool` gains a `proxy: Option` field, and a new +`register_default_tools_with_proxy(registry, workspace, proxy)` installs the +default tool set with the shell's egress routed through a loopback proxy +(`SandboxPolicy::with_proxy`) — direct off-box network kernel-denied, so the +proxy's exact-host allowlist is the only way out. `register_default_tools` +is unchanged (egress unrestricted). This is the injection point the daemon +uses to wire its goalie proxy into agent shell commands. diff --git a/.changeset/phase3-daemon-egress-capstone.md b/.changeset/phase3-daemon-egress-capstone.md new file mode 100644 index 00000000..68e53d33 --- /dev/null +++ b/.changeset/phase3-daemon-egress-capstone.md @@ -0,0 +1,17 @@ +--- +'smooai-smooth-daemon': patch +'smooai-smooth-goalie': patch +--- + +Phase 3 (EPIC th-c89c2a): wire the egress boundary into the daemon +end-to-end. New `config::resolve_egress()` reads `SMOOTH_EGRESS_ALLOWLIST` +(comma/space-separated exact hosts; opt-in like auth/sandbox) and +`SMOOTH_EGRESS_PROXY_ADDR`. On startup the daemon spawns goalie's +`run_proxy_local` on a loopback port, threads the proxy address onto +`AppState`, and the runner registers tools via +`register_default_tools_with_proxy` — so agent `bash` egress is forced +through the proxy's allowlist (direct off-box network kernel-denied). goalie +re-exports `run_proxy_local`/`run_proxy_with`/`NetworkDecider`. Invalid +allowlist entries are dropped and logged. Verified live: with an allowlist +set, the daemon logs `egress boundary ON`, the proxy listens, and a request +to an unlisted host returns 403. diff --git a/.changeset/phase3-deny-own-smooth-creds.md b/.changeset/phase3-deny-own-smooth-creds.md new file mode 100644 index 00000000..41bae6b3 --- /dev/null +++ b/.changeset/phase3-deny-own-smooth-creds.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-tools': patch +--- + +Phase 3 hardening (th-08e05a / EPIC th-c89c2a): the macOS Seatbelt bash +sandbox now also kernel-denies reads of the daemon's *own* credentials in +`~/.smooth` — `providers.json` (the LLM API key) and the `auth/` directory +(the auth JWT). Previously a sandboxed shell tool could `cat +~/.smooth/providers.json` and exfil exactly the secret that drives the +agent. Project-scoped `/.smooth` pearls stay readable (different +path). Adds an adversarial test that plants a sentinel under `~/.smooth/auth` +and proves the sandbox can't read it. diff --git a/.changeset/phase3-egress-allowlist-core.md b/.changeset/phase3-egress-allowlist-core.md new file mode 100644 index 00000000..50ba6a78 --- /dev/null +++ b/.changeset/phase3-egress-allowlist-core.md @@ -0,0 +1,16 @@ +--- +'smooai-smooth-goalie': patch +--- + +Phase 3 (EPIC th-c89c2a, P0 #3): add the egress boundary's security-critical +core — an in-process exact-host allowlist (`EgressAllowlist`) with a single +strict hostname parser (`normalize_hostname`). The parser rejects, before any +membership check, the bypass primitives that defeat host allowlists: embedded +NUL / non-ASCII labels (the `attacker.com\0.google.com` SOCKS5 class, +CVE-2025-55284), ports/schemes/userinfo/paths, and malformed DNS label +structure. The allowlist holds exact hosts only — wildcard/port entries are +dropped at construction (and returned for logging), so a bad config can only +narrow reachability, never widen it. The query host is normalized through the +*same* parser so a normalization mismatch can't sneak past. Pure, in-process +(no Wonk round-trip); the daemon's egress proxy + sandbox wiring follow. +Seven adversarial unit tests. diff --git a/.changeset/phase3-egress-default-allowlist.md b/.changeset/phase3-egress-default-allowlist.md new file mode 100644 index 00000000..bdabce02 --- /dev/null +++ b/.changeset/phase3-egress-default-allowlist.md @@ -0,0 +1,13 @@ +--- +'smooai-smooth-daemon': patch +--- + +Phase 3 (EPIC th-c89c2a): make the egress boundary adoptable out-of-the-box. +`SMOOTH_EGRESS_ALLOWLIST` now understands a `defaults` token that expands to a +curated set (`DEFAULT_EGRESS_HOSTS`) of the hosts an agent's shell legitimately +reaches — package registries (npm/yarn/crates/pypi), source hosts +(github/raw/codeload), and the Smoo platform — and merges with any of your own +exact hosts (`SMOOTH_EGRESS_ALLOWLIST="defaults, mycorp.internal"`). The +sentinel never surfaces as a rejected host. Verified live: with `defaults`, +github is reachable through the proxy (200) while an unlisted host is blocked +(403). Docs updated. diff --git a/.changeset/phase3-egress-proxy-local-decider.md b/.changeset/phase3-egress-proxy-local-decider.md new file mode 100644 index 00000000..1fac29e8 --- /dev/null +++ b/.changeset/phase3-egress-proxy-local-decider.md @@ -0,0 +1,13 @@ +--- +'smooai-smooth-goalie': patch +--- + +Phase 3 (EPIC th-c89c2a): wire the `EgressAllowlist` into goalie's forward +proxy via a `NetworkDecider` (Wonk **or** in-process `Local` allowlist). The +always-on daemon can now run the proxy with `run_proxy_local(addr, allowlist, +audit)` — exact-host egress decisions with no Wonk network round-trip, fail- +closed by construction. The accept loop is factored into `serve()` and the +HTTP + CONNECT paths share one decision call; audit reasons now reflect the +actual decider. Backward-compatible: `run_proxy(addr, wonk, audit)` is +unchanged. Adds an end-to-end test (a real client through the proxy gets 403 +for an unlisted host, contacting no upstream) plus a decider unit test. diff --git a/.changeset/phase3-rce-circuit-breakers.md b/.changeset/phase3-rce-circuit-breakers.md new file mode 100644 index 00000000..e458a0df --- /dev/null +++ b/.changeset/phase3-rce-circuit-breakers.md @@ -0,0 +1,14 @@ +--- +'smooai-smooth-daemon': patch +--- + +Phase 3 hardening (EPIC th-c89c2a): broaden the permission engine's +remote-code-execution circuit-breakers. Previously only `curl|sh` / +`wget|bash` were caught; now a downloader piped into *any* interpreter +(`python`/`python3`/`perl`/`ruby`/`node`/`zsh`/`dash`/`ksh`, plus `|&` +variants) trips the breaker, as does a command-substituted download fed to +`eval` or an interpreter `-c` (`eval "$(curl …)"`, `bash -c "$(wget …)"`). +Detection is segment-based on `|` so an interpreter name appearing as a +substring (`shellcheck`) is not a false positive. Circuit-breakers fire in +every mode including `auto`/`bypass`, where they are the last backstop +before the kernel sandbox. Adds adversarial + lookalike tests. diff --git a/.changeset/phase3-sandbox-egress-routing.md b/.changeset/phase3-sandbox-egress-routing.md new file mode 100644 index 00000000..e6008a65 --- /dev/null +++ b/.changeset/phase3-sandbox-egress-routing.md @@ -0,0 +1,14 @@ +--- +'smooai-smooth-tools': patch +--- + +Phase 3 (EPIC th-c89c2a): route sandboxed shell egress through the goalie +proxy and deny direct bypass. `SandboxPolicy::with_proxy(host:port)` makes +the sandbox the egress boundary — it sets `HTTP(S)_PROXY`/`ALL_PROXY` (with +`NO_PROXY` for loopback) on the child, and the macOS Seatbelt profile +**denies direct `network-outbound`** except to loopback (the proxy + local +dev servers). A tool that ignores the proxy vars simply can't connect off-box. +Off-box traffic must therefore pass the proxy's exact-host allowlist. Without +a proxy configured, network is unrestricted as before (opt-in, no regression). +Verified live that the deny actually blocks external egress (direct curl → +connection refused) while the SBPL parses and benign commands run. diff --git a/.changeset/phase3-scrub-secret-env.md b/.changeset/phase3-scrub-secret-env.md new file mode 100644 index 00000000..5ab52eaf --- /dev/null +++ b/.changeset/phase3-scrub-secret-env.md @@ -0,0 +1,15 @@ +--- +'smooai-smooth-tools': patch +--- + +Phase 3 hardening (th-08e05a / EPIC th-c89c2a): scrub secret-bearing +environment variables from sandboxed shell subprocesses. `env`/`printenv` +are read-only-classified (auto-allowed), and the bash tool inherited the +daemon's full environment — so a sandboxed `env` could dump the daemon's +own `SMOOTH_API_KEY` / `SMOOTH_DAEMON_TOKEN` and provider `*_API_KEY`s +straight into the transcript (the env-var twin of the `~/.smooth` read +hole). `SandboxedCommand::shell` now removes secret-named vars +(`SMOOTH_*`, `*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*PASSWORD*`, `*_PAT`, …) +from the child env at the single spawn point — platform-independent, so it +also protects the not-yet-sandboxed Linux path. Benign vars (PATH, HOME, …) +are preserved. Adds unit + adversarial tests. diff --git a/.changeset/phase3-seatbelt-cred-git-hardening.md b/.changeset/phase3-seatbelt-cred-git-hardening.md new file mode 100644 index 00000000..68dd35e0 --- /dev/null +++ b/.changeset/phase3-seatbelt-cred-git-hardening.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-tools': patch +--- + +Phase 3 hardening (th-08e05a / EPIC th-c89c2a): extend the macOS Seatbelt +bash sandbox to cover more of the plan's P1 acceptance criteria. Writes to +`workspace/.git/config` are now kernel-denied alongside `.git/hooks` (a +writable config can repoint `core.hooksPath` or add executing aliases — +P1 #5). Credential read-denial grows beyond `~/.ssh`/`~/.aws`/`~/.config/gh`/ +`~/.gnupg` to also cover `~/.config/gcloud`, `~/.kube`, `~/.docker`, and +`~/.netrc` (P1 #6). Adds adversarial tests proving a planted hook / +`.git/config` write fails and cloud/registry creds don't leak. diff --git a/.changeset/phase4-daemon-uptime.md b/.changeset/phase4-daemon-uptime.md new file mode 100644 index 00000000..217ba4ce --- /dev/null +++ b/.changeset/phase4-daemon-uptime.md @@ -0,0 +1,10 @@ +--- +'smooai-smooth-daemon': patch +'smooai-smooth-web': patch +--- + +Phase 4 (EPIC th-c89c2a): surface daemon uptime. `AppState` records a +`started_at` instant at construction, and `GET /api/status` now reports +`uptime_seconds`. The control-surface header shows a compact "up 2h 14m" +indicator next to the version — useful at-a-glance signal for an always-on +daemon. Tested that status carries uptime; verified live. diff --git a/.changeset/phase4-egress-status-visibility.md b/.changeset/phase4-egress-status-visibility.md new file mode 100644 index 00000000..60563806 --- /dev/null +++ b/.changeset/phase4-egress-status-visibility.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-daemon': patch +'smooai-smooth-web': patch +--- + +Phase 4 (EPIC th-c89c2a): surface the egress boundary's status to operators. +`GET /api/status` now includes `egress_proxy` — the proxy address when the +egress boundary is on, else `null` — and the control surface shows an +`egress on/off` chip in the header (tooltip names the proxy). Also hardens +`resolve_egress` into a pure, env-free `resolve_egress_inner` so its +parse/expand tests don't race on `SMOOTH_EGRESS_ALLOWLIST` (matching the +existing `resolve_llm_inner` pattern). diff --git a/.changeset/phase4-memory-browser.md b/.changeset/phase4-memory-browser.md new file mode 100644 index 00000000..f7962fdb --- /dev/null +++ b/.changeset/phase4-memory-browser.md @@ -0,0 +1,14 @@ +--- +'smooai-smooth-daemon': patch +'smooai-smooth-web': patch +--- + +Phase 4 (EPIC th-c89c2a): add a memory browser to the control surface. A new +read-only `GET /api/memory?q=…&limit=…` searches the agent's durable memory +via the engine's keyword `recall` (no new trait surface), returning projected +hits (content, type, relevance, created_at); an empty query returns nothing. +The control surface gains a Memory search panel in the sidebar that surfaces +matching entries with their `MemoryType`. Now that the `remember` tool +populates real memories, this completes the Phase-4 operator-visibility set +(sessions, chat, approvals, status, egress, memory). Tested: recall match + +projection and the empty-query case. diff --git a/.changeset/phase4-session-auto-title.md b/.changeset/phase4-session-auto-title.md new file mode 100644 index 00000000..c1cc9533 --- /dev/null +++ b/.changeset/phase4-session-auto-title.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-daemon': patch +--- + +Phase 4 (EPIC th-c89c2a): auto-title sessions from their first message. On the +first `TaskStart`, an untitled session gets a readable title derived from the +first non-empty line of the user's message (trimmed to 60 chars, ellipsised) — +so the control surface's sessions list shows something meaningful instead of a +raw id slice. Adds `SessionStore::set_title_if_unset` (in-memory + SQLite +impls), which never clobbers a title the operator chose explicitly. Tested: +title-if-unset fill/keep semantics and the `derive_title` truncation/empty +cases. diff --git a/.changeset/phase4-slice3-status-chat-polish.md b/.changeset/phase4-slice3-status-chat-polish.md new file mode 100644 index 00000000..301f48d9 --- /dev/null +++ b/.changeset/phase4-slice3-status-chat-polish.md @@ -0,0 +1,10 @@ +--- +'smooai-smooth-daemon': patch +'smooai-smooth-web': patch +--- + +Phase 4 Slice 3 (th-bd0def): daemon `/api/status` endpoint exposing live +permission mode + active-task count, and control-surface chat polish — +markdown rendering of assistant messages, auto-scroll to newest item, +permission-mode badge in the header, and per-task completion meta +(iterations + cost) on `TaskComplete`. diff --git a/.changeset/phase4-slice4-runtime-mode-switch.md b/.changeset/phase4-slice4-runtime-mode-switch.md new file mode 100644 index 00000000..d2cdb544 --- /dev/null +++ b/.changeset/phase4-slice4-runtime-mode-switch.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-daemon': patch +'smooai-smooth-web': patch +--- + +Phase 4 Slice 4 (th-bd0def): runtime permission-mode switching. The +daemon's Gate-1 posture is now a thread-safe `SharedPermissionMode` +(atomic-backed) instead of a fixed start-time value, and a new +`PUT /api/mode` endpoint switches it live (400 on an unknown mode); +the change takes effect on the next dispatched task. The control surface +turns the header permission-mode badge into a dropdown that switches +posture via the endpoint and re-reads `/api/status`. diff --git a/.changeset/phase4-slice5-task-cancel.md b/.changeset/phase4-slice5-task-cancel.md new file mode 100644 index 00000000..3f91f499 --- /dev/null +++ b/.changeset/phase4-slice5-task-cancel.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-daemon': patch +'smooai-smooth-web': patch +--- + +Phase 4 Slice 5 (th-bd0def): cancel a running task from the control +surface. The daemon's `TaskCancel` handler now emits a terminal +`TaskError("task cancelled")` and resets the session to idle when it +actually aborts a fiber — previously the aborted task skipped its +completion cleanup, leaving the client stuck "busy" with no signal. The +control surface captures the running `task_id` from the event stream and +shows a **stop** button while a task runs, sending `TaskCancel`. diff --git a/.changeset/phase4-slice6-bearer-auth.md b/.changeset/phase4-slice6-bearer-auth.md new file mode 100644 index 00000000..e8990c17 --- /dev/null +++ b/.changeset/phase4-slice6-bearer-auth.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-daemon': patch +--- + +Phase 4 Slice 6 (th-bd0def): bearer-token auth + bind hardening for the +always-on daemon — the gap its own module doc flagged. Auth is opt-in: +with no `SMOOTH_DAEMON_TOKEN` set the daemon serves open (loopback trusts +the local user), so existing frontends are unaffected. When a token is +set, every API + WS route requires `Authorization: Bearer ` (or a +`?token=` query param, for browser WebSockets that can't set headers), +checked in constant time; `/health` and the embedded SPA stay open. A +non-loopback bind without a token logs a startup warning. diff --git a/.changeset/phase5-schedule-api.md b/.changeset/phase5-schedule-api.md new file mode 100644 index 00000000..03241461 --- /dev/null +++ b/.changeset/phase5-schedule-api.md @@ -0,0 +1,14 @@ +--- +'smooai-smooth-daemon': patch +--- + +Phase 5 (EPIC th-c89c2a): the schedule management API. `GET /api/schedule` +lists scheduled tasks, `POST /api/schedule` creates one from +`{prompt, schedule}` (the `schedule` being a tagged `ScheduleKind`, e.g. +`{"kind":"daily_at","hour":8,"minute":0}` or +`{"kind":"every_n_seconds","secs":300}`; empty prompt → 400), and +`DELETE /api/schedule/{id}` removes one (204). New schedules are first due at +the next cadence point after now, so the scheduler tick picks them up. Tested +at the handler level (create/list/empty-reject/delete) and verified live (CRUD +round-trip, `next_due` resolving to the next 08:00). The `th` CLI + control- +surface UI follow. diff --git a/.changeset/phase5-schedule-model.md b/.changeset/phase5-schedule-model.md new file mode 100644 index 00000000..52b91094 --- /dev/null +++ b/.changeset/phase5-schedule-model.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-daemon': patch +--- + +Phase 5 (EPIC th-c89c2a): begin scheduled/proactive tasks — the hermes-style +"do this every morning / every N minutes" capability. This first slice is the +pure model + timing core: a `ScheduleKind` (`EveryNSeconds`, `DailyAt` in UTC) +with strictly-after `next_after` computation, and a `Schedule` with +`is_due`/`mark_fired` lifecycle. No storage or tick loop yet (those follow), so +the timing logic is exhaustively unit-tested without a clock or DB — +interval advance, daily today-vs-tomorrow (incl. the exactly-at-time edge), +component clamping, the due/advance lifecycle, and serde round-trip. diff --git a/.changeset/phase5-schedule-store.md b/.changeset/phase5-schedule-store.md new file mode 100644 index 00000000..74940aa4 --- /dev/null +++ b/.changeset/phase5-schedule-store.md @@ -0,0 +1,13 @@ +--- +'smooai-smooth-daemon': patch +--- + +Phase 5 (EPIC th-c89c2a): durable schedule store. Adds a `ScheduleStore` +trait (`upsert`/`list`/`due(now)`/`delete`) with an in-memory backend and a +SQLite-backed `SqliteScheduleStore` over a new `schedules` table, sharing the +daemon's connection — so scheduled tasks survive a restart. `due` narrows to +enabled rows in SQL then applies the precise `is_due` `DateTime` check in Rust +(avoiding rfc3339 fractional-second string-compare edges). Wired onto +`Stores` and `AppState`. Tested: in-memory upsert/list/due/delete + +disabled-exclusion, and SQLite persist-across-reopen with kind/timestamp +round-trip. The scheduler tick loop + dispatch + the `th`/API surface follow. diff --git a/.changeset/phase5-schedule-ui.md b/.changeset/phase5-schedule-ui.md new file mode 100644 index 00000000..f703627c --- /dev/null +++ b/.changeset/phase5-schedule-ui.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-web': patch +--- + +Phase 5 (EPIC th-c89c2a): control-surface schedule panel. The sidebar gains a +Schedules section that lists the agent's proactive tasks (cadence + prompt, +with a remove button) and an add form — a prompt field plus a compact cadence +input (`30m` for every-N-minutes, `08:00` for daily UTC), parsed client-side +(`parseCadence`) and validated before enabling Add. Backed by the existing +`/api/schedule` endpoints (`daemon.ts` gains typed `listSchedules` / +`createSchedule` / `deleteSchedule`). This completes the scheduler across all +three surfaces — API, `th` CLI, and the web control surface. diff --git a/.changeset/phase5-scheduler-tick.md b/.changeset/phase5-scheduler-tick.md new file mode 100644 index 00000000..3b703ad8 --- /dev/null +++ b/.changeset/phase5-scheduler-tick.md @@ -0,0 +1,15 @@ +--- +'smooai-smooth-daemon': patch +--- + +Phase 5 (EPIC th-c89c2a): the scheduler tick — what makes the always-on agent +*proactive*. A background loop (`spawn_scheduler`, spawned from +`serve_persistent`) wakes every 30s, asks the `ScheduleStore` which schedules +are due, fires each one's prompt into a per-schedule `schedule:{id}` session +via the same coordinator + `run_task` path a live client uses, then advances +the schedule's `next_due`. Scheduled runs have no connected client so their +events are drained (they still persist to the durable event log + conversation +history, recoverable via `/api/session`). The tick logic is split from the +loop and tested: a due schedule fires (records `last_run`, advances past now) +and gets its session, while a not-yet-due one is untouched. The `th`/API +surface to create/list/remove schedules follows. diff --git a/.changeset/phase5-th-daemon-schedule.md b/.changeset/phase5-th-daemon-schedule.md new file mode 100644 index 00000000..e5b01b55 --- /dev/null +++ b/.changeset/phase5-th-daemon-schedule.md @@ -0,0 +1,11 @@ +--- +'smooai-smooth-cli': patch +--- + +Phase 5 (EPIC th-c89c2a): `th daemon schedule` — manage the always-on agent's +proactive tasks from the terminal. `th daemon schedule list` prints schedules +(id, cadence, next-due, prompt, disabled marker); `add --prompt … (--every-minutes N | --daily HH:MM)` +creates one (exactly one cadence flag required); `rm ` removes one. The +cadence-building and list-formatting are pure and unit-tested (flag parsing, +ranges, both kinds); verified live against a running daemon for the full +add/list/rm round-trip. diff --git a/.changeset/reasoning-as-thinking.md b/.changeset/reasoning-as-thinking.md new file mode 100644 index 00000000..67f1cbff --- /dev/null +++ b/.changeset/reasoning-as-thinking.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-daemon': patch +'smooai-smooth-web': patch +--- + +Reasoning models now show their thinking as a quiet, collapsible "thought for a +moment" aside instead of bleeding chain-of-thought into the answer (th-4d8682). +smooth-operator-core emits reasoning on a distinct `AgentEvent::ReasoningDelta`, +operator-server maps it to a `stream_reasoning` protocol message, and smooth-web +captures it into a separate field rendered as a collapsed disclosure — the answer +(`stream_token`) stays clean. Fixes the daemon's visible chain-of-thought with no +model change. diff --git a/.changeset/scheduler-cli.md b/.changeset/scheduler-cli.md new file mode 100644 index 00000000..f496c5c3 --- /dev/null +++ b/.changeset/scheduler-cli.md @@ -0,0 +1,10 @@ +--- +'smooai-smooth-daemon': patch +--- + +EPIC th-c89c2a (th-2ff975): `smooth-daemon schedule add/list/remove` — the CLI to +manage proactive schedules. `add "" --every 30m|2h|90s|1d` or `--daily-at +HH:MM` writes a `Schedule` to the durable store the running daemon's tick loop +reads; `list` shows cadence + next-due + prompt; `remove ` deletes. Reachable +as `th daemon schedule …` (passthrough). This closes the loop: a user can now +create an always-on proactive task that fires into the operator on its cadence. diff --git a/.changeset/scheduler-foundation.md b/.changeset/scheduler-foundation.md new file mode 100644 index 00000000..123af13c --- /dev/null +++ b/.changeset/scheduler-foundation.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-daemon': patch +--- + +EPIC th-c89c2a (th-2ff975): restore the schedule model as the foundation for +operator-driven proactivity. `ScheduleKind` (EveryNSeconds / DailyAt), `Schedule` +(prompt + cadence + next-due + enabled), the `ScheduleStore` trait, and +`InMemoryScheduleStore` are back (self-contained, 4 tests) after being deleted with +the bespoke serve_persistent loop. Architecture for the loop ahead: the daemon +fires due schedules by acting as a **WS client of its own operator** (the public +`handle_frame` + canonical `send_message` protocol) — proactivity is "just another +client," on-message with the north star. Next: a durable store + the tick loop. diff --git a/.changeset/service-env-and-updater.md b/.changeset/service-env-and-updater.md new file mode 100644 index 00000000..15dcf858 --- /dev/null +++ b/.changeset/service-env-and-updater.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-cli': minor +--- + +`th service` deploy hardening for self-hosted boxes like smoo-hub (th-c435fd): +- `th service install --env KEY=VALUE` bakes env vars into the LaunchAgent/systemd + unit, e.g. `--env SMOOTH_ADDR=127.0.0.1:8788 --env SMOOTH_TAILSCALE_HTTPS_PORT=8443` + to run on a free port + coexist with another tailscale serve on :443. +- `th service self-update [--repo]` pulls latest, runs `pnpm install:th`, and restarts + the service. +- `th service install-updater [--repo] [--interval]` installs a launchd timer that + runs self-update on a cadence (default hourly) so the box stays current. diff --git a/.changeset/smooth-flow-glowup.md b/.changeset/smooth-flow-glowup.md new file mode 100644 index 00000000..578bf28b --- /dev/null +++ b/.changeset/smooth-flow-glowup.md @@ -0,0 +1,19 @@ +--- +'smooai-smooth-cli': minor +'smooai-smooth-code': minor +--- + +Visual glow-up — "Smooth Flow" design language across the `th` CLI + `th code` TUI. + +The brand is a color that flows warm→cool; the chrome now makes that literal. + +- **Flow rule (the signature):** `flow_rule(width, ch)` renders a horizontal + hairline whose every cell steps the full Smooth gradient (orange→pink→teal→ + blue) — the wordmark stretched into a divider. Added to both `gradient.rs` + (CLI, ANSI) and `theme.rs` (TUI, ratatui spans). Used under the `th up` boot + header; reserved for headers so it reads as special. +- **Curated glyph vocabulary** (one set, used everywhere): user `❯` (warm), + agent `✦` + the brand wordmark (cool), tool `▸`→`✓`/`✗`, system `·`, stream + cursor `▌`. Replaces the ad-hoc `⚙`/`⏳`/`█` mix in the live inline renderer. +- `flow_color` interpolates the 4-stop warm→cool brand gradient; all new + helpers unit-tested. diff --git a/.changeset/smooth-modes.md b/.changeset/smooth-modes.md new file mode 100644 index 00000000..83885d9e --- /dev/null +++ b/.changeset/smooth-modes.md @@ -0,0 +1,14 @@ +--- +'smooai-smooth-daemon': minor +'smooai-smooth-web': minor +'smooai-smooth-code': minor +--- + +Smooth Modes: in-chat model switching with always-on cost (th-f512b1, th-2a6330). +`/smooth-mode ` switches Big Smooth's model per-conversation across a +budget tier (flash/code/ui/plan/fast — all <$0.60/1M) and an opt-in premium tier +(flash+/code+/ui+/plan+/max). The active mode, model, cost badge, and live session +spend are shown at all times — identically in smooth-web and the `th code` TUI — +with a persistent ⚠ warning + one-time confirm on premium tiers. Per-turn model +override rides `send_message.model`; live cost rides `eventual_response.data.data.usage`; +badges come from `GET /admin/model-costs` (real gateway pricing). diff --git a/.changeset/smooth-tools-circuit-breaker.md b/.changeset/smooth-tools-circuit-breaker.md new file mode 100644 index 00000000..e6484a8d --- /dev/null +++ b/.changeset/smooth-tools-circuit-breaker.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-tools': minor +--- + +EPIC th-c89c2a: the `bash` tool now hard-denies **circuit-breaker** commands +(`smooth_tools::is_circuit_breaker`) before spawning — `rm -rf /` / `rm -rf ~`, +fork bombs, `curl … | sh`-style remote-code-execution, `mkfs`/`dd of=/dev/…`. +Mirrors the daemon's `permission.rs` circuit-breaker so the operator local-flavor +path (which doesn't install the bespoke permission engine) still gets a deny gate. +The kernel OS-sandbox remains the load-bearing boundary; this is cheap +defense-in-depth. (Closes th-1f694a — delivered in-tool rather than via an +operator host-hook seam, which the sandbox made unnecessary.) diff --git a/.changeset/smooth-web-brand-pwa.md b/.changeset/smooth-web-brand-pwa.md new file mode 100644 index 00000000..0cad0aee --- /dev/null +++ b/.changeset/smooth-web-brand-pwa.md @@ -0,0 +1,11 @@ +--- +'smooai-smooth-web': minor +--- + +Brand the smooth-web PWA with the Smooth `th` icon + force-update on new deploys. +The `th` gradient mark is the favicon + PWA/app icon (regenerated at every size, on +the brand near-black for installed icons) and appears as a quiet top-left product +mark in the UI. The PWA now uses `registerType: 'prompt'` and polls for updates +while open; when a new version ships it shows a non-dismissable "A fresh Big Smooth +is ready" modal whose only action is a forced refresh — so an installed/long-open +tab never drifts onto stale code. Manifest renamed to "Big Smooth". diff --git a/.changeset/smooth-web-presence.md b/.changeset/smooth-web-presence.md new file mode 100644 index 00000000..a2b8c16e --- /dev/null +++ b/.changeset/smooth-web-presence.md @@ -0,0 +1,12 @@ +--- +'smooai-smooth-web': patch +--- + +EPIC th-c89c2a (th-f1a1f0): reimagine smooth-web as the operator's **Presence** +control surface. A thin client on the canonical WS protocol (`operator.ts` +`useOperator` hook — same stream_token/stream_chunk/write_confirmation_required +events as `th code`): one session, streaming conversation with inline tool calls, +and the HITL approval inbox as the hero. The Three.js Big Smooth face is now +reactive across the agent's live presence (awake → thinking → speaking → +amber-alert "needs you"). Warm-ink theme. Replaces the orphaned `/api/*`-bound SPA +(deleted control/daemon/api/layout/pages). Build green (1856 modules). diff --git a/.changeset/split-daemon-from-th.md b/.changeset/split-daemon-from-th.md new file mode 100644 index 00000000..45dd4e87 --- /dev/null +++ b/.changeset/split-daemon-from-th.md @@ -0,0 +1,20 @@ +--- +'smooai-smooth-cli': minor +'smooai-smooth-daemon': minor +--- + +EPIC th-c89c2a: the operator runtime is no longer statically linked into `th`. +`th daemon …` is now a thin **passthrough** (`daemon_launcher`) that resolves + +spawns a standalone `smooth-daemon` binary — found via `SMOOTH_DAEMON_BIN` / +`~/.smooth/bin` / next-to-`th` / `PATH` / the dev workspace, or **downloaded on +demand** from the GitHub release. So installing `th` (the official Smoo AI CLI) +no longer pulls in axum + the engine + adapters + the embedded widget bundle, and +`th` stops path-depping the operator crates. + +- `smooth-daemon` binary gains the full daemon CLI (`run` / `operator` / `status` + / `audit` / `schedule`); the client/format handlers moved out of `th`. +- `th` drops the `smooth-daemon` dependency; `Daemon` becomes a + `trailing_var_arg` passthrough. `pnpm install:th` also installs the + `smooth-daemon` binary for dev. +- Verified: `smooth-operator-server` is gone from `th`'s dep tree; `th daemon + operator` spawns the binary, which serves the operator (widget + `/ws`) live. diff --git a/.changeset/th-code-operator-protocol.md b/.changeset/th-code-operator-protocol.md new file mode 100644 index 00000000..a69f9f62 --- /dev/null +++ b/.changeset/th-code-operator-protocol.md @@ -0,0 +1,16 @@ +--- +'smooai-smooth-code': minor +--- + +EPIC th-c89c2a: `th code` now speaks the **operator's canonical WS protocol** +(`th daemon operator`, :8787) instead of the legacy bespoke `/ws` (:4400). New +`OperatorClient` connects with the local token, opens a conversation session +(reused across turns so multi-turn history stays server-side), sends +`send_message`, and maps the operator's `stream_token` / `stream_chunk` (tool +calls) / `eventual_response` / `error` back to the same `ServerEvent`s the TUI + +headless already render — so the rendering loops are unchanged. `app.rs` + +`headless.rs` swapped over; `ensure_server` now starts `th daemon operator`. +Validated live: `th code --headless` ran a real LLM turn that called the +kernel-sandboxed `bash` tool and round-tripped its output. The bespoke +`BigSmoothClient` + the SSE fallback remain only for the bench-test capture path +(follow-up to remove). diff --git a/.changeset/th-daemon-audit.md b/.changeset/th-daemon-audit.md new file mode 100644 index 00000000..449c3982 --- /dev/null +++ b/.changeset/th-daemon-audit.md @@ -0,0 +1,11 @@ +--- +'smooai-smooth-cli': patch +--- + +Add `th daemon audit` — tail the egress proxy's JSON-lines audit log +(`~/.smooth/audit/egress-proxy.jsonl`) and print the recent allowed/blocked +off-box network decisions as compact `ts ALLOW/BLOCK METHOD host` rows +(`--lines N`, default 20; friendly message when no log exists yet). With +`th daemon status` showing whether the egress boundary is on, this lets the +operator see what it actually did. Pure `format_audit_line` is unit-tested; +verified live against a running daemon (a blocked + an allowed decision). diff --git a/.changeset/th-daemon-status-and-egress-fix.md b/.changeset/th-daemon-status-and-egress-fix.md new file mode 100644 index 00000000..dff67f20 --- /dev/null +++ b/.changeset/th-daemon-status-and-egress-fix.md @@ -0,0 +1,17 @@ +--- +'smooai-smooth-daemon': patch +'smooai-smooth-cli': patch +--- + +Add `th daemon status` and fix `th daemon` not starting the egress boundary. + +- **`th daemon status`** queries the running daemon's `/api/status` and prints + version, uptime, permission mode, egress state, and active-task count + (friendly message when the daemon isn't reachable). Pure formatters are + unit-tested. +- **Bug fix:** the egress-proxy startup lived only in the standalone + `smooth-daemon` binary's `main`, so launching via **`th daemon`** (the + primary entry) served without ever starting the proxy — the egress boundary + was silently inert even with `SMOOTH_EGRESS_ALLOWLIST` set. The startup is + consolidated into a library `smooth_daemon::serve_persistent`, now used by + both entries. Verified live: `th daemon status` reports `egress: on (…)`. diff --git a/.changeset/web-push-notifications.md b/.changeset/web-push-notifications.md new file mode 100644 index 00000000..d9505676 --- /dev/null +++ b/.changeset/web-push-notifications.md @@ -0,0 +1,13 @@ +--- +"smooai-smooth-daemon": minor +"smooai-smooth-web": minor +--- + +Web Push: Big Smooth can notify your phone (an installed PWA) with the tab closed. +Daemon serves `/push/key` (VAPID public key), `/push/subscribe` (persists to +~/.smooth/push-subs.json), and `/push/test`; `PushState::send_to_all` VAPID-signs + +encrypts via the `web-push` crate and prunes expired endpoints. VAPID keys come from +`SMOOTH_VAPID_PUBLIC`/`SMOOTH_VAPID_PRIVATE` (unset ⇒ routes 503, push off). Frontend +adds a "Notify me" bell (top-right), a `usePush` enrollment hook, and a `push-sw.js` +service-worker handler (imported into the generated SW) that shows the notification and +focuses the app on tap. diff --git a/CLAUDE.md b/CLAUDE.md index 5d0b6cf1..2ba25484 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code when working with code in this reposi ## Project Overview -Smooth is the Smoo AI CLI and orchestration platform — a **single Rust binary** (`th`) that coordinates Smooth Operators (AI agents in Microsandbox microVMs). Zero runtime dependencies. +Smooth is the Smoo AI CLI and orchestration platform — a **single Rust binary** (`th`) plus a companion `smooth-daemon`: an always-on, single-tenant personal AI agent built on the **smooth-operator** engine. `th daemon` hosts the operator (canonical WS protocol + widget, durable, kernel-sandboxed tools, proactive scheduler); every surface (`th code` TUI, the web widget) is a thin client on that one protocol. Zero runtime dependencies. (The original microVM substrate was collapsed onto the operator in EPIC th-c89c2a.) --- @@ -17,36 +17,48 @@ Smooth is the Smoo AI CLI and orchestration platform — a **single Rust binary* ``` smooth/ ├── crates/ -│ ├── smooth-cli/ # Binary — clap CLI (23 commands) -│ ├── smooth-bigsmooth/ # Library — orchestrator, policy generation, sandbox -│ ├── smooth-policy/ # Library — shared policy types, TOML parsing -│ ├── smooth-wonk/ # Binary — in-VM access control authority -│ ├── smooth-goalie/ # Binary — in-VM network + filesystem proxy -│ ├── smooth-narc/ # Binary — in-VM tool surveillance + LLM judge -│ ├── smooth-code/ # Library — ratatui terminal dashboard +│ ├── smooth-cli/ # Binary — the `th` clap CLI +│ ├── smooth-daemon/ # Binary — the always-on personal-agent daemon (hosts the operator) +│ ├── smooth-tools/ # Library — workspace-scoped agent tools (fs/grep/bash) + Gate-1 deny +│ ├── smooth-policy/ # Library — shared policy types + the Gate-1 auto-mode rule engine +│ ├── smooth-goalie/ # Library — egress allowlist proxy (the network boundary) +│ ├── smooth-cast/ # Library — coding-harness extensions to the operator (fixer/oracle/… roles) +│ ├── smooth-pearls/ # Library — built-in pearl tracker (Dolt-backed) +│ ├── smooth-diver/ # Library — pearl lifecycle + project-management service +│ ├── smooth-plugin/ # Library — trait-based plugin system (CLI/API/TUI/tool extensions) +│ ├── smooth-api-client/ # Library — typed api.smoo.ai bindings + auth wrapper +│ ├── smooth-code/ # Library — ratatui AI coding TUI (an operator client) │ └── smooth-web/ # Library — embedded Vite SPA via rust-embed │ └── web/ # React + Vite source (TypeScript) -├── Cargo.toml # Workspace root +├── Cargo.toml # Workspace root (engine is a path-dep to ../smooth-operator) ├── rustfmt.toml # Format: 160 width, field init shorthand ├── install.sh # Curl installer └── .claude/hooks/ # Worktree enforcement ``` +> **EPIC th-c89c2a collapsed the microVM substrate onto the operator.** The +> per-VM crates (`smooth-bigsmooth`, `smooth-wonk`, `smooth-narc`, +> `smooth-operative`, `smooth-scribe`, `smooth-archivist`) and the local +> `smooth-operator` engine crate are **gone**: `th daemon` now hosts +> smooth-operator's `LocalServer` directly (engine consumed as a path-dep to the +> `../smooth-operator` checkout), with security re-homed onto a kernel sandbox + +> the `smooth-goalie` egress proxy + the `smooth-policy` Gate-1 rule engine. + ### Key Crates -- **smooth-cli** (`crates/smooth-cli/`): clap entry point, 27 commands including `th access` for policy control -- **smooth-bigsmooth** (`crates/smooth-bigsmooth/`): axum server, 20+ routes, orchestrator, sandbox pool, policy generation, session management, pearls/jira/tailscale -- **smooth-operator** (`crates/smooth-operator/`): Rust-native AI agent framework — LLM client, tool system with hooks, agent loop, conversation management, built-in checkpointing (Groove) -- **smooth-policy** (`crates/smooth-policy/`): shared policy types (network, filesystem, pearls, tools, MCP), TOML parsing, glob matching, phase defaults -- **smooth-pearls** (`crates/smooth-pearls/`): built-in pearl tracker (dependency-graph work items). Dolt-backed via `smooth-dolt` Go binary for version control and git sync. Types: `Pearl`, `PearlStore`, `PearlStatus`, `PearlUpdate`, `PearlQuery`, `SmoothDolt`, `Registry`. Also stores session messages, orchestrator snapshots, and memories. -- **smooth-wonk** (`crates/smooth-wonk/`): in-VM access control authority, policy hot-reload via notify+ArcSwap, access negotiation with Big Smooth -- **smooth-goalie** (`crates/smooth-goalie/`): in-VM HTTP/HTTPS forward proxy, delegates all decisions to Wonk, JSON-lines audit logging -- **smooth-narc** (`crates/smooth-narc/`): tool surveillance via ToolHook, secret detection (10 patterns), prompt injection guard (6 patterns), write guard, severity-based alerts -- **smooth-operative** (`crates/smooth-operative/`): the sandboxed-worker binary that runs *inside* each microVM (one operative per dispatched pearl). Runs the `smooth-operator` engine's agent loop + file/bash tools + NarcHook, streams JSON-lines `AgentEvent`s on stdout. Cross-compiled to `aarch64-unknown-linux-musl`; Big Smooth mounts it into the sandbox at runtime. Build with `scripts/build-operative.sh`. (Distinct from the `smooth-operator` engine crate above, and from the public `smooth-operator` service.) -- **smooth-scribe** (`crates/smooth-scribe/`): per-VM structured logging service, LogEntry with trace context, query/filter support -- **smooth-archivist** (`crates/smooth-archivist/`): central log aggregator, batch ingest from all Scribes, cross-VM query, stats, SSE event stream -- **smooth-code** (`crates/smooth-code/`): ratatui AI coding TUI — streaming chat, tool calls, file browser, git, sessions, model picker, extensions -- **smooth-web** (`crates/smooth-web/`): rust-embed serves compiled Vite SPA +- **smooth-cli** (`crates/smooth-cli/`): clap entry point — the `th` binary. `th daemon …` passes through to the `smooth-daemon` binary. +- **smooth-daemon** (`crates/smooth-daemon/`): the always-on personal-agent daemon. Hosts smooth-operator's `LocalServer` (canonical WS protocol + official widget on `:8787`) made durable by a sqlite `StorageAdapter`; runs the **proactive scheduler** (`schedule.rs`/`scheduler.rs` — fires due tasks into the operator as a loopback WS client) and resolves the LLM gateway. `th daemon` / `th daemon schedule …` / `th daemon permissions …`. +- **smooth-tools** (`crates/smooth-tools/`): the workspace-scoped agent tools (`read_file`/`write_file`/`edit_file`/`grep`/`list_files`/`bash`) the daemon provides per-turn via the operator's `ToolProvider` seam. Path scoping (`path.rs`), the bash circuit-breaker (`guard.rs`), and **Gate-1 deny enforcement** (`permission.rs`, loaded from `~/.smooth/permissions.toml`). +- **smooth-policy** (`crates/smooth-policy/`): shared policy types **and the Gate-1 auto-mode rule engine** (`auto_mode.rs`): `Decision` (deny/ask/allow), `Matcher` (Claude-Code `Tool(pattern)` syntax), `PermissionRules` with deny>ask>allow precedence, bash compound-split, `from_toml`. +- **smooth-goalie** (`crates/smooth-goalie/`): the egress allowlist proxy — the daemon's network boundary (exact-host allowlist, JSON-lines audit). Formerly delegated to the in-VM Wonk; now standalone. +- **smooth-cast** (`crates/smooth-cast/`): coding-harness extensions to the operator engine — the `th code` coding workflow, skill discovery, and harness roles (fixer/oracle/chief/intent_classifier) the published generic engine no longer ships. +- **smooth-pearls** (`crates/smooth-pearls/`): built-in pearl tracker (dependency-graph work items). Dolt-backed via `smooth-dolt` Go binary. Types: `Pearl`, `PearlStore`, `PearlStatus`, `PearlUpdate`, `PearlQuery`, `SmoothDolt`, `Registry`. Also stores session messages + memories. +- **smooth-diver** (`crates/smooth-diver/`): pearl lifecycle manager + project-management service. +- **smooth-plugin** (`crates/smooth-plugin/`): trait-based plugin system for extending Smooth with CLI commands, API routes, TUI views, and operator tools. +- **smooth-api-client** (`crates/smooth-api-client/`): typed `api.smoo.ai` bindings generated from its `openapi.json`, plus an auth wrapper (token store, bearer middleware, refresh-on-401). +- **smooth-code** (`crates/smooth-code/`): ratatui AI coding TUI — an **operator client** (`OperatorClient` speaks the canonical WS protocol to `th daemon`). Streaming chat, tool calls, the HITL approve/deny prompt, file browser, git, sessions, model picker. +- **smooth-web** (`crates/smooth-web/`): rust-embed serves a compiled Vite SPA (the operator's web surface). +- **The engine** is consumed as a **path-dep** to the `../smooth-operator` checkout (crates `smooth-operator` / `-server` / `-svc`), not a local crate — so the daemon embeds the operator's `LocalServer`, tool `ToolProvider` seam, durable `StorageAdapter` seam, and HITL `ConfirmationHook`. --- @@ -189,81 +201,60 @@ pnpm dev # Vite dev server at :3100 --- -## 4. Key Modules (smooth-bigsmooth) +## 4. Key Modules (smooth-daemon) | Module | Purpose | |---|---| -| `server.rs` | axum router, all API routes (20+), access control routes | -| `orchestrator.rs` | State machine: Idle → Scheduling → Dispatching → Monitoring → Reviewing | -| `sandbox.rs` | Embedded [`microsandbox`] Rust SDK: create, destroy, exec, status. No external `msb` CLI — hardware-isolated microVMs boot directly from the binary. | -| `pool.rs` | Sandbox capacity (max 3), port allocation | -| `tools.rs` | Tool registry + hooks (secret detection, prompt injection) | -| `policy.rs` | Policy generation, phase defaults, access request handling | -| `pearls.rs` | `PearlStore` wrapper (list, create, update, close, comment) | -| `search.rs` | @ autocomplete (pearls + globwalk files + path expansion) | -| `audit.rs` | Rotating file appender at ~/.smooth/audit/ | -| `db.rs` | rusqlite: memories, worker_runs, config tables | -| `jira.rs` | Jira REST client + bidirectional sync | -| `tailscale.rs` | tailscale CLI status wrapper | -| `session.rs` | Session persistence, message history, orchestrator snapshots, inbox | -| `ws.rs` | WebSocket message types | - -### Dispatch modes - -Big Smooth's WebSocket `TaskStart` handler can dispatch tasks one of two ways: - -- **In-process** (default): the agent loop runs inside Big Smooth's own process - with tools executing against the host filesystem. Fast, works without any - special setup, but Big Smooth is NOT read-only on this path. -- **Sandboxed** (`SMOOTH_SANDBOXED=1`): Big Smooth spawns a real microVM via - the embedded `microsandbox` crate, mounts the cross-compiled - `smooth-operative` binary at `/opt/smooth/bin`, bind-mounts the - user's working directory at `/workspace`, and execs the runner inside the - VM. The runner hosts the agent loop, NarcHook tool surveillance, and file - tools; it streams `AgentEvent`s as JSON-lines on stdout, which Big Smooth - parses and forwards to WebSocket clients. Big Smooth performs zero writes, - zero tool execution, and zero LLM calls — it is strictly the READ-ONLY - orchestrator the security architecture promises. - -The sandboxed path requires a one-time dev setup to build the runner -binary for the sandbox's target triple. On a fresh clone: - -```bash -rustup target add aarch64-unknown-linux-musl -cargo install --locked cargo-zigbuild -pip3 install ziglang # provides python-zig for cargo-zigbuild -bash scripts/build-operative.sh # produces target/aarch64-unknown-linux-musl/release/smooth-operative -``` - -Re-run `scripts/build-operative.sh` after changing anything under -`crates/smooth-operative/` or its transitive deps. - -The in-process path is kept for backwards compatibility and for the existing -headless E2E tests. New features should target the sandboxed path. +| `operator.rs` | `serve_local_flavor` — boots the operator's `LocalServer` (gateway resolution, the sandboxed `ToolProvider`, durable storage, the scheduler) and serves the canonical WS protocol + widget on `:8787` | +| `operator_storage.rs` | `SqliteStorageAdapter` — durable conversations/participants/messages/sessions over the operator's `.storage()` seam (survives restart, no Postgres) | +| `schedule.rs` | The proactive-task model: `ScheduleKind` (EveryNSeconds/DailyAt), `Schedule`, `ScheduleStore` trait, `SqliteScheduleStore` (durable) + `InMemoryScheduleStore` | +| `scheduler.rs` | The tick loop (`tick`/`spawn_scheduler`) + the `TurnDriver` trait; `OperatorTurnDriver` fires due schedules into the operator as a **loopback WS client** | +| `config.rs` | Egress allowlist resolution + LLM gateway/credential resolution (env → `providers.json`) | +| `main.rs` | the `smooth-daemon` CLI: `run`/`operator`/`audit`/`schedule …`/`permissions …` | + +### Dispatch + +There is **one agent loop** — the operator's. `th daemon` hosts smooth-operator's +`LocalServer` in-process; tool calls run through the `smooth-tools` `ToolProvider` +(workspace-confined fs/grep + an OS-sandboxed `bash` whose egress routes through +the goalie proxy). No microVM, no second loop, no `SMOOTH_SANDBOXED` branch — the +microVM dispatch path and the cross-compiled `smooth-operative` runner were +deleted in EPIC th-c89c2a. The TUI, the widget, and the scheduler are all just +**clients on the canonical protocol**. ### Security Architecture -The sandbox access control system uses named services running inside each microVM: - -- **Big Smooth** — READ-ONLY orchestrator in "The Safehouse" VM -- **Archivist** — central log aggregator (can write only to log paths) -- **Wonk** — per-VM access control authority (rule engine, no LLM) -- **Goalie** — per-VM network + FUSE filesystem proxy (iptables enforced) -- **Narc** — per-VM tool surveillance + prompt injection guard (regex + LLM judge) -- **Scribe** — per-VM structured logging, feeds Archivist -- **Groove** — LLM checkpointing + session resume (built into smooth-operator) - -See README.md for full architecture diagrams and the plan file for implementation details. - -### smooth-operator (Agent Framework) +Single trusted operator, no untrusted tenant — so the boundary is the **kernel**, +not a VM. Layers (cheap → load-bearing): + +- **Gate 1 — deterministic rule engine** (`smooth-policy::auto_mode` + + `smooth-tools::permission`): a Claude-Code-style **deny/ask/allow** rule set + from `~/.smooth/permissions.toml`. **Deny is enforced at the tool boundary** + (bash/write/edit/read), with bash compound-split so `ls && rm -rf ~` is caught + on the `rm`. `Ask`→HITL per-call awaits an operator host-hook seam (th-01ec60). + Inspect with `th daemon permissions check ""`. +- **Bash circuit-breaker** (`smooth-tools::guard`): hardcoded hard-deny of + catastrophic commands (`rm -rf /`, fork bombs, `curl … | sh`) — never run. +- **HITL** (the operator's `write_confirmation_required` + `th code`'s approve/deny + prompt): opt-in via `SMOOTH_AGENT_CONFIRM_TOOLS`; the "ask" affordance (th-1ea4f6). +- **Kernel OS-sandbox** (`smooth-tools::sandbox`): confines tool subprocesses to + the workspace — **the load-bearing boundary**. +- **Egress proxy** (`smooth-goalie`): an exact-host allowlist outside the sandbox; + off-box network is kernel-denied unless routed through it. `th daemon audit`. +- **Groove** — LLM checkpointing + session resume (built into smooth-operator). + +### smooth-operator (Agent Framework — path-dep, `../smooth-operator`) + +The engine is consumed as a path-dep, not a local crate. Key seams the daemon uses: | Module | Purpose | |---|---| | `agent.rs` | Observe → think → act loop, event emission, checkpoint integration | | `llm.rs` | OpenAI-compatible chat completion client, streaming-ready | -| `tool.rs` | Tool trait + ToolRegistry with pre/post hooks (Narc integration) | +| `tool.rs` | `Tool` trait + `ToolRegistry` with pre/post hooks (`ToolRegistry::add_hook` — where `ConfirmationHook` and a future Gate-1 host hook install) | | `conversation.rs` | Message history, context window management, token estimation | | `checkpoint.rs` | Checkpoint + CheckpointStore trait, configurable strategies | +| `server` / `svc` | `LocalServer` + builder (`.storage()`/`.tools()`/`.auth()`/`.serve_widget()`), the canonical WS protocol, `StorageAdapter`/`ToolProvider` seams | --- diff --git a/Cargo.lock b/Cargo.lock index 63af9047..abd11805 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,10 +3,14 @@ version = 4 [[package]] -name = "adler2" -version = "2.0.1" +name = "aead" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] [[package]] name = "aes" @@ -15,10 +19,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-keywrap" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b6f24a1f796bc46415a1d0d18dc0a8203ccba088acf5def3291c4f61225522" +dependencies = [ + "aes 0.9.1", + "byteorder", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -28,12 +79,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aliasable" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" - [[package]] name = "allocator-api2" version = "0.2.21" @@ -115,77 +160,22 @@ dependencies = [ ] [[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - -[[package]] -name = "asn1-rs" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" +name = "arrayref" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] -name = "astral-tokio-tar" -version = "0.6.0" +name = "arrayvec" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9" -dependencies = [ - "filetime", - "futures-core", - "libc", - "portable-atomic", - "rustc-hash", - "tokio", - "tokio-stream", - "xattr", -] +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" [[package]] -name = "async-broadcast" -version = "0.7.2" +name = "ascii" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "async-channel" @@ -199,122 +189,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-compression" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" -dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.89" @@ -326,15 +200,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - [[package]] name = "atomic" version = "0.6.1" @@ -367,28 +232,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.39.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.7.9" @@ -399,7 +242,7 @@ dependencies = [ "axum-core 0.4.5", "bytes", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -417,7 +260,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower 0.5.3", + "tower", "tower-layer", "tower-service", "tracing", @@ -434,7 +277,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -453,7 +296,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-tungstenite 0.28.0", - "tower 0.5.3", + "tower", "tower-layer", "tower-service", "tracing", @@ -468,7 +311,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -488,7 +331,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -510,7 +353,7 @@ dependencies = [ "bytes", "futures-util", "headers", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -522,6 +365,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -541,23 +396,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "bincode_derive", - "unty", -] - -[[package]] -name = "bincode_derive" -version = "2.0.1" +name = "binstring" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" -dependencies = [ - "virtue", -] +checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef" [[package]] name = "bit-set" @@ -585,8 +427,16 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" dependencies = [ - "serde_core", + "arrayref", + "arrayvec", + "constant_time_eq", ] [[package]] @@ -600,35 +450,13 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "bstr" version = "1.12.1" @@ -671,41 +499,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" +name = "camino" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" dependencies = [ - "cc", - "pkg-config", + "serde_core", ] [[package]] -name = "capng" -version = "0.2.3" +name = "cargo-platform" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a26766f93f07f7e8b8309ed2824fa2a68f5d12d219de855e24688e9fbe89e85" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" dependencies = [ - "bitflags 1.3.2", - "libc", + "serde", + "serde_core", ] [[package]] -name = "caps" -version = "0.5.6" +name = "cargo_metadata" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd1ddba47aba30b6a889298ad0109c3b8dcb0e8fc993b459daa7067d46f865e0" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" dependencies = [ - "libc", + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", ] [[package]] @@ -717,15 +540,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.2.58" @@ -733,17 +547,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -756,17 +562,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - [[package]] name = "chrono" version = "0.4.44" @@ -788,40 +583,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" [[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "ciborium-io", - "half", + "crypto-common 0.1.7", + "inout 0.1.4", ] [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common 0.1.7", - "inout", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -852,7 +630,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -865,12 +643,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] -name = "cmake" -version = "0.1.58" +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "coarsetime" +version = "0.1.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" dependencies = [ - "cc", + "libc", + "wasix", + "wasm-bindgen", ] [[package]] @@ -903,25 +689,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "compression-codecs" -version = "0.4.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" -dependencies = [ - "compression-core", - "flate2", - "memchr", - "zstd", - "zstd-safe", -] - -[[package]] -name = "compression-core" -version = "0.4.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -944,6 +711,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-oid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b" + [[package]] name = "const-oid" version = "0.9.6" @@ -957,24 +730,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] -name = "const_format" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" +name = "constant_time_eq" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" @@ -1011,6 +770,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1029,30 +794,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "critical-section" version = "1.2.0" @@ -1087,15 +828,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1130,10 +862,16 @@ dependencies = [ ] [[package]] -name = "crunchy" -version = "0.2.4" +name = "crypto-bigint" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] [[package]] name = "crypto-common" @@ -1142,16 +880,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.2", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1165,150 +906,127 @@ dependencies = [ ] [[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" +name = "ct-codecs" +version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] +checksum = "49fb0c6640b4507ebd99ff67677009e381ba5eee1d14df78de4a3d16eb123c39" [[package]] -name = "darling_core" -version = "0.23.0" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", + "cipher 0.4.4", ] [[package]] -name = "darling_macro" -version = "0.20.11" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", + "cmov", ] [[package]] -name = "darling_macro" -version = "0.23.0" +name = "curl" +version = "0.4.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc" dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.117", + "curl-sys", + "libc", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "socket2", + "windows-sys 0.59.0", ] [[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "dbus" -version = "0.9.10" +name = "curl-sys" +version = "0.4.87+curl-8.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +checksum = "61a460380f0ef783703dcbe909107f39c162adeac050d73c850055118b5b6327" dependencies = [ + "cc", "libc", - "libdbus-sys", + "libnghttp2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", "windows-sys 0.59.0", ] [[package]] -name = "dbus-secret-service" -version = "4.1.0" +name = "curve25519-dalek" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "aes", - "block-padding", - "cbc", - "dbus", - "fastrand", - "hkdf", - "num", - "once_cell", - "sha2 0.10.9", + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", "zeroize", ] [[package]] -name = "defmt" -version = "0.3.100" +name = "curve25519-dalek-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "defmt 1.0.1", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "defmt" -version = "1.0.1" +name = "darling" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "bitflags 1.3.2", - "defmt-macros", + "darling_core", + "darling_macro", ] [[package]] -name = "defmt-macros" -version = "1.0.1" +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "defmt-parser", - "proc-macro-error2", + "ident_case", "proc-macro2", "quote", + "strsim", "syn 2.0.117", ] [[package]] -name = "defmt-parser" -version = "1.0.0" +name = "darling_macro" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "thiserror 2.0.18", + "darling_core", + "quote", + "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deltae" version = "0.3.2" @@ -1317,67 +1035,54 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "9.0.0" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4" dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", + "const-oid 0.6.2", + "der_derive", ] [[package]] -name = "deranged" -version = "0.5.8" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "powerfmt", + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "derive_builder" -version = "0.20.2" +name = "der" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "derive_builder_macro", + "const-oid 0.10.2", + "zeroize", ] [[package]] -name = "derive_builder_core" -version = "0.20.2" +name = "der_derive" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +checksum = "8aed3b3c608dc56cf36c45fe979d04eda51242e6703d8d0bb03426ef7c41db6a" dependencies = [ - "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 1.0.109", + "synstructure 0.12.6", ] [[package]] -name = "derive_builder_macro" -version = "0.20.2" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "derive_builder_core", - "syn 2.0.117", + "powerfmt", ] [[package]] @@ -1400,7 +1105,6 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", - "unicode-xid", ] [[package]] @@ -1431,22 +1135,12 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" -dependencies = [ - "block-buffer 0.12.0", - "const-oid 0.10.2", - "crypto-common 0.2.1", -] - -[[package]] -name = "dirs" -version = "6.0.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "dirs-sys", + "block-buffer 0.12.1", + "crypto-common 0.2.2", ] [[package]] @@ -1460,26 +1154,14 @@ dependencies = [ ] [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "dirs-sys-next" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", + "redox_users", + "winapi", ] [[package]] @@ -1494,50 +1176,111 @@ dependencies = [ ] [[package]] -name = "docker_credential" -version = "1.3.2" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d89dfcba45b4afad7450a99b39e751590463e45c04728cf555d36bb66940de8" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "base64 0.21.7", - "serde", - "serde_json", + "litrs", ] [[package]] -name = "document-features" -version = "0.2.12" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "litrs", + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ece" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746" +dependencies = [ + "base64 0.21.7", + "byteorder", + "hex", + "hkdf", + "lazy_static", + "once_cell", + "openssl", + "serde", + "sha2", + "thiserror 1.0.69", ] [[package]] -name = "dotenvy" -version = "0.15.7" +name = "ed25519" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] [[package]] -name = "dunce" -version = "1.0.5" +name = "ed25519-compact" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "5c24599140dc39d7a81e4476e7573d41bbc18e07c803900298e522a5fbcfbfb6" +dependencies = [ + "ct-codecs", + "getrandom 0.4.2", +] [[package]] -name = "dyn-clone" -version = "1.0.20" +name = "ed25519-dalek" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "serde", + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", ] [[package]] @@ -1564,46 +1307,13 @@ dependencies = [ "encoding_rs", ] -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "endian-type" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" - [[package]] name = "enum-as-inner" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -1625,17 +1335,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - [[package]] name = "euclid" version = "0.22.14" @@ -1666,6 +1365,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1683,25 +1394,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "filedescriptor" -version = "0.8.3" +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", + "rand_core 0.6.4", + "subtle", ] [[package]] -name = "filetime" -version = "0.2.27" +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" dependencies = [ - "cfg-if", "libc", - "libredox", + "thiserror 1.0.69", + "winapi", ] [[package]] @@ -1722,42 +1438,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fluent-uri" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "fnv" version = "1.0.7" @@ -1810,12 +1490,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.32" @@ -1858,17 +1532,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - [[package]] name = "futures-io" version = "0.3.32" @@ -1945,6 +1608,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1990,31 +1654,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] -name = "getset" -version = "0.1.6" +name = "ghash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.117", + "opaque-debug", + "polyval", ] -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "globset" version = "0.4.18" @@ -2028,17 +1686,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "globwalk" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" -dependencies = [ - "bitflags 2.11.0", - "ignore", - "walkdir", -] - [[package]] name = "grep-matcher" version = "0.1.8" @@ -2076,6 +1723,17 @@ dependencies = [ "memmap2", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -2087,8 +1745,8 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", - "indexmap 2.13.0", + "http 1.4.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -2096,39 +1754,20 @@ dependencies = [ ] [[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hash32" -version = "0.3.1" +name = "hashbrown" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "byteorder", + "ahash", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -2145,11 +1784,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.14.5", ] [[package]] @@ -2161,7 +1800,7 @@ dependencies = [ "base64 0.22.1", "bytes", "headers-core", - "http", + "http 1.4.0", "httpdate", "mime", "sha1", @@ -2173,25 +1812,9 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http", -] - -[[package]] -name = "heapless" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" -dependencies = [ - "hash32", - "stable_deref_trait", + "http 1.4.0", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -2219,25 +1842,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hickory-client" -version = "0.26.0-alpha.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b710cd52bde69b58137785a5d2b1ec1b20980a3ca0d5f959eab8c45a72e92d" -dependencies = [ - "cfg-if", - "data-encoding", - "futures-channel", - "futures-util", - "hickory-proto 0.26.0-alpha.1", - "once_cell", - "radix_trie", - "rand 0.9.2", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "hickory-proto" version = "0.25.2" @@ -2264,46 +1868,18 @@ dependencies = [ ] [[package]] -name = "hickory-proto" -version = "0.26.0-alpha.1" +name = "hickory-resolver" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62d7684f766b0f96344be88c023f9b6650039aea09d526b4974cce302eb61b1" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ - "async-trait", - "bytes", "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", "futures-util", - "idna", - "ipnet", + "hickory-proto", + "ipconfig", + "moka", "once_cell", - "rand 0.9.2", - "ring", - "rustls", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tokio-rustls", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto 0.25.2", - "ipconfig", - "moka", - "once_cell", - "parking_lot", + "parking_lot", "rand 0.9.2", "resolv-conf", "smallvec", @@ -2331,31 +1907,48 @@ dependencies = [ ] [[package]] -name = "home" -version = "0.5.12" +name = "hmac-sha1-compact" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823" + +[[package]] +name = "hmac-sha256" +version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" dependencies = [ - "windows-sys 0.61.2", + "digest 0.10.7", +] + +[[package]] +name = "hmac-sha512" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b" +dependencies = [ + "digest 0.10.7", ] [[package]] name = "http" -version = "1.4.0" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ "bytes", + "fnv", "itoa", ] [[package]] -name = "http-auth" -version = "0.1.10" +name = "http" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ - "memchr", + "bytes", + "itoa", ] [[package]] @@ -2365,7 +1958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -2376,16 +1969,20 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "pin-project-lite", ] [[package]] -name = "http-range-header" -version = "0.4.2" +name = "http-serde" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http 1.4.0", + "serde", +] [[package]] name = "httparse" @@ -2401,10 +1998,11 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ + "ctutils", "typenum", ] @@ -2419,7 +2017,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", + "http 1.4.0", "http-body", "httparse", "httpdate", @@ -2436,15 +2034,17 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", + "log", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.6", + "webpki-roots", ] [[package]] @@ -2486,14 +2086,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.0", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "system-configuration", "tokio", "tower-layer", @@ -2656,36 +2256,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "imago" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e8e4b92aa0dd860579cfba776dbf0918a3a7ac5cb601af7d3fc835e71592a5b" -dependencies = [ - "async-trait", - "bincode", - "cfg-if", - "libc", - "miniz_oxide", - "nix 0.30.1", - "page_size", - "rustc_version", - "tokio", - "tracing", - "vm-memory 0.18.0", - "windows-sys 0.61.2", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -2721,44 +2291,21 @@ dependencies = [ ] [[package]] -name = "inherent" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "inotify" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" -dependencies = [ - "bitflags 2.11.0", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "libc", + "generic-array", ] [[package]] name = "inout" -version = "0.1.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "block-padding", - "generic-array", + "hybrid-array", ] [[package]] @@ -2767,7 +2314,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling 0.23.0", + "darling", "indoc", "proc-macro2", "quote", @@ -2780,7 +2327,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.3", + "socket2", "widestring", "windows-registry", "windows-result", @@ -2793,15 +2340,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "ipnetwork" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" -dependencies = [ - "serde", -] - [[package]] name = "iri-string" version = "0.7.12" @@ -2837,6 +2375,32 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "isahc" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d93e1769c5c2b13a8e0d8ca9b6466c60bafade047326f25e3bcb97a947d875" +dependencies = [ + "async-channel", + "castaway", + "crossbeam-utils", + "curl", + "curl-sys", + "encoding_rs", + "event-listener", + "futures-lite", + "http 0.2.12", + "log", + "mime", + "polling", + "slab", + "sluice", + "tracing", + "tracing-futures", + "url", + "waker-fn", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2852,22 +2416,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -2877,7 +2425,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys 0.4.1", + "jni-sys", "log", "simd_cesu8", "thiserror 2.0.18", @@ -2898,15 +2446,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - [[package]] name = "jni-sys" version = "0.4.1" @@ -2926,16 +2465,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.94" @@ -2955,80 +2484,81 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64 0.22.1", + "ed25519-dalek", "getrandom 0.2.17", + "hmac", "js-sys", + "p256", + "p384", + "pem 3.0.6", + "rand 0.8.5", + "rsa", "serde", "serde_json", - "signature", + "sha2", + "signature 2.2.0", + "simple_asn1", ] [[package]] -name = "kasuari" -version = "0.4.12" +name = "jwt-simple" +version = "0.12.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +checksum = "3991f54af4b009bb6efe01aa5a4fcce9ca52f3de7a104a3f6b6e2ad36c852c48" dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", + "anyhow", + "binstring", + "blake2b_simd", + "coarsetime", + "ct-codecs", + "ed25519-compact", + "hmac-sha1-compact", + "hmac-sha256", + "hmac-sha512", + "k256", + "p256", + "p384", + "rand 0.8.5", + "serde", + "serde_json", + "superboring", "thiserror 2.0.18", -] - -[[package]] -name = "keyring" -version = "3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" -dependencies = [ - "byteorder", - "dbus-secret-service", - "linux-keyutils", - "log", - "secret-service", - "security-framework 2.11.1", - "security-framework 3.7.0", - "windows-sys 0.60.2", "zeroize", ] [[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.0.4" +name = "k256" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ - "bitflags 1.3.2", - "libc", + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature 2.2.0", ] [[package]] -name = "kvm-bindings" -version = "0.14.0" +name = "kasuari" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b3c06ff73c7ce03e780887ec2389d62d2a2a9ddf471ab05c2ff69207cd3f3b4" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ - "vmm-sys-util", + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", ] [[package]] -name = "kvm-ioctls" -version = "0.24.0" +name = "keccak" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333f77a20344a448f3f70664918135fddeb804e938f28a99d685bd92926e0b19" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" dependencies = [ - "bitflags 2.11.0", - "kvm-bindings", - "libc", - "vmm-sys-util", + "cfg-if", + "cpufeatures 0.3.0", ] [[package]] @@ -3059,41 +2589,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] -name = "libdbus-sys" -version = "0.2.7" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "cc", - "pkg-config", -] +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] -name = "libloading" -version = "0.8.9" +name = "libnghttp2-sys" +version = "0.1.13+1.68.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +checksum = "492e00167f1418c15648144f42bbfc63099806ecee9bf8d09a6353d6b4856b3c" dependencies = [ - "cfg-if", - "windows-link", + "cc", + "libc", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.11.0", "libc", - "plain", - "redox_syscall 0.7.3", ] [[package]] @@ -3118,31 +2635,24 @@ dependencies = [ ] [[package]] -name = "line-clipping" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "linux-keyutils" -version = "0.2.5" +name = "libz-sys" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ - "bitflags 2.11.0", + "cc", "libc", + "pkg-config", + "vcpkg", ] [[package]] -name = "linux-loader" -version = "0.13.0" +name = "line-clipping" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870c3814345f050991f99869417779f6062542bcf4ed81db7a1b926ad1306638" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "vm-memory 0.16.2", + "bitflags 2.11.0", ] [[package]] @@ -3193,35 +2703,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lsp-types" -version = "0.97.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" -dependencies = [ - "bitflags 1.3.2", - "fluent-uri", - "serde", - "serde_json", - "serde_repr", -] - [[package]] name = "mac_address" version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix 0.29.0", + "nix", "winapi", ] -[[package]] -name = "managed" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" - [[package]] name = "matchers" version = "0.2.0" @@ -3243,16 +2734,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest 0.10.7", -] - [[package]] name = "memchr" version = "2.8.0" @@ -3284,207 +2765,8 @@ dependencies = [ ] [[package]] -name = "microsandbox" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d289ffd511867e5f82bbe2b577fcc78acbf8c99100c9435d515902c532f6f45" -dependencies = [ - "astral-tokio-tar", - "async-compression", - "bytes", - "chrono", - "crossterm", - "dbus", - "dirs", - "docker_credential", - "flate2", - "futures", - "hex", - "keyring", - "libc", - "microsandbox-db", - "microsandbox-filesystem", - "microsandbox-image", - "microsandbox-migration", - "microsandbox-network", - "microsandbox-protocol", - "microsandbox-runtime", - "microsandbox-utils", - "nix 0.30.1", - "rand 0.10.1", - "reqwest 0.13.2", - "scopeguard", - "sea-orm", - "serde", - "serde_json", - "sha2 0.11.0", - "tar", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tracing", - "typed-builder", - "which", -] - -[[package]] -name = "microsandbox-db" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e0cf4b1e42563a39975ec111d0c5ca3b2d2648dcc445a97be6cebc1c8155308" -dependencies = [ - "async-trait", - "sea-orm", - "sqlx", - "tokio", - "tracing", -] - -[[package]] -name = "microsandbox-filesystem" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efdf0dbee927e3e828860eddc6a3b234a8e793ccc23636ed9962c25b3955121e" -dependencies = [ - "libc", - "microsandbox-utils", - "msb_krun", - "scopeguard", - "tempfile", - "tracing", -] - -[[package]] -name = "microsandbox-image" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e99be592e0bf911930b31ae52864217c7d5d9b54e83cadd3b93d065ad23bed8d" -dependencies = [ - "astral-tokio-tar", - "async-compression", - "futures", - "hex", - "libc", - "microsandbox-utils", - "oci-client", - "oci-spec", - "rustls-pemfile", - "scopeguard", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "xattr", -] - -[[package]] -name = "microsandbox-migration" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b72e5b5b2c5bdea11a4d50b540c69d20466b828311466713ed03b2e1720718a" -dependencies = [ - "sea-orm-migration", -] - -[[package]] -name = "microsandbox-network" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51003a55eb30a25b767a1664622b7b376cbd39ecb186b982db16c120ca1abd0" -dependencies = [ - "base64 0.22.1", - "bytes", - "core-foundation 0.9.4", - "crossbeam-queue", - "dirs", - "futures", - "hickory-client", - "hickory-proto 0.26.0-alpha.1", - "ipnetwork", - "libc", - "lru", - "microsandbox-protocol", - "microsandbox-utils", - "msb_krun", - "parking_lot", - "pem", - "percent-encoding", - "rcgen", - "resolv-conf", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "serde", - "smoltcp", - "system-configuration", - "thiserror 2.0.18", - "time", - "tokio", - "tokio-rustls", - "tracing", -] - -[[package]] -name = "microsandbox-protocol" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d655a839bc767cadbd769a15f35b3cedd23aac8c67a53ba36188822e6af193d4" -dependencies = [ - "chrono", - "ciborium", - "serde", - "serde_bytes", - "thiserror 2.0.18", - "tokio", -] - -[[package]] -name = "microsandbox-runtime" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e601dd7c100467e7318845df3fb8e5ab0d83866416eab1405ff3290a1ec53874" -dependencies = [ - "bytes", - "chrono", - "clap", - "crossbeam-queue", - "libc", - "microsandbox-db", - "microsandbox-filesystem", - "microsandbox-network", - "microsandbox-protocol", - "microsandbox-utils", - "msb_krun", - "nix 0.30.1", - "rustls", - "sea-orm", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "microsandbox-utils" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d62293decc8415995915c95f45b5e2425316e518f3ff90a28c23beecf945108" -dependencies = [ - "dirs", - "libc", - "reflink-copy", - "scopeguard", - "ureq", -] - -[[package]] -name = "mime" -version = "0.3.17" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" @@ -3504,16 +2786,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.2.0" @@ -3526,6 +2798,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163f15320f3fba11760c373af52d7f69d638482c2c350d877fb06513b1c3137c" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "sha3", + "signature 3.0.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "moka" version = "0.12.15" @@ -3543,177 +2842,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "msb_krun" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c29ba9c9c8a38cd001c0c9af16bd3c75be328ab8c95a3aa933b125c1d93f41c" -dependencies = [ - "crossbeam-channel", - "kvm-bindings", - "kvm-ioctls", - "libc", - "libloading", - "log", - "msb_krun_devices", - "msb_krun_hvf", - "msb_krun_polly", - "msb_krun_utils", - "msb_krun_vmm", - "vm-memory 0.16.2", -] - -[[package]] -name = "msb_krun_arch" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb4327e3efe423f3bcdca0390589193834b9f73eb54bb13b90f48f1755b499e" -dependencies = [ - "kvm-bindings", - "kvm-ioctls", - "libc", - "msb_krun_arch_gen", - "msb_krun_smbios", - "msb_krun_utils", - "vm-memory 0.16.2", - "vmm-sys-util", -] - -[[package]] -name = "msb_krun_arch_gen" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bc7dbc08509138172ccd3b40df5c1501ad498ba12f2643eb013320205f9c987" - -[[package]] -name = "msb_krun_cpuid" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d88b81a52ae5b3fe1afb1692b0785a4fe946c77bbc9e43a030e84e90627819" -dependencies = [ - "kvm-bindings", - "kvm-ioctls", - "vmm-sys-util", -] - -[[package]] -name = "msb_krun_devices" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f6d808b3c4d5a7c24fba02a64f2f704da973cf6820deedd2811c2ec795b9fc" -dependencies = [ - "bitflags 1.3.2", - "capng", - "caps", - "crossbeam-channel", - "imago", - "kvm-bindings", - "kvm-ioctls", - "libc", - "libloading", - "log", - "lru", - "msb_krun_arch", - "msb_krun_hvf", - "msb_krun_polly", - "msb_krun_utils", - "nix 0.30.1", - "rand 0.9.2", - "virtio-bindings", - "vm-fdt", - "vm-memory 0.16.2", -] - -[[package]] -name = "msb_krun_hvf" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce2de785e2cceda28ce03f20a9eeb895ac947ab66b09db31077a070bd6dda86" -dependencies = [ - "crossbeam-channel", - "libloading", - "log", - "msb_krun_arch", -] - -[[package]] -name = "msb_krun_kernel" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047a3f886330138dd6a681ef96b0b2257b494f295535bdd641841068f5847bc" -dependencies = [ - "msb_krun_utils", - "vm-memory 0.16.2", -] - -[[package]] -name = "msb_krun_polly" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db57f147c6e1aca9a14d137ada76b6df78b538a0673d86b33652be78e9784499" -dependencies = [ - "libc", - "msb_krun_utils", -] - -[[package]] -name = "msb_krun_smbios" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c0c3a0923f194852028fd0e809a8d26c5465bc550f59c08b25bae5dd9885457" -dependencies = [ - "vm-memory 0.16.2", -] - -[[package]] -name = "msb_krun_utils" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbae5590c539ee8b9ed0e2d4c50c6ee5b546f03f9eb2408b885628514ddb9a6" -dependencies = [ - "bitflags 1.3.2", - "crossbeam-channel", - "kvm-bindings", - "libc", - "log", - "nix 0.30.1", - "vmm-sys-util", -] - -[[package]] -name = "msb_krun_vmm" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd62153e5ffe89a76b775c89b9c01b65d38c67a32ca8d516a910d4cb497d367" -dependencies = [ - "bzip2", - "crossbeam-channel", - "flate2", - "kvm-bindings", - "kvm-ioctls", - "libc", - "linux-loader", - "log", - "msb_krun_arch", - "msb_krun_arch_gen", - "msb_krun_cpuid", - "msb_krun_devices", - "msb_krun_hvf", - "msb_krun_kernel", - "msb_krun_polly", - "msb_krun_utils", - "nix 0.30.1", - "vm-memory 0.16.2", - "vmm-sys-util", - "zstd", -] - -[[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - [[package]] name = "native-tls" version = "0.2.18" @@ -3723,10 +2851,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.2.1", "openssl-sys", "schannel", - "security-framework 3.7.0", + "security-framework", "security-framework-sys", "tempfile", ] @@ -3737,15 +2865,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - [[package]] name = "nix" version = "0.29.0" @@ -3759,31 +2878,6 @@ dependencies = [ "memoffset", ] -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nix" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nom" version = "7.1.3" @@ -3794,32 +2888,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags 2.11.0", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3829,30 +2897,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nucleo-matcher" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" -dependencies = [ - "memchr", - "unicode-segmentation", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - [[package]] name = "num-bigint" version = "0.4.6" @@ -3879,15 +2923,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.1" @@ -3925,17 +2960,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3987,66 +3011,45 @@ dependencies = [ ] [[package]] -name = "oci-client" -version = "0.16.1" +name = "octocrab" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b7f8deaffcd3b0e3baf93dddcab3d18b91d46dc37d38a8b170089b234de5bb3" +checksum = "fe45bd53ce50c9e85e8a27259675f65d772165fe5eef3e278c1b6168c3f97623" dependencies = [ + "arc-swap", + "async-trait", + "base64 0.22.1", "bytes", + "cargo_metadata", + "cfg-if", "chrono", + "futures", + "futures-core", "futures-util", - "http", - "http-auth", + "getrandom 0.2.17", + "http 1.4.0", + "http-body", + "http-body-util", + "http-serde", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", "jsonwebtoken", - "lazy_static", - "oci-spec", - "olpc-cjson", - "regex", - "reqwest 0.13.2", + "percent-encoding", + "pin-project", + "secrecy", "serde", "serde_json", - "sha2 0.10.9", - "thiserror 2.0.18", + "serde_path_to_error", + "serde_urlencoded", + "snafu", "tokio", + "tower", + "tower-http", "tracing", - "unicase", -] - -[[package]] -name = "oci-spec" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8445a2631507cec628a15fdd6154b54a3ab3f20ed4fe9d73a3b8b7a4e1ba03a" -dependencies = [ - "const_format", - "derive_builder", - "getset", - "regex", - "serde", - "serde_json", - "strum 0.27.2", - "strum_macros", - "thiserror 2.0.18", -] - -[[package]] -name = "oid-registry" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "olpc-cjson" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083" -dependencies = [ - "serde", - "serde_json", - "unicode-normalization", + "url", + "web-time", ] [[package]] @@ -4065,6 +3068,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.5" @@ -4082,7 +3091,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" dependencies = [ - "indexmap 2.13.0", + "indexmap", "serde", "serde_json", ] @@ -4112,6 +3121,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4131,52 +3146,74 @@ dependencies = [ ] [[package]] -name = "option-ext" -version = "0.2.0" +name = "opentelemetry" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", + "tracing", +] [[package]] -name = "ordered-float" -version = "4.6.0" +name = "opentelemetry-otlp" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ - "num-traits", + "http 1.4.0", + "opentelemetry", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "thiserror 2.0.18", + "tokio", + "tonic", + "tonic-types", ] [[package]] -name = "ordered-stream" -version = "0.2.0" +name = "opentelemetry-proto" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ - "futures-core", - "pin-project-lite", + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", ] [[package]] -name = "ouroboros" -version = "0.18.5" +name = "opentelemetry_sdk" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", ] [[package]] -name = "ouroboros_macro" -version = "0.18.5" +name = "ordered-float" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ - "heck 0.4.1", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.117", + "num-traits", ] [[package]] @@ -4186,13 +3223,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] -name = "page_size" -version = "0.6.0" +name = "p256" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "libc", - "winapi", + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", ] [[package]] @@ -4230,23 +3281,28 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] -[[package]] -name = "pastey" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" - [[package]] name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb" +dependencies = [ + "base64 0.13.1", + "once_cell", + "regex", +] + [[package]] name = "pem" version = "3.0.6" @@ -4312,17 +3368,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2 0.10.9", -] - -[[package]] -name = "petgraph" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" -dependencies = [ - "fixedbitset 0.5.7", - "indexmap 2.13.0", + "sha2", ] [[package]] @@ -4403,26 +3449,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkcs1" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -4431,21 +3466,25 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", ] [[package]] -name = "pkg-config" -version = "0.3.32" +name = "pkcs8" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", +] [[package]] -name = "plain" -version = "0.2.3" +name = "pkg-config" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "polling" @@ -4461,6 +3500,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -4502,12 +3553,12 @@ dependencies = [ ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "primeorder" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "toml_edit 0.25.10+spec-1.1.0", + "elliptic-curve", ] [[package]] @@ -4541,33 +3592,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "version_check", - "yansi", -] - -[[package]] -name = "process-wrap" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" -dependencies = [ - "futures", - "indexmap 2.13.0", - "nix 0.31.2", - "tokio", - "tracing", - "windows", -] - [[package]] name = "progenitor" version = "0.10.0" @@ -4588,7 +3612,7 @@ dependencies = [ "bytes", "futures-core", "percent-encoding", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", "serde_urlencoded", @@ -4600,14 +3624,14 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b17e5363daa50bf1cccfade6b0fb970d2278758fd5cfa9ab69f25028e4b1afa3" dependencies = [ - "heck 0.5.0", - "http", - "indexmap 2.13.0", + "heck", + "http 1.4.0", + "indexmap", "openapiv3", "proc-macro2", "quote", "regex", - "schemars 0.8.22", + "schemars", "serde", "serde_json", "syn 2.0.117", @@ -4626,7 +3650,7 @@ dependencies = [ "proc-macro2", "progenitor-impl", "quote", - "schemars 0.8.22", + "schemars", "serde", "serde_json", "serde_tokenstream", @@ -4636,39 +3660,19 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.5" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", ] -[[package]] -name = "prost-build" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" -dependencies = [ - "heck 0.5.0", - "itertools", - "log", - "multimap", - "once_cell", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn 2.0.117", - "tempfile", -] - [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools", @@ -4679,9 +3683,9 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.13.5" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] @@ -4718,7 +3722,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -4731,7 +3735,6 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ - "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -4756,7 +3759,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -4782,16 +3785,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radix_trie" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" -dependencies = [ - "endian-type", - "nibble_vec", -] - [[package]] name = "rand" version = "0.8.5" @@ -4813,17 +3806,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -4895,7 +3877,7 @@ dependencies = [ "itertools", "kasuari", "lru", - "strum 0.27.2", + "strum", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -4947,96 +3929,30 @@ dependencies = [ "itertools", "line-clipping", "ratatui-core", - "strum 0.27.2", + "strum", "time", "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "rcgen" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", + "unicode-width", ] [[package]] -name = "ref-cast-impl" -version = "1.0.25" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "bitflags 2.11.0", ] [[package]] -name = "reflink-copy" -version = "0.1.29" +name = "redox_users" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "cfg-if", - "libc", - "rustix", - "windows", + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", ] [[package]] @@ -5092,7 +4008,7 @@ dependencies = [ "futures-util", "h2", "hickory-resolver", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -5117,59 +4033,15 @@ dependencies = [ "tokio-native-tls", "tokio-rustls", "tokio-util", - "tower 0.5.3", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", - "webpki-roots 1.0.6", -] - -[[package]] -name = "reqwest" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" -dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower 0.5.3", + "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -5178,6 +4050,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -5192,43 +4074,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rmcp" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f542f74cf247da16f19bbc87e298cd201e912314f4083e88cdd671f44f5fcb53" -dependencies = [ - "async-trait", - "base64 0.22.1", - "chrono", - "futures", - "pastey", - "pin-project-lite", - "process-wrap", - "rmcp-macros", - "schemars 1.2.1", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", -] - -[[package]] -name = "rmcp-macros" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2391e4ae47f314e70eaafb6c7bd82e495e770b935448864446302143019151f" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "serde_json", - "syn 2.0.117", -] - [[package]] name = "rsa" version = "0.9.10" @@ -5241,14 +4086,29 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "sha2", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -5279,7 +4139,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2 0.10.9", + "sha2", "walkdir", ] @@ -5298,15 +4158,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - [[package]] name = "rustix" version = "1.1.4" @@ -5326,7 +4177,6 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -5342,19 +4192,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework 3.7.0", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", + "security-framework", ] [[package]] @@ -5367,40 +4208,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni 0.21.1", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework 3.7.0", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5444,26 +4257,12 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "chrono", "dyn-clone", - "schemars_derive 0.8.22", + "schemars_derive", "serde", "serde_json", "uuid", ] -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "chrono", - "dyn-clone", - "ref-cast", - "schemars_derive 1.2.1", - "serde", - "serde_json", -] - [[package]] name = "schemars_derive" version = "0.8.22" @@ -5476,18 +4275,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "schemars_derive" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -5495,179 +4282,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "sea-bae" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" -dependencies = [ - "heck 0.4.1", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "sea-orm" -version = "1.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957" -dependencies = [ - "async-stream", - "async-trait", - "chrono", - "derive_more", - "futures-util", - "log", - "ouroboros", - "sea-orm-macros", - "sea-query", - "sea-query-binder", - "serde", - "sqlx", - "strum 0.26.3", - "thiserror 2.0.18", - "tracing", - "url", -] - -[[package]] -name = "sea-orm-cli" -version = "1.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da80ebcdb44571e86f03a2bdcb5532136a87397f366f38bbce64673fc5e6a450" -dependencies = [ - "chrono", - "glob", - "regex", - "sea-schema", - "sqlx", - "tokio", - "tracing", - "tracing-subscriber", - "url", -] - -[[package]] -name = "sea-orm-macros" -version = "1.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b9a3f90e336ec74803e8eb98c61bc98754c1adfba3b4f84d946237b752b1c88" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "sea-bae", - "syn 2.0.117", - "unicode-ident", -] - -[[package]] -name = "sea-orm-migration" -version = "1.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c577f2959277e936c1d08109acd1e08fc36a95ef29ec028190ba82cad8f96e" -dependencies = [ - "async-trait", - "sea-orm", - "sea-orm-cli", - "sea-schema", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "sea-query" -version = "0.32.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" -dependencies = [ - "chrono", - "inherent", - "ordered-float", - "sea-query-derive", -] - -[[package]] -name = "sea-query-binder" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" -dependencies = [ - "chrono", - "sea-query", - "sqlx", -] - -[[package]] -name = "sea-query-derive" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" -dependencies = [ - "darling 0.20.11", - "heck 0.4.1", - "proc-macro2", - "quote", - "syn 2.0.117", - "thiserror 2.0.18", -] - -[[package]] -name = "sea-schema" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2239ff574c04858ca77485f112afea1a15e53135d3097d0c86509cef1def1338" -dependencies = [ - "futures", - "sea-query", - "sea-query-binder", - "sea-schema-derive", - "sqlx", -] - -[[package]] -name = "sea-schema-derive" -version = "0.3.0" +name = "sec1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "syn 2.0.117", + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", ] [[package]] -name = "secret-service" -version = "4.0.0" +name = "sec1_decode" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" -dependencies = [ - "aes", - "cbc", - "futures-util", - "generic-array", - "hkdf", - "num", - "once_cell", - "rand 0.8.5", - "serde", - "sha2 0.10.9", - "zbus", +checksum = "b6326ddc956378a0739200b2c30892dccaf198992dfd7323274690b9e188af23" +dependencies = [ + "der 0.4.5", + "pem 0.8.3", + "thiserror 1.0.69", ] [[package]] -name = "security-framework" -version = "2.11.1" +name = "secrecy" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", + "zeroize", ] [[package]] @@ -5713,16 +4358,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -5778,17 +4413,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -5828,7 +4452,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.13.0", + "indexmap", "itoa", "ryu", "serde", @@ -5841,7 +4465,7 @@ version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ - "indexmap 2.13.0", + "indexmap", "itoa", "libyml", "memchr", @@ -5873,14 +4497,13 @@ dependencies = [ ] [[package]] -name = "sha2" +name = "sha3" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", + "keccak", ] [[package]] @@ -5946,10 +4569,14 @@ dependencies = [ ] [[package]] -name = "simd-adler32" -version = "0.3.9" +name = "signature" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] [[package]] name = "simd_cesu8" @@ -5973,6 +4600,18 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -5986,27 +4625,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "smallvec" -version = "1.15.1" +name = "sluice" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "160b744a45e8261307bcfe03c98e2f8274502207d534c9a64b675c4db1b6bd58" dependencies = [ - "serde", + "async-channel", + "futures-core", + "futures-io", ] [[package]] -name = "smoltcp" -version = "0.13.0" +name = "smallvec" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac729b0a77bd092a3f06ddaddc59fe0d67f48ba0de45a9abe707c2842c7f8767" -dependencies = [ - "bitflags 1.3.2", - "byteorder", - "cfg-if", - "defmt 0.3.100", - "heapless", - "managed", -] +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smooai-client-shared" @@ -6019,10 +4652,10 @@ dependencies = [ "chrono", "dirs-next", "rand 0.8.5", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "tokio", "url", "webbrowser", @@ -6044,7 +4677,7 @@ dependencies = [ "progenitor-client", "rand 0.8.5", "regress", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", "syn 2.0.117", @@ -6056,120 +4689,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "smooai-smooth-archivist" -version = "0.14.1" -dependencies = [ - "anyhow", - "axum 0.8.8", - "chrono", - "futures-util", - "http-body-util", - "hyper", - "serde", - "serde_json", - "smooai-smooth-scribe", - "tokio", - "tower 0.5.3", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "smooai-smooth-bench" -version = "0.14.1" -dependencies = [ - "anyhow", - "async-trait", - "axum 0.8.8", - "chrono", - "clap", - "dirs-next", - "reqwest 0.12.28", - "serde", - "serde_json", - "smooai-smooth-cast", - "smooai-smooth-code", - "smooai-smooth-operator-core", - "smooai-smooth-pearls", - "tempfile", - "tokio", - "toml", - "tracing", - "uuid", -] - -[[package]] -name = "smooai-smooth-bigsmooth" -version = "0.14.1" -dependencies = [ - "anyhow", - "async-trait", - "axum 0.8.8", - "axum-extra", - "chrono", - "dirs-next", - "futures-core", - "futures-util", - "globwalk", - "http-body-util", - "hyper", - "hyper-util", - "ignore", - "prost", - "prost-types", - "reqwest 0.12.28", - "serde", - "serde_json", - "smooai-smooth-archivist", - "smooai-smooth-bootstrap-bill", - "smooai-smooth-cast", - "smooai-smooth-code", - "smooai-smooth-diver", - "smooai-smooth-goalie", - "smooai-smooth-narc", - "smooai-smooth-operator-core", - "smooai-smooth-pearls", - "smooai-smooth-policy", - "smooai-smooth-scribe", - "smooai-smooth-web", - "smooai-smooth-wonk", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-tungstenite 0.26.2", - "toml", - "tonic", - "tonic-build", - "tower 0.5.3", - "tower-http", - "tracing", - "tracing-appender", - "tracing-subscriber", - "urlencoding", - "uuid", -] - -[[package]] -name = "smooai-smooth-bootstrap-bill" -version = "0.14.1" -dependencies = [ - "anyhow", - "chrono", - "clap", - "dirs-next", - "microsandbox", - "serde", - "serde_json", - "tempfile", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "smooai-smooth-cast" version = "0.14.1" @@ -6203,22 +4722,18 @@ dependencies = [ "open", "owo-colors", "rand 0.8.5", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "smooai-client-shared", "smooai-smooth-api-client", - "smooai-smooth-bench", - "smooai-smooth-bigsmooth", - "smooai-smooth-bootstrap-bill", "smooai-smooth-cast", "smooai-smooth-code", "smooai-smooth-diver", "smooai-smooth-operator-core", "smooai-smooth-pearls", - "smooai-smooth-tunnel", - "smooai-smooth-web", + "smooai-smooth-policy", "tabled", "tempfile", "tiny_http", @@ -6237,7 +4752,6 @@ version = "0.14.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.8", "chrono", "crossterm", "dirs-next", @@ -6246,32 +4760,57 @@ dependencies = [ "ignore", "pulldown-cmark", "ratatui", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", "similar", - "smooai-smooth-bigsmooth", "smooai-smooth-cast", - "smooai-smooth-narc", "smooai-smooth-operator-core", "smooai-smooth-pearls", "tempfile", "tokio", - "tokio-stream", "tokio-tungstenite 0.26.2", "tracing", "uuid", ] [[package]] -name = "smooai-smooth-credential-helper" +name = "smooai-smooth-daemon" version = "0.14.1" dependencies = [ "anyhow", - "reqwest 0.12.28", + "async-trait", + "axum 0.8.8", + "base64 0.22.1", + "chrono", + "clap", + "dirs-next", + "futures-util", + "http-body-util", + "rand 0.8.5", + "reqwest", + "rusqlite", "serde", "serde_json", + "sha2", + "smooai-client-shared", + "smooai-smooth-goalie", + "smooai-smooth-operator", + "smooai-smooth-operator-core", + "smooai-smooth-operator-server", + "smooai-smooth-policy", + "smooai-smooth-tools", + "smooai-smooth-web", + "tempfile", + "thiserror 2.0.18", "tokio", + "tokio-stream", + "tokio-tungstenite 0.26.2", + "tower", + "tracing", + "tracing-subscriber", + "uuid", + "web-push", ] [[package]] @@ -6283,13 +4822,13 @@ dependencies = [ "chrono", "http-body-util", "hyper", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", "smooai-smooth-pearls", "tempfile", "tokio", - "tower 0.5.3", + "tower", "tracing", "uuid", ] @@ -6305,7 +4844,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", "smooai-smooth-policy", @@ -6316,112 +4855,112 @@ dependencies = [ ] [[package]] -name = "smooai-smooth-host-stub" -version = "0.14.1" +name = "smooai-smooth-operator" +version = "1.22.6" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "chrono", - "glob", - "hyper-util", - "prost", - "prost-types", + "hex", + "hmac", + "jsonwebtoken", + "octocrab", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", + "reqwest", + "rustls", "serde", "serde_json", - "tempfile", - "thiserror 2.0.18", + "sha2", + "smooai-smooth-operator-core", "tokio", - "tokio-stream", - "tonic", - "tonic-build", - "tower 0.5.3", "tracing", + "tracing-opentelemetry", "tracing-subscriber", "url", + "uuid", ] [[package]] -name = "smooai-smooth-narc" -version = "0.14.1" +name = "smooai-smooth-operator-adapter-memory" +version = "1.22.6" dependencies = [ "anyhow", "async-trait", "chrono", - "hyper-util", - "prost", - "prost-types", + "smooai-smooth-operator", + "smooai-smooth-operator-core", +] + +[[package]] +name = "smooai-smooth-operator-core" +version = "0.16.2" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "dirs-next", + "futures-core", + "futures-util", "regex", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", - "smooai-smooth-operator-core", - "tempfile", + "serde_yml", + "sha2", + "thiserror 2.0.18", "tokio", "tokio-stream", - "tonic", - "tonic-build", - "tower 0.5.3", + "tokio-tungstenite 0.26.2", + "toml", "tracing", "uuid", ] [[package]] -name = "smooai-smooth-operative" -version = "0.14.1" +name = "smooai-smooth-operator-ingestion" +version = "1.22.6" dependencies = [ "anyhow", "async-trait", - "axum 0.8.8", - "dirs-next", - "globset", - "grep-regex", - "grep-searcher", - "ignore", - "lsp-types", - "nucleo-matcher", - "reqwest 0.12.28", - "rmcp", + "base64 0.22.1", + "chrono", + "jsonwebtoken", + "octocrab", + "reqwest", + "rustls", "serde", "serde_json", - "similar", - "smooai-smooth-cast", - "smooai-smooth-goalie", - "smooai-smooth-narc", + "smooai-smooth-operator", "smooai-smooth-operator-core", - "smooai-smooth-pearls", - "smooai-smooth-policy", - "smooai-smooth-scribe", - "smooai-smooth-wonk", - "tempfile", "tokio", - "toml", - "tracing", - "tracing-subscriber", "uuid", ] [[package]] -name = "smooai-smooth-operator-core" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60cac82deb3b84139783af4c3494717d184e1f9c5372945462191053c37dea39" +name = "smooai-smooth-operator-server" +version = "1.22.6" dependencies = [ "anyhow", "async-trait", - "bytes", + "axum 0.8.8", "chrono", - "dirs-next", - "futures-core", "futures-util", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", - "serde_yml", - "thiserror 2.0.18", + "smooai-smooth-operator", + "smooai-smooth-operator-adapter-memory", + "smooai-smooth-operator-core", + "smooai-smooth-operator-ingestion", "tokio", - "tokio-stream", - "tokio-tungstenite 0.26.2", + "tokio-util", + "tower-http", "tracing", + "tracing-subscriber", "uuid", ] @@ -6462,57 +5001,35 @@ dependencies = [ name = "smooai-smooth-policy" version = "0.14.1" dependencies = [ + "anyhow", "chrono", + "dirs-next", "globset", "serde", + "serde_json", + "sha2", "tempfile", "thiserror 2.0.18", "toml", ] [[package]] -name = "smooai-smooth-scribe" +name = "smooai-smooth-tools" version = "0.14.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.8", - "chrono", - "futures-util", - "http-body-util", - "hyper", - "hyper-util", - "prost", - "prost-types", - "reqwest 0.12.28", - "serde", + "dirs-next", + "globset", + "grep-regex", + "grep-searcher", + "ignore", "serde_json", "smooai-smooth-operator-core", + "smooai-smooth-policy", "tempfile", "tokio", - "tokio-stream", - "tonic", - "tonic-build", - "tower 0.5.3", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "smooai-smooth-tunnel" -version = "0.14.1" -dependencies = [ - "anyhow", - "futures-util", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite 0.26.2", "tracing", - "url", - "uuid", ] [[package]] @@ -6521,51 +5038,33 @@ version = "0.14.1" dependencies = [ "axum 0.8.8", "axum-extra", + "http-body-util", "mime_guess", "rust-embed", - "tower 0.5.3", + "serde_json", + "tokio", + "tower", ] [[package]] -name = "smooai-smooth-wonk" -version = "0.14.1" +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" dependencies = [ - "anyhow", - "arc-swap", - "async-trait", - "axum 0.8.8", - "chrono", - "clap", - "http-body-util", - "hyper", - "hyper-util", - "notify", - "prost", - "prost-types", - "reqwest 0.12.28", - "serde", - "serde_json", - "smooai-smooth-narc", - "smooai-smooth-operator-core", - "smooai-smooth-policy", - "tempfile", - "tokio", - "tokio-stream", - "tonic", - "tonic-build", - "tower 0.5.3", - "tracing", - "tracing-subscriber", + "snafu-derive", ] [[package]] -name = "socket2" -version = "0.5.10" +name = "snafu-derive" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "libc", - "windows-sys 0.52.0", + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -6583,9 +5082,6 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] [[package]] name = "spki" @@ -6594,201 +5090,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64 0.22.1", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap 2.13.0", - "log", - "memchr", - "once_cell", - "percent-encoding", - "rustls", - "serde", - "serde_json", - "sha2 0.10.9", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.117", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck 0.5.0", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2 0.10.9", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.117", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64 0.22.1", - "bitflags 2.11.0", - "byteorder", - "bytes", - "chrono", - "crc", - "digest 0.10.7", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.5", - "rsa", - "serde", - "sha1", - "sha2 0.10.9", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.18", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64 0.22.1", - "bitflags 2.11.0", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.5", - "serde", - "serde_json", - "sha2 0.10.9", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.18", - "tracing", - "whoami", + "der 0.7.10", ] [[package]] -name = "sqlx-sqlite" -version = "0.8.6" +name = "spki" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror 2.0.18", - "tracing", - "url", + "base64ct", + "der 0.8.0", ] [[package]] @@ -6803,29 +5115,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" - [[package]] name = "strum" version = "0.27.2" @@ -6841,7 +5136,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -6853,6 +5148,22 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "superboring" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efc6310a69b44420f3bf53d518077615b7d466cc57df7a80e404e7feb8c510f7" +dependencies = [ + "aes-gcm", + "aes-keywrap", + "getrandom 0.2.17", + "hmac-sha256", + "hmac-sha512", + "ml-dsa", + "rand 0.8.5", + "rsa", +] + [[package]] name = "syn" version = "1.0.109" @@ -6884,6 +5195,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -6932,7 +5255,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d9946811baad81710ec921809e2af67ad77719418673b2a3794932d57b7538" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro-error2", "proc-macro2", "quote", @@ -6945,17 +5268,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "tar" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -7002,20 +5314,20 @@ dependencies = [ "fancy-regex", "filedescriptor", "finl_unicode", - "fixedbitset 0.4.2", + "fixedbitset", "hex", "lazy_static", "libc", "log", "memmem", - "nix 0.29.0", + "nix", "num-derive", "num-traits", "ordered-float", "pest", "pest_derive", "phf", - "sha2 0.10.9", + "sha2", "signal-hook", "siphasher", "terminfo", @@ -7163,7 +5475,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -7256,26 +5568,17 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", + "toml_datetime", + "toml_edit", ] [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ - "serde_core", + "serde", ] [[package]] @@ -7284,33 +5587,12 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.13.0", + "indexmap", "serde", "serde_spanned", - "toml_datetime 0.6.11", + "toml_datetime", "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.10+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" -dependencies = [ - "indexmap 2.13.0", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.1", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.1", + "winnow", ] [[package]] @@ -7321,17 +5603,14 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tonic" -version = "0.12.3" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ - "async-stream", "async-trait", - "axum 0.7.9", "base64 0.22.1", "bytes", - "h2", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -7339,48 +5618,35 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", - "socket2 0.5.10", + "sync_wrapper", "tokio", "tokio-stream", - "tower 0.4.13", + "tower", "tower-layer", "tower-service", "tracing", ] [[package]] -name = "tonic-build" -version = "0.12.3" +name = "tonic-prost" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build", - "prost-types", - "quote", - "syn 2.0.117", + "bytes", + "prost", + "tonic", ] [[package]] -name = "tower" -version = "0.4.13" +name = "tonic-types" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand 0.8.5", - "slab", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", + "prost", + "prost-types", + "tonic", ] [[package]] @@ -7391,9 +5657,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -7407,21 +5676,12 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.11.0", "bytes", - "futures-core", "futures-util", - "http", + "http 1.4.0", "http-body", - "http-body-util", - "http-range-header", - "httpdate", "iri-string", - "mime", - "mime_guess", - "percent-encoding", "pin-project-lite", - "tokio", - "tokio-util", - "tower 0.5.3", + "tower", "tower-layer", "tower-service", "tracing", @@ -7451,18 +5711,6 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-appender" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" -dependencies = [ - "crossbeam-channel", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - [[package]] name = "tracing-attributes" version = "0.1.31" @@ -7484,6 +5732,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -7495,6 +5753,22 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -7527,7 +5801,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", @@ -7544,7 +5818,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", @@ -7553,31 +5827,11 @@ dependencies = [ "utf-8", ] -[[package]] -name = "typed-builder" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" -dependencies = [ - "typed-builder-macro", -] - -[[package]] -name = "typed-builder-macro" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typify" @@ -7595,12 +5849,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" dependencies = [ - "heck 0.5.0", + "heck", "log", "proc-macro2", "quote", "regress", - "schemars 0.8.22", + "schemars", "semver", "serde", "serde_json", @@ -7617,7 +5871,7 @@ checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" dependencies = [ "proc-macro2", "quote", - "schemars 0.8.22", + "schemars", "semver", "serde", "serde_json", @@ -7632,50 +5886,18 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - [[package]] name = "unicase" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -7705,6 +5927,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -7717,42 +5949,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "ureq-proto", - "utf8-zero", - "webpki-roots 1.0.6", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64 0.22.1", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -7763,6 +5959,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -7777,12 +5974,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -7826,55 +6017,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "virtio-bindings" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091f1f09cfbf2a78563b562e7a949465cce1aef63b6065645188d995162f8868" - -[[package]] -name = "virtue" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" - -[[package]] -name = "vm-fdt" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e21282841a059bb62627ce8441c491f09603622cd5a21c43bfedc85a2952f23" - -[[package]] -name = "vm-memory" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd5e56d48353c5f54ef50bd158a0452fc82f5383da840f7b8efc31695dd3b9d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "vm-memory" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b55e753c7725603745cb32b2287ef7ef3da05c03c7702cda3fa8abe25ae0465" -dependencies = [ - "libc", - "thiserror 2.0.18", -] - -[[package]] -name = "vmm-sys-util" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "506c62fdf617a5176827c2f9afbcf1be155b03a9b4bf9617a60dbc07e3a1642f" -dependencies = [ - "bitflags 1.3.2", - "libc", -] - [[package]] name = "vtparse" version = "0.6.2" @@ -7884,6 +6026,12 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "walkdir" version = "2.5.0" @@ -7928,10 +6076,13 @@ dependencies = [ ] [[package]] -name = "wasite" -version = "0.1.0" +name = "wasix" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +checksum = "ae86f02046da16a333a9129d31451423e1657737ecdafed4193838a5f54c5cfe" +dependencies = [ + "wasi", +] [[package]] name = "wasm-bindgen" @@ -8005,7 +6156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap", "wasm-encoder", "wasmparser", ] @@ -8023,19 +6174,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasmparser" version = "0.244.0" @@ -8044,10 +6182,32 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap", "semver", ] +[[package]] +name = "web-push" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5c305b9ee2993ab68b7744b13ef32231d83600dd879ac8183b4c76ae31d28ac" +dependencies = [ + "async-trait", + "chrono", + "ct-codecs", + "ece", + "futures-lite", + "http 0.2.12", + "isahc", + "jwt-simple", + "log", + "pem 3.0.6", + "sec1_decode", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "web-sys" version = "0.3.94" @@ -8065,6 +6225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] @@ -8075,7 +6236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" dependencies = [ "core-foundation 0.10.1", - "jni 0.22.4", + "jni", "log", "ndk-context", "objc2", @@ -8084,24 +6245,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - [[package]] name = "webpki-roots" version = "1.0.6" @@ -8129,7 +6272,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2 0.10.9", + "sha2", "thiserror 1.0.69", "uuid", ] @@ -8167,39 +6310,20 @@ checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - -[[package]] -name = "which" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" -dependencies = [ - "libc", + "syn 1.0.109", ] [[package]] -name = "whoami" -version = "1.6.1" +name = "wezterm-input-types" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" dependencies = [ - "libredox", - "wasite", + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", ] [[package]] @@ -8239,27 +6363,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -8273,17 +6376,6 @@ dependencies = [ "windows-strings", ] -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -8312,16 +6404,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - [[package]] name = "windows-registry" version = "0.6.1" @@ -8351,24 +6433,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -8405,36 +6469,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -8468,27 +6502,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8501,18 +6514,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8525,18 +6526,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8561,18 +6550,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8585,18 +6562,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8609,18 +6574,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8633,18 +6586,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8666,15 +6607,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -8691,7 +6623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -8702,8 +6634,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", - "indexmap 2.13.0", + "heck", + "indexmap", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -8734,7 +6666,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap 2.13.0", + "indexmap", "log", "serde", "serde_derive", @@ -8753,7 +6685,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap", "log", "semver", "serde", @@ -8769,59 +6701,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "x509-parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "ring", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "xdg-home" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yasna" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" -dependencies = [ - "time", -] - [[package]] name = "yoke" version = "0.8.1" @@ -8842,63 +6721,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zbus" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" -dependencies = [ - "async-broadcast", - "async-process", - "async-recursion", - "async-trait", - "enumflags2", - "event-listener", - "futures-core", - "futures-sink", - "futures-util", - "hex", - "nix 0.29.0", - "ordered-stream", - "rand 0.8.5", - "serde", - "serde_repr", - "sha1", - "static_assertions", - "tracing", - "uds_windows", - "windows-sys 0.52.0", - "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" -dependencies = [ - "serde", - "static_assertions", - "zvariant", + "synstructure 0.13.2", ] [[package]] @@ -8939,7 +6762,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -8947,20 +6770,6 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] [[package]] name = "zerotrie" @@ -9000,68 +6809,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "zvariant" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" -dependencies = [ - "endi", - "enumflags2", - "serde", - "static_assertions", - "zvariant_derive", -] - -[[package]] -name = "zvariant_derive" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] diff --git a/Cargo.toml b/Cargo.toml index d5755c44..19726fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = ["crates/*"] -exclude = ["crates/smooth-bigsmooth/tests/fixtures"] [workspace.package] version = "0.14.1" @@ -193,26 +192,49 @@ microsandbox = "0.4" # discovery, and the fixer/oracle/chief/intent_classifier cast roles) now live # in the smooth-owned `smooth-cast` crate below, built on the engine's generic # public API. -smooth-operator = { version = "0.14.0", package = "smooai-smooth-operator-core" } +# +# th-f30175 (EPIC th-c89c2a, smooth-daemon rewrite): VENDORED to a local path +# dep on the sibling `smooth-operator-core` checkout so we can patch the engine +# (configurable streaming timeouts, AUTO_CONTEXT_LIMIT, Context-Epoch prompt +# stability, first-class Dolt Memory backend) during the always-on-daemon +# rewrite. Relative path resolves across sibling worktrees (smooth-* and +# smooth-operator-core both live under ~/dev/smooai/). +# +# ⚠️ CI CAVEAT (learned from the client-shared dep below, lines ~217–226): a +# local `path =` dep ONLY resolves on a dev laptop and breaks every CI release +# ("failed to load manifest for workspace member"). Before this branch merges to +# main, this MUST be converted to a git-rev dep on smooth-operator-core (mirror +# the smooai-client-shared pattern) — or the engine patches upstreamed and this +# reverted to a crates.io version bump. Tracked under EPIC th-c89c2a. +smooth-operator = { path = "../smooth-operator-core/rust/smooth-operator-core", package = "smooai-smooth-operator-core" } +# The OPERATOR (the system on the engine), local deployment flavor — embedded +# in-process by the daemon (EPIC th-c89c2a). `default-features = false` drops ALL +# cloud adapters (AWS SDK / tokio-postgres / redis / nats); the local flavor runs +# entirely on in-memory storage + backplane. Distinct dep keys because the +# service lib's lib-name is also `smooth_operator` (would collide with the engine +# alias above) — so it's imported here as `smooth_operator_svc`. +# The local-flavor seams (LocalTokenVerifier, LocalServerBuilder::auth/tools/ +# serve_widget, widget serving) landed on smooth-operator `main` (#108), so this +# now path-deps the canonical `../smooth-operator` checkout — same local-dev +# two-repo path pattern as the engine dep above. Full CI-mergeability still needs +# git-rev/published deps for BOTH the engine and the operator (none are published +# yet); tracked under th-d3537a. +smooth-operator-server = { path = "../smooth-operator-hardening/rust/smooth-operator-server", package = "smooai-smooth-operator-server", default-features = false } +smooth-operator-svc = { path = "../smooth-operator-hardening/rust/smooth-operator", package = "smooai-smooth-operator", default-features = false } # smooth-owned coding-harness extensions to the generic engine (re-homed from # the engine when it went generic at 0.14.0). See crates/smooth-cast. smooth-cast = { version = "0.14.1", path = "crates/smooth-cast", package = "smooai-smooth-cast" } -smooth-bigsmooth = { version = "0.14.1", path = "crates/smooth-bigsmooth", package = "smooai-smooth-bigsmooth" } smooth-policy = { path = "crates/smooth-policy", version = "0.14.1", package = "smooai-smooth-policy" } smooth-web = { version = "0.14.1", path = "crates/smooth-web", package = "smooai-smooth-web" } -smooth-scribe = { version = "0.14.1", path = "crates/smooth-scribe", package = "smooai-smooth-scribe" } -smooth-wonk = { version = "0.14.1", path = "crates/smooth-wonk", package = "smooai-smooth-wonk" } smooth-goalie = { version = "0.14.1", path = "crates/smooth-goalie", package = "smooai-smooth-goalie" } -smooth-narc = { version = "0.14.1", path = "crates/smooth-narc", package = "smooai-smooth-narc" } -smooth-archivist = { version = "0.14.1", path = "crates/smooth-archivist", package = "smooai-smooth-archivist" } smooth-plugin = { version = "0.14.1", path = "crates/smooth-plugin", package = "smooai-smooth-plugin" } smooth-code = { version = "0.14.1", path = "crates/smooth-code", package = "smooai-smooth-code" } +# th-ed6604: reusable agent tools (fs/grep/bash) for the smooth-daemon (EPIC +# th-c89c2a). New clean impls; smooth-operative may migrate onto this later. +smooth-tools = { version = "0.14.1", path = "crates/smooth-tools", package = "smooai-smooth-tools" } +smooth-daemon = { version = "0.14.1", path = "crates/smooth-daemon", package = "smooai-smooth-daemon" } smooth-pearls = { path = "crates/smooth-pearls", version = "0.14.1", package = "smooai-smooth-pearls" } smooth-diver = { version = "0.14.1", path = "crates/smooth-diver", package = "smooai-smooth-diver" } -smooth-bootstrap-bill = { version = "0.14.1", path = "crates/smooth-bootstrap-bill", default-features = false, package = "smooai-smooth-bootstrap-bill" } -smooth-tunnel = { version = "0.14.1", path = "crates/smooth-tunnel", package = "smooai-smooth-tunnel" } -smooth-host-stub = { version = "0.14.1", path = "crates/smooth-host-stub", package = "smooai-smooth-host-stub" } -smooth-bench = { version = "0.14.1", path = "crates/smooth-bench", package = "smooai-smooth-bench" } smooth-api-client = { version = "0.14.1", path = "crates/smooth-api-client", package = "smooai-smooth-api-client" } # Cross-runtime client shared library (github.com/SmooAI/client-shared). # SMOODEV-1464: switched from a local `path = "../client-shared/rust"` dep to diff --git a/README.md b/README.md index 251919df..cd269716 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- smooth — Coordinate teams of AI agents. One binary. + th — the single-binary Smoo AI CLI, home of Big Smooth

@@ -14,26 +14,67 @@

- Install  ·  Quick Start  ·  What is Smooth  ·  Architecture  ·  CLI  ·  Platform + Install  ·  + The toolkit  ·  + Big Smooth  ·  + Orchestration  ·  + Get started

--- -> Smooth is the central CLI and orchestration platform for Smoo AI. It dispatches teams of AI agents — Smooth operatives — to work on real projects inside hardware-isolated microVMs, with adversarial surveillance and policy-gated access control. No Docker. No Node.js. No runtime dependencies. One 10MB binary. +## `th` is the single binary that runs your whole AI-agent workflow. + +One ~10MB Rust binary. **Zero runtime dependencies** — no Docker, no Node, no +Python. `th` gives you web search, your org's knowledge base, web crawling, a +coding TUI, and a shared work tracker from the terminal — and it's the engine +behind **Big Smooth**, the always-on AI agent you keep running on your machine. + +```bash +brew install SmooAI/tools/th +``` + +If you write code with an AI assistant, `th` is the missing operating layer: +the primitives an agent needs (search, retrieval, crawl, memory, messaging) as +first-class CLI commands, plus a persistent agent that uses all of them for you. + +--- + +## It scales past your laptop + +`th` isn't just a local binary — it plugs into Smoo AI's cloud. + +- **☁️ Cloud scale.** Your agents don't have to live on one machine. The same + agent engine that powers your personal Big Smooth also runs **hosted org + agents in Smoo AI's cloud** (`api.smoo.ai`), fronted by a gateway + (`llm.smoo.ai`) that gives one key access to every major model. Personal agent + on your laptop, fleet in the cloud — same engine, same tools. +- **🧩 Cloud marketplace.** Add new capabilities to your agents without + rebuilding anything. `th ext search` browses the **extension marketplace** — a + curated index plus community extensions tagged for Smooth — and `th ext install` + drops one in. Big Smooth loads installed extensions per turn, so a new tool is + live on the next message. + +```bash +th ext search browser # find extensions (curated + community) +th ext install npm:@scope/pkg # install one (local dir, npm:, or git:) +``` --- ## Install -### Homebrew (recommended, macOS + Linux) +### Homebrew (recommended — macOS + Linux) ```bash brew install SmooAI/tools/th ``` -That taps [SmooAI/homebrew-tools](https://github.com/SmooAI/homebrew-tools) and installs the `th` binary on first use; `brew upgrade th` picks up future releases automatically. +That taps [SmooAI/homebrew-tools](https://github.com/SmooAI/homebrew-tools) and +installs `th`; `brew upgrade th` picks up future releases. -Platforms: Apple Silicon macOS, Linux x86_64, Linux arm64. Windows support is in flight (pearl `th-a165b4` — needs Cargo feature gating so the binary excludes microsandbox + the TUI on Windows; in the meantime, install via WSL). +Platforms: Apple Silicon macOS, Linux x86_64/arm64. **Windows is via WSL** for +now — native Windows support is in flight. ### `curl | sh` @@ -41,630 +82,304 @@ Platforms: Apple Silicon macOS, Linux x86_64, Linux arm64. Windows support is in curl -fsSL https://raw.githubusercontent.com/SmooAI/smooth/main/install.sh | sh ``` -### Build from source +### Build from source (the dev loop) ```bash git clone https://github.com/SmooAI/smooth.git cd smooth -cargo install --path crates/smooth-cli +pnpm install:th # builds the web bundle + installs th to ~/.cargo/bin ``` -## Quick Start - -```bash -# Authenticate with Smoo AI's gateway (resolves every smooth-* slot) -th model login smooai-gateway +Or just the binary: `cargo install --path crates/smooth-cli`. -# Start Smooth — default boots inside a microsandbox microVM -th up +Every subcommand is self-documenting — run `th --help` and `th --help` +liberally. -# Open the interactive coding assistant -th code -``` - -Or bring your own provider — see [Authentication](#authentication) -below for the full list. - -No Docker. No Node.js. No runtime dependencies. One 10MB binary. +--- -### Two modes, one cast +## The `th` toolkit -Smooth has exactly two ways to run, and they share the same agents, -tools, and surveillance — the only thing that differs is the blast -radius: +`th` leads with four things you'll reach for every day. Each is a real command +you can run right now. -| Command | What it does | When to use it | -|---|---|---| -| `th up` (default) | Boots the entire cast inside a hardware-isolated [Microsandbox](https://github.com/microsandbox/microsandbox) microVM (libkrun on Linux, HVF on macOS). `:4400` forwards out for the TUI / web UI. | Your laptop, anywhere the host can run microsandbox. | -| `th up direct` | Runs the same cast as host processes with no sandbox in front. | CI runners, dedicated devboxes, nested-virt VMs — environments that are *already* sandboxed. | +### 1. `th search` — web search from the terminal -Docker is never the sandbox runtime. The `docker` CLI is still bundled -*inside* the microVM so the agent can reach a host Docker / OrbStack / -Colima / Rancher / Podman daemon for nested-virt-free workloads, but -Smooth itself runs on microsandbox or directly on the host — nothing -else. +Ranked web results without leaving your shell, served by Smoo AI's own search +stack. There's an anonymous free tier; sign in for deeper search and a +synthesized answer. -See [ADR-001](docs/Decisions/ADR-001-Consolidate-into-one-microVM.md) -for the consolidation rationale and [ADR-002](docs/Decisions/ADR-002-microsandbox-0.4.6-and-remove-docker-backend.md) -for the most recent microsandbox bump + Docker-backend removal. +```bash +th search "rust axum websocket backpressure" +th search "sqlite wal checkpoint tuning" --answer # synthesized answer (authed) +th search "who maintains ripgrep" --max 5 --json # machine-readable +``` ---- +`--depth advanced` and `--scrape` (fetch full page content per result) unlock on +the authed tier. It's the web-facing companion to `th knowledge` (your docs) and +`th crawl` (one page). -## What is Smooth? +> The search backend is still being built out — the free tier is intentionally +> capped, and advanced depth / answer synthesis are the authed surface. -Smooth is the central CLI and orchestration platform for [Smoo AI](https://smoo.ai). It does two things: +### 2. `th knowledge` — RAG retrieval over your org's own knowledge base -1. **Agent Orchestration** — Dispatch Smooth operatives (sandboxed AI agents) to work on real projects inside hardware-isolated Microsandbox microVMs, with adversarial surveillance and policy-gated access control. +`th knowledge search` runs **real semantic retrieval — the exact same RAG an +agent uses** — over your organization's own documents, backed by `api.smoo.ai`. +It returns the most relevant passages (name + content + relevance score), so you +(or an agent) can pull authoritative internal context straight into a coding +session. Grow the base with `th knowledge add-url` to ingest a whole site. -2. **Smoo AI Platform CLI** — Manage config schemas, interact with the Smoo AI API, sync with Jira, and control your infrastructure from one command. +```bash +th knowledge search "how do we rotate the gateway keys" # semantic RAG retrieval +th knowledge search "deploy runbook" --doc --max 5 # scope to one doc +th knowledge add-url https://docs.example.com # crawl a site into the KB +th knowledge list # what's in the KB +th knowledge upload ... # add a text document +``` -### How the agent loop works +`list` / `show` / `content` / `update` / `delete` round out document management. +Sign in first with `th auth login`. -Inside each operative VM, a **single agent** handles its own inner iteration -(LLM → tool → LLM → …) via `smooth-operator`'s agent loop. A thin outer -governor wraps it with three jobs: feed last run's test output back in, -snapshot the workspace when failing tests drop, and stop on the first -convincing signal. +### 3. Agent features — Big Smooth, `th code`, and the agent loop -```mermaid -%%{init: {'theme':'base','themeVariables':{ - 'background':'#020618','primaryColor':'#0b1426','primaryTextColor':'#e6edf6','primaryBorderColor':'#2b3a52', - 'lineColor':'#7c8aa0','secondaryColor':'#0b1426','tertiaryColor':'#0b1426','fontFamily':'ui-sans-serif, system-ui, sans-serif', - 'clusterBkg':'#0b1426','clusterBorder':'#22304a'}}}%% -flowchart LR - START["Task prompt"] --> TURN - TURN["Coding turn
agent runs tools"] --> GREEN{"Tests green?"} - GREEN -- yes --> DONE["Done"] - GREEN -- no --> SNAP["Snapshot
if failures dropped"] - SNAP --> STOP{"Stop signal?
close-to-green · budget · cap"} - STOP -- no --> TURN - STOP -- yes --> RESTORE["Restore best state"] --> DONE +The same engine powers two ways to put an AI agent to work: - classDef warm fill:#f49f0a,stroke:#ff6b6c,color:#1a0f00; - classDef teal fill:#00a6a6,stroke:#00c2c2,color:#011; - class TURN warm - class DONE teal +```bash +th daemon run # start Big Smooth — the always-on personal AI agent (see below) +th code # launch the interactive coding TUI ``` -Implemented in [`smooth-operator::coding_workflow`](crates/smooth-operator/src/coding_workflow.rs). -An earlier version decomposed the run into seven phases (ASSESS / PLAN / -EXECUTE / VERIFY / REVIEW / TEST / FINALIZE). The phase pipeline kept -silently short-circuiting at one detector or another; the single-agent -loop is smaller, easier to reason about, and matches the shape of -benchmark-tuned coding agents. We kept the self-validation requirement -in the system prompt, the best-state snapshot, and the compile-error -short-circuit — and dropped per-phase dispatch. - -**Stop conditions** are budget + plateau, not a fixed iteration cap: - -- **Green** — agent reports all tests passing. -- **Close-to-green** — a previous turn reached ≤3 failing tests; this - turn didn't improve on it. More iteration is more likely to regress. -- **Budget** — next turn would blow the `--budget-usd` cap. -- **Iteration cap** — safety ceiling (default 5), not the primary brake. - -### Model routing - -Every LLM call dispatches through a **semantic routing slot**. The gateway -(typically `llm.smoo.ai`) resolves each slot to a concrete model, so -upgrading backends doesn't churn the code. - -Six semantic slots (plus a `smooth-default` wire-compat alias that -the gateway routes onto `smooth-coding`): - -| Slot | Used by | Shape | -|---|---|---| -| `smooth-coding` | The coding loop (workhorse) — also serves the legacy `smooth-default` alias | Strong tool use + multi-turn | -| `smooth-reasoning` | `th code` Plan/Think modes — merged from the old `thinking` + `planning` slots | Extended chain-of-thought, task decomposition | -| `smooth-reviewing` | `th code` Review mode, code-review flows | Adversarial critique | -| `smooth-judge` | Narc's LLM-as-a-judge, bench scoring | Yes/no verdicts, low latency | -| `smooth-summarize` | Context compression during long runs | Summarization | -| `smooth-fast` | Session auto-naming, short titles, autocomplete | Haiku/Flash-class, sub-second TTFT | - -Routing is in [`smooth-operator::providers`](crates/smooth-operator/src/providers.rs). -The CLI's `th code` presets remap slots to arbitrary models via the -model picker — e.g. point Coding at Kimi Code for a run, Reasoning at -GLM, whatever. - -**Live status.** The TUI streams an `AgentEvent::PhaseStart` on each -coding turn and shows iteration + routing alias + resolved upstream + -spend in the status bar: - -``` -CODING · smooth-coding → minimax-m2.7 | iter 3/5 | failed: 4 → 1 | spend: $0.012 -``` +- **Big Smooth** is a persistent, chat-first agent that runs on your machine and + can act on your Smoo AI org through `th` itself. [How it works ↓](#how-big-smooth-works) +- **`th code`** is a ratatui coding assistant — streaming chat, tool calls, a + file browser, and git, with `fixer` / `mapper` / `oracle` / `heckler` lead + roles (`--agent`), session resume (`--resume`), and a headless mode + (`--headless --message …`) for scripting. +- Both run the **[smooth-operator](https://github.com/SmooAI/smooth-operator)** + engine's agent loop (observe → think → act) with a tool registry, pre/post + tool hooks, and built-in checkpointing. -All state is durable through Smooth's built-in pearl tracker (Dolt-backed -per-project, git-syncable). +### 4. `th crawl` — web crawling   `PREVIEW · not yet GA` ---- +Turn a page — or a whole site — into clean markdown through an authenticated +crawler with a real browser UA and JS rendering, so it gets pages a plain fetch +403s on. -## Architecture +```bash +th crawl scrape https://example.com/docs/page # one page → markdown +th crawl map https://example.com # discoverable URLs, no content +th crawl crawl https://example.com # whole site → markdown +``` -```mermaid -%%{init: {'theme':'base','themeVariables':{ - 'background':'#020618','primaryColor':'#0b1426','primaryTextColor':'#e6edf6','primaryBorderColor':'#2b3a52', - 'lineColor':'#7c8aa0','secondaryColor':'#0b1426','tertiaryColor':'#0b1426','fontFamily':'ui-sans-serif, system-ui, sans-serif', - 'clusterBkg':'#0b1426','clusterBorder':'#22304a'}}}%% -flowchart TB - TH["th binary
+ host credential broker"] - - subgraph VM["The microVM · microsandbox"] - BS["Big Smooth
orchestrator · READ-ONLY"] - W["Wonk · access control"] - G["Goalie · net + fs proxy"] - N["Narc · surveillance"] - SC["Scribe → Archivist · logging"] - OPS["Smooth operatives
the workers"] - end +> **Preview.** `th crawl` is functional but **not yet GA** — the underlying +> `search.smoo.ai` crawler backend is still in flight. Expect rough edges and +> changing limits until it lands. - TH -->|spawns| VM - BS -->|orchestrates| OPS - OPS -->|HTTP_PROXY| G - G -->|"allowed?"| W - N -->|intercepts| OPS - OPS --> SC +### And the rest of the belt - classDef warm fill:#f49f0a,stroke:#ff6b6c,color:#1a0f00; - classDef teal fill:#00a6a6,stroke:#00c2c2,color:#011; - class OPS warm - class W teal +```bash +# SEP extensions — add tools/hooks/UI to agents without rebuilding the binary +th ext search # browse the extension marketplace +th ext install npm:@scope/pkg # install (local dir, npm:, or git:) +th ext list # installed extensions + trust state + +# Pearls — the built-in, Dolt-backed work tracker (more below) +th pearls ready # what's ready to work on +th pearls create --title="..." --description="..." +th pearls show / update / close + +# Worktrees — feature work stays off main, always +th worktree create SMOODEV-XX-desc +th worktree list / merge / remove + +# The Smoo AI platform API — no more hand-rolled curl to api.smoo.ai +th auth login # sign in (browser); --m2m for service accounts +th api orgs | agents | keys | members | crm | knowledge | jobs | testing … ``` -`th up direct` runs the exact same cast as host processes — same gRPC -sockets, same surveillance, same credential broker — just without the -microsandbox boundary in front. Same diagram, the microVM box is just -not there. - -**Wire transport.** The cast services bind four tonic-gRPC servers on -Unix-domain sockets at startup (`narc.sock`, `wonk.sock`, `scribe.sock`, -`bigsmooth.sock` under `$SMOOTH_SINGLE_PROCESS_SOCKET_DIR`). Each -operative subprocess dials those sockets for every tool check, -policy decision, and log entry — see `proto/{narc,wonk,scribe,bigsmooth}.proto` -and `crates/smooth-bigsmooth/src/single_process.rs::bootstrap_from_app_state`. -Inside Big Smooth's own process the same services are reached via -`Arc` directly (no wire). The outer TUI/web UI/bench harness -speaks HTTP+WebSocket to Big Smooth on `:4400`. Full topology lives in -[`docs/Architecture/Transport.md`](docs/Architecture/Transport.md). - -### The Cast - -Big Smooth, Archivist, Wonk, Goalie, Narc, Scribe, and Groove all live -in the same microVM (sandboxed mode) or the same process tree (direct -mode). Smooth operatives are subprocesses spawned inside that same -boundary, one per dispatched pearl. - -| Service | Role | -|---|---| -| **Big Smooth** | Orchestrator. Schedules work, generates policies, handles access requests. **READ-ONLY** — cannot write to the filesystem. | -| **Archivist** | Central log + trace aggregator. Receives events and OTLP traces from every Scribe. Stores traces in SQLite, optionally forwards to external OTel backends (Jaeger, Tempo, Honeycomb). Can write, but only to log paths. | -| **Wonk** | Access control authority. Reads policy TOML, answers "is this allowed?" for every network request, tool call, pearl access, and CLI command. No LLM. | -| **Goalie** | Network + filesystem proxy. Dumb pipe — forwards or blocks based on Wonk's answer. iptables + FUSE enforced at the kernel level inside the VM. | -| **Narc** | Tool surveillance + prompt-injection guard. Two-tier detection: fast regex pre-filters + LLM-as-a-judge for ambiguous cases. | -| **Scribe** | Structured logging service. All services log through Scribe, which writes to in-memory SQLite and feeds Archivist. | -| **Groove** | LLM checkpointing + session resume. Captures conversation state after tool calls so an interrupted operative picks up at the last checkpoint. | - -**The Board** = Big Smooth + Archivist (leadership). **The Safehouse** -is the microVM (or, in direct mode, the host process tree) where The -Board operates alongside the rest of the cast. - -**Smooth operatives** = the AI agents (the sandboxed workers). The only ones who write code. (They run the `smooth-operator` engine; don't confuse the worker with the engine.) - -### Inside each MicroVM +`th` replaces the `curl … api.smoo.ai`, the web dashboard trip, and the Supabase +Studio poke — one authenticated, typed, paginated surface. Run `th --help` for +the full command list (config, files, booking, notify, jira, audit, and more). -```mermaid -%%{init: {'theme':'base','themeVariables':{ - 'background':'#020618','primaryColor':'#0b1426','primaryTextColor':'#e6edf6','primaryBorderColor':'#2b3a52', - 'lineColor':'#7c8aa0','secondaryColor':'#0b1426','tertiaryColor':'#0b1426','fontFamily':'ui-sans-serif, system-ui, sans-serif', - 'clusterBkg':'#0b1426','clusterBorder':'#22304a'}}}%% -flowchart LR - subgraph VM["MicroVM · --scope none"] - Operator["Operative / Big Smooth"] - Wonk["Wonk · :8400"] - Goalie["Goalie · :8480"] - Narc["Narc"] - Scribe["Scribe · :8401"] - end - - Operator -->|HTTP_PROXY| Goalie - Goalie -->|"allowed?"| Wonk - Narc -->|"intercepts · checks tool"| Operator - Operator --> Scribe - Wonk --> Scribe - Goalie --> Scribe - Narc --> Scribe - - classDef warm fill:#f49f0a,stroke:#ff6b6c,color:#1a0f00; - classDef teal fill:#00a6a6,stroke:#00c2c2,color:#011; - class Operator warm - class Scribe teal -``` +--- -- **Wonk** reads `/etc/smooth/policy.toml`, listens on `127.0.0.1:8400`, hot-reloads on file change -- **Goalie** listens on `127.0.0.1:8480` as HTTP proxy. iptables rejects all outbound TCP except from the Goalie UID. FUSE mount at `/workspace` for filesystem access control. -- **Narc** intercepts tool calls and incoming prompts. Regex fast path catches obvious secrets and write violations. Ambiguous cases go to a small/fast LLM (Haiku, Flash, GPT-4o-mini) for a yes/no verdict. -- **Scribe** listens on `127.0.0.1:8401`, writes to on-pod SQLite and JSON-lines, feeds events to Archivist. Bridges `tracing` spans to OpenTelemetry via `tracing-opentelemetry`, generating trace hierarchies for operative lifecycles, prompts, tool calls, and network requests. Exports OTLP traces to Archivist with W3C traceparent propagation across VM boundaries. +## How Big Smooth works -### Security Model +**Big Smooth is the always-on AI agent built on `th`.** It's a chat-first +personal agent that runs as a durable service on your machine, built directly on +the [smooth-operator](https://github.com/SmooAI/smooth-operator) engine — +Smooth's own agent loop, LLM client, and tool system. You talk to it; it uses +the whole `th` toolkit on your behalf, including acting on your Smoo AI org +through `th` commands. ```mermaid %%{init: {'theme':'base','themeVariables':{ 'background':'#020618','primaryColor':'#0b1426','primaryTextColor':'#e6edf6','primaryBorderColor':'#2b3a52', 'lineColor':'#7c8aa0','secondaryColor':'#0b1426','tertiaryColor':'#0b1426','fontFamily':'ui-sans-serif, system-ui, sans-serif', 'clusterBkg':'#0b1426','clusterBorder':'#22304a'}}}%% -flowchart TD - subgraph Enforcement["Kernel-level enforcement"] - IPT["iptables · only Goalie UID egress"] - FUSE["FUSE mount · all file I/O via Goalie"] - end +flowchart TB + UI["Chat UI · web + TUI"] --> DAEMON + DAEMON["Big Smooth
always-on daemon"] --> ENGINE + ENGINE["smooth-operator engine
agent loop · LLM · checkpointing"] --> TOOLS - subgraph Policy["Policy-driven access control"] - TOML["policy.toml
generated per operative"] - NET["Network allowlist"] - FS["Filesystem deny
*.env · *.pem · .ssh/*"] - REST["Tools · pearls · MCP allowlists"] + subgraph TOOLS["Per-turn tools"] + FS["sandboxed fs / grep / bash"] + SEP["SEP extensions
th search · knowledge · api …"] end - subgraph Detection["Narc · two-tier detection"] - REGEX["Regex fast path"] - LLM["LLM judge · ambiguous cases"] + subgraph SAFETY["Tool hooks · every call is judged"] + AUTO["Auto-mode
allow · deny · ask"] + NARC["Narc
LLM-judge + regex guards"] end - TOML --> NET & FS & REST - IPT --> NET - FUSE --> FS + TOOLS --> SAFETY + DAEMON --> STATE["Dolt pearls + SQLite state"] + DAEMON --> SCHED["Proactive scheduler"] + DAEMON --> NET["Tailscale reachability"] classDef warm fill:#f49f0a,stroke:#ff6b6c,color:#1a0f00; classDef teal fill:#00a6a6,stroke:#00c2c2,color:#011; - class TOML warm - class LLM teal -``` - -**Key invariants:** -- Big Smooth **never writes**. Narc enforces this in-VM — any write attempt is instantly blocked. -- Archivist **can write**, but only to log paths. Writes to any other path are blocked. -- Operatives can only see their assigned pearls and dependencies (scoped by auth token). -- All outbound traffic goes through Goalie. No process can bypass the proxy — enforced at the kernel level. - -### Continuous Access Negotiation - -Operatives can request expanded access at runtime. The flow: - -```mermaid -%%{init: {'theme':'base','themeVariables':{ - 'background':'#020618','primaryColor':'#0b1426','primaryTextColor':'#e6edf6','primaryBorderColor':'#2b3a52', - 'lineColor':'#7c8aa0','actorBkg':'#0b1426','actorBorder':'#2b3a52','actorTextColor':'#e6edf6', - 'signalColor':'#7c8aa0','signalTextColor':'#e6edf6','noteBkgColor':'#f49f0a','noteTextColor':'#1a0f00','noteBorderColor':'#ff6b6c', - 'fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%% -sequenceDiagram - participant Op as Operative - participant G as Goalie - participant W as Wonk - participant BS as Big Smooth - - Op->>G: GET api.stripe.com/v1/charges - G->>W: is this allowed? - W-->>G: BLOCKED (not in allowlist) - G-->>Op: 403 Blocked - G->>W: request access - W->>BS: POST /api/access/request - BS->>BS: auto-approve? check pearl labels? - alt auto-approved - BS-->>W: approved + updated policy - W-->>W: hot-reload policy - Note over Op,G: retry succeeds - else needs human - BS->>BS: send to inbox - Note over BS: th access approve <pearl> <domain> - end -``` - -### Default access envelope - -Each operative VM boots with a minimal envelope: - -- **Network**: the configured LLM gateway (`llm.smoo.ai` by default), - relevant package registries (crates.io, npm, PyPI), and GitHub. Any - other domain needs explicit approval — see continuous access negotiation - above. -- **Filesystem**: read-write on `/workspace` (bind-mount of the user's - repo). Everything else is read-only or denied. `.env`, `*.pem`, - `.ssh/*`, and other secret-shaped paths are always denied. -- **Pearls**: the assigned pearl + its dependency closure (depth 2). - Tasks cannot reach pearls outside that closure. -- **Tools**: the registered tool allowlist (file read/write, bash via - Goalie, MCP tools that were approved, CLI-wrapper plugins). Every - invocation passes Narc's regex prefilter + ambiguous-case LLM judge. - ---- - -## The `th` CLI - -### Core - -```bash -th up # Start everything -th down # Stop -th status # System health -th code # Interactive coding assistant (ratatui) -``` - -### Authentication - -Smooth talks to any OpenAI-compatible endpoint. The recommended default -is **[llm.smoo.ai](https://llm.smoo.ai)** — our LiteLLM-backed gateway -that maps every `smooth-*` routing slot to a production-tuned upstream -(Claude, GPT, Gemini, Kimi, MiniMax, GLM, Qwen, etc.) with Stripe- -metered billing, org/team keys, and an admin dashboard. One key, every -model, no per-provider plumbing. - -```bash -# Smoo AI's gateway (recommended — every slot resolves via one key) -th model login smooai-gateway - -# Or bring your own upstream — any OpenAI-compatible provider: -th model login kimi-code # Moonshot Kimi Code (coding workhorse) -th model login kimi # Moonshot Kimi chat endpoint -th model login openrouter # OpenRouter (aggregator over many providers) -th model login openai # OpenAI direct -th model login anthropic # Anthropic direct -th model login google # Google (Gemini) -th model login ollama # Local Ollama models - -th model status # Show all provider status -th model providers # List configured providers -th model default # Which provider backs smooth-default -``` - -> **Note on `th auth` vs `th model`** (pearl `th-abc4e2`): `th auth` is now **user identity** — `th auth login` runs the Supabase OAuth browser flow against `auth.smoo.ai` and stores a JWT at `~/.smooth/auth/smooai.json` so subsequent `th api …` / `th admin …` calls authenticate as you. **LLM provider credentials** (the commands above) moved to `th model login` / `th model providers` / `th model default`. Two different identity systems, two different command trees. - -Providers and slots are independent: you can pin each routing slot -(`smooth-coding`, `smooth-thinking`, …) to a different provider/model -via `th code`'s model picker or by editing `~/.smooth/providers.json`. - -### Work - -```bash -th run # Trigger work on a pearl -th operatives # List active Smooth operatives -th pause/resume/steer/cancel # Control operatives mid-task -th approve # Approve a review -th inbox # Messages needing attention + class DAEMON warm + class NARC teal ``` -### Access Control +- **Engine.** Runs the smooth-operator agent loop — observe → think → act — with + a tool registry, streaming, and checkpointed session resume. +- **Tools + SEP extensions.** Each turn gets a fresh tool set: sandboxed + filesystem/grep/bash plus **SEP extensions** (subprocess tools/hooks/UI over + the Smooth Extension Protocol) installed with `th ext`. This is how Big Smooth + reaches your Smoo AI org — it shells out to `th api …` through an extension. +- **Safety = tool hooks, not a VM.** Every tool call (extension tools included) + passes two in-process hooks before it runs: an **auto-mode permission engine** + (allow / deny / **ask**, with persistent allow-lists; `ask` parks on an access + queue surfaced in the UI) and **Narc**, an LLM-judge layer with regex + fast-paths that flags secret exfiltration, prompt injection, and dangerous + operations. This replaced the old microVM isolation model — the safety now + lives on the tool registry itself. +- **Durable state.** Work items live in the Dolt-backed pearl store; session and + runtime state live in local SQLite. Nothing is lost across restarts. +- **Proactive + reachable.** A scheduler lets pearls "speak up" when their time + arrives, and Tailscale serve makes the agent reachable from your other + devices. + +### Run it ```bash -th access pending # List pending access requests -th access approve # Approve domain access -th access deny # Deny domain access -th access policy # Show current policy +th auth login # sign in to Smoo AI (browser flow) +th model login # add an LLM provider key (or point at the Smoo AI gateway) +th daemon run # boot Big Smooth (serves its chat UI same-origin on :8788) +th daemon status # health check ``` -### Tools & Plugins +Keep it running across reboots with the native service manager (no sudo, no +system daemons): ```bash -# MCP servers (Playwright, GitHub, filesystem, etc.) -th mcp add playwright npx @playwright/mcp@latest -th mcp add --project repo-fs npx @modelcontextprotocol/server-filesystem /workspace -th mcp list # Global + project scopes -th mcp defaults # Show shipped defaults (budget-aware-mcp, …) -th mcp install # Register all shipped defaults (idempotent) -th mcp install budget-aware-mcp # Register a single shipped default -th mcp test playwright # Health check -th mcp remove playwright - -# CLI-wrapper plugins — shell commands exposed as agent tools -th plugin init jq --command 'jq {{filter}} <<< {{json}}' -th plugin init --project deploy --command 'scripts/deploy.sh {{env}}' -th plugin list -th plugin remove deploy --project +th service install # LaunchAgent (macOS) / systemd --user (Linux) +th service status +th service logs -f ``` -Global config lives at `~/.smooth/`; project config at -`/.smooth/`. Project entries shadow global on name collision. -See [`docs/extending.md`](docs/extending.md) for the full guide. - -### Run a pearl in a sandbox (`th run`) - -Dispatch a pearl (or ad-hoc prompt) to a Smooth operative running in a -microVM. The agent has bind-mount access to your workspace, a -project-scoped cache at `/opt/smooth/cache`, and (with `--keep-alive`) -forwarded ports so you can review dev servers live. - -```bash -# First ready pearl, default image -th run --keep-alive - -# Explicit pearl, explicit memory -th run th-abcdef --keep-alive --memory-mb 6144 +> **Reachability note.** Big Smooth binds to loopback only by default +> (`127.0.0.1:8788`). Set `SMOOTH_ADDR` to change the bind. For access from your +> other devices, expose it over your tailnet with `tailscale serve` (→ `:8443`) +> rather than opening the raw bind — the API has no authentication today. -# Ad-hoc prompt against the current directory -th run "add a /health route that returns {\"ok\":true}" --keep-alive +--- -# Inspect + tear down -th operatives list -th operatives kill -``` +## The orchestration superpower -**One image for every stack.** `smooai/smooth-operative` ships with -alpine + `mise` baked in. The agent reads the workspace -(`package.json`, `Cargo.toml`, `pyproject.toml`, `go.mod`) and -installs whatever toolchain it needs at runtime — node + pnpm, -python + uv, rust, go, bun, deno, or any of the ~140 tools mise -supports. Installs land in `/opt/smooth/cache/mise`, bound to the -host project cache so second-run starts are offline-fast. +Here's the force-multiplier: **`th` gives a fleet of AI coding agents a shared +inbox and a shared brain.** Run several AI coding agents at once — across +worktrees, machines, or harnesses — and let them coordinate instead of stepping +on each other. -Build locally: +- **`th msg` + `th agent` — the shared inbox.** Any process that can run `th` + registers as a named agent (`th agent register`) and sends agent-to-agent mail + (`th msg send --to `, `th msg inbox`, `th msg watch`). It's + harness-agnostic — your AI coding agents talk to each other regardless of what + each one is running under. +- **`th pearls` — the shared brain.** One Dolt-backed, version-controlled work + tracker that every agent reads and writes: a dependency graph of work items, + synced over git's own `refs/dolt/data` ref. One agent files a pearl, another + claims it, a third closes it — with full history, no central server. ```bash -scripts/build-smooth-operative-image.sh -``` - -Override via `--image` or `SMOOTH_OPERATIVE_IMAGE` env if you want a -custom variant (e.g. a version pinned for CI reproducibility). +# Agent A registers and picks up ready work +th agent register --name builder +th pearls ready +th pearls update th-abc123 --status=in_progress -**Microsandbox image resolution.** Locally-built images live in -your Docker Desktop image store; `microsandbox` pulls from registries -by default, so if its pull can't see your local build, push it -first (`docker push smooai/smooth-operative:0.2.0`) or set -`SMOOTH_OPERATIVE_IMAGE` to something microsandbox can reach. +# Agent A hands off to Agent B over the shared inbox +th msg send --to reviewer "th-abc123 ready for review — tests green on my branch" -**Project cache.** Each workspace path hashes to its own cache, -mounted at `/opt/smooth/cache` inside the VM. Subsequent runs on the -same repo share mise installs + language stores (pnpm-store, cargo -registry, uv cache, etc.). Backed by a first-class microsandbox -Volume by default (`~/.microsandbox/volumes/smooth-cache-/`); -set `SMOOTH_USE_VOLUMES=0` to fall back to the legacy bind-mount -(`~/.smooth/project-cache//`). Manage with: - -```bash -th cache list # shows entries from both backends, tagged -th cache prune --older-than 30 # evict caches idle > N days -th cache clear /path/to/project # remove entry for a specific workspace +# Agent B is watching the inbox and pulls the shared work tracker +th msg watch +th pearls show th-abc123 ``` -### Background service - -Keep `th up` running across reboots via the native service manager -(user-level; no sudo, no system daemons). +Give your coding agents a shared inbox and a shared brain, and a pile of +independent agents becomes a coordinated team. -```bash -th service install # LaunchAgent (macOS) / systemd --user (Linux) / logon task (Windows) -th service start / stop / restart -th service status -th service logs -f # Tail ~/.smooth/service.log -th service uninstall -th service install --system # Print the system-level artifact + install instructions -``` +--- -### System +## Get started ```bash -th db status # Database info -th db backup # Backup SQLite -th audit tail leader # View audit logs -th tailscale status # Tailscale info -th worktree create/list/merge # Git worktrees +brew install SmooAI/tools/th # or: curl -fsSL https://raw.githubusercontent.com/SmooAI/smooth/main/install.sh | sh +th auth login # sign in to Smoo AI +th search "hello world" # try the free web search +th daemon run # boot Big Smooth +th --help # explore everything else ``` ---- - -## Extending Smooth - -Two extension points add tools without rebuilding the binary: - -- **MCP servers** — spawn [Model Context - Protocol](https://modelcontextprotocol.io) servers like Playwright - MCP or GitHub MCP; their tools land in the agent's registry as - `.`. Smooth ships one default out of the box: - [`budget-aware-mcp`](https://github.com/Doorman11991/budget-aware-mcp) - — token-budgeted code-graph queries (`graph_walk`, `search_graph`, - `check_scope`, `explain_symbol`, `find_dead_code`, …) so the - operative can pull just the structurally-relevant code instead of - ripgrep-then-read-file dumping entire files. Registered on first - `th up`; opt out with `SMOOTH_SKIP_DEFAULT_MCP=1` or remove via - `th mcp remove budget-aware-mcp`. -- **CLI-wrapper plugins** — drop a TOML manifest at - `.smooth/plugins//plugin.toml` and the runner registers it as - `plugin.`, rendering `{{placeholder}}` args into a shell - command template. - -Both are configurable globally (`~/.smooth/`) and per-project -(`/.smooth/`). Project entries shadow global ones. There's -**no trust gate** on loading these — consistent with `npm install`, -`.zshrc`, or cloning any repo and running `pnpm dev`. Defense-in-depth -happens at *call time*: Narc's CliGuard / injection / secret -detectors gate every tool invocation, Wonk policy gates every -network + filesystem access, and the whole agent loop runs inside -a hardware-isolated microVM. See [`docs/extending.md`](docs/extending.md) -and [`SECURITY.md`](SECURITY.md). +### Links ---- +- **Using `th`** — [`docs/Engineering/Using-th-CLI.md`](docs/Engineering/Using-th-CLI.md) +- **Extending Smooth** (MCP, plugins, SEP extensions) — [`docs/extending.md`](docs/extending.md) +- **Security model** — [`SECURITY.md`](SECURITY.md) +- **Contributor guide** (build, test, worktree workflow) — [`CLAUDE.md`](CLAUDE.md) +- **The smooth-operator engine** — [github.com/SmooAI/smooth-operator](https://github.com/SmooAI/smooth-operator) -## Tech Stack - -| | | -|---|---| -| **Language** | Rust 2021 edition | -| **HTTP** | axum + tower | -| **Database** | rusqlite (bundled SQLite) | -| **TUI** | ratatui + crossterm | -| **Web** | React 19 + Vite + Tailwind CSS 4 (embedded) | -| **Markdown** | pulldown-cmark (TUI), react-markdown (web) | -| **Sandboxes** | Microsandbox (hardware-isolated microVMs) | -| **Agent framework** | smooth-operator (Rust-native, built-in checkpointing) | -| **LLM** | OpenAI-compatible via `llm.smoo.ai` gateway by default (Kimi, MiniMax, GLM, Qwen, Anthropic, OpenAI, Google) | -| **Work tracking** | Pearls (Dolt-backed, git-syncable) | -| **Policy** | TOML-based, hot-reloadable via notify + ArcSwap | -| **Logging** | smooai-logger (structured, context-aware) | -| **Tracing** | OpenTelemetry (tracing-opentelemetry bridge, OTLP export) | -| **Linting** | clippy (pedantic + nursery) | -| **Formatting** | rustfmt (160 max width) | - -## Workspace +### Workspace ``` smooth/ ├── crates/ -│ ├── smooth-cli/ # Binary — clap CLI, the `th` entry point -│ ├── smooth-bigsmooth/ # Library — orchestrator, policy gen, session mgmt -│ ├── smooth-bootstrap-bill/ # Library + binary — host-side microsandbox broker ("Bill") -│ ├── smooth-operator/ # Library — Rust-native AI agent framework -│ ├── smooth-operative/ # Binary — agent loop inside each operative VM -│ ├── smooth-policy/ # Library — shared policy types, TOML parsing -│ ├── smooth-wonk/ # Binary — in-VM access control authority -│ ├── smooth-goalie/ # Binary — in-VM network + filesystem proxy -│ ├── smooth-narc/ # Library — tool surveillance + secret detection -│ ├── smooth-scribe/ # Library — per-VM structured logging -│ ├── smooth-archivist/ # Library — central log aggregator -│ ├── smooth-pearls/ # Library — Dolt-backed pearl tracker -│ ├── smooth-plugin/ # Library — CLI-wrapper plugin manifests -│ ├── smooth-diver/ # Library — deep research / exploratory agent -│ ├── smooth-tunnel/ # Library — th.smoo.ai reverse-tunnel client -│ ├── smooth-bench/ # Binary — coding-benchmark harness (aider-polyglot, SWE-bench, …) -│ ├── smooth-code/ # Library — ratatui terminal dashboard -│ └── smooth-web/ # Library — embedded Vite SPA -│ └── web/ # React + Vite source -├── Cargo.toml # Workspace root -├── rustfmt.toml # Format config -└── install.sh # Curl installer +│ ├── smooth-cli/ # Binary — the `th` clap entry point +│ ├── smooth-daemon/ # Big Smooth — the always-on agent runtime +│ ├── smooth-code/ # ratatui coding TUI +│ ├── smooth-web/ # embedded React + Vite SPA +│ ├── smooth-pearls/ # Dolt-backed pearl (work-item) tracker +│ ├── smooth-policy/ # policy types + auto-mode permission engine +│ ├── smooth-tools/ # sandboxed fs/grep/bash agent tools +│ ├── smooth-api-client/ # api.smoo.ai client +│ ├── smooth-cast/ # LLM cast / model routing +│ ├── smooth-diver/ # deep-research / exploratory agent +│ └── … +├── Cargo.toml # workspace root +└── install.sh # curl installer ``` -## Development +The [smooth-operator](https://github.com/SmooAI/smooth-operator) agent engine is +consumed as an external crate — the daemon runs *on* it. -```bash -# Build -cargo build - -# Test (full suite across all crates) -cargo test - -# Format -cargo fmt - -# Lint -cargo clippy - -# Run dev (with auto-reload) -cargo watch -x 'run -p smooth-cli -- up' - -# Release build (~10MB) -cargo build --release -p smooth-cli -ls -lh target/release/th -``` +--- -## 🧩 Part of Smoo AI {#part-of-smoo-ai} +## 🧩 Part of Smoo AI -Smooth is built and open-sourced by **[Smoo AI](https://smoo.ai)** — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools. +Smooth is built and open-sourced by **[Smoo AI](https://smoo.ai)** — the +AI-powered business platform with AI built into every product: CRM, customer +support, campaigns, field service, observability, and developer tools. - 🚀 **Smooth on the platform** — [smoo.ai/th](https://smoo.ai/th) - 🧰 **More open source from Smoo AI** — [smoo.ai/open-source](https://smoo.ai/open-source) -- 🧩 **Sibling packages** — [smooth-operator](https://github.com/SmooAI/smooth-operator) (the agent engine Smooth runs), [@smooai/deploy](https://github.com/SmooAI/deploy), [@smooai/logger](https://github.com/SmooAI/logger), [@smooai/config](https://github.com/SmooAI/config) ## 🤝 Contributing -Issues and PRs welcome. All feature work happens in a git worktree (`th worktree create`) — see [CLAUDE.md](CLAUDE.md) for build, test, and workflow conventions, and [SECURITY.md](SECURITY.md) for the security model. +Issues and PRs welcome. All feature work happens in a git worktree +(`th worktree create`) — see [CLAUDE.md](CLAUDE.md) for build, test, and workflow +conventions, and [SECURITY.md](SECURITY.md) for the security model. ## 📄 License diff --git a/crates/smooth-archivist/Cargo.toml b/crates/smooth-archivist/Cargo.toml deleted file mode 100644 index ab9f147a..00000000 --- a/crates/smooth-archivist/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[package] -name = "smooai-smooth-archivist" -# Internal crate — part of the `th` binary, not a public library. -# `th` ships as a GitHub binary release; only smooai-smooth-operator-core -# is published to crates.io (separately, as its own crate). Pearl th-607f69. -publish = false -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Smooth Archivist — Central log aggregator" -readme = "README.md" - -[lib] -name = "smooth_archivist" - -[dependencies] -tokio.workspace = true -axum.workspace = true -serde.workspace = true -serde_json.workspace = true -chrono.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -anyhow.workspace = true -uuid.workspace = true -futures-util.workspace = true -smooth-scribe.workspace = true - -[dev-dependencies] -tokio = { workspace = true, features = ["test-util"] } -tower = { workspace = true } -http-body-util.workspace = true -hyper.workspace = true - -[lints] -workspace = true diff --git a/crates/smooth-archivist/README.md b/crates/smooth-archivist/README.md deleted file mode 100644 index 71e011a9..00000000 --- a/crates/smooth-archivist/README.md +++ /dev/null @@ -1,46 +0,0 @@ -
- -# smooth-archivist - -**Archivist — keeper of the cross-VM record** - -*Every Scribe, every operator, every log line. Ingested, deduplicated, queryable. The only place in the cast that sees the whole picture.* - -[![crates.io](https://img.shields.io/crates/v/smooai-smooth-archivist)](https://crates.io/crates/smooai-smooth-archivist) -[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/SmooAI/smooth/blob/main/LICENSE) - -
- ---- - -Scribes are local — one per operator microVM, written to from the agent's audit hook. Archivist is global. It runs inside the Safehouse and receives batched `LogEntry` uploads from every Scribe on the network, deduplicates them by `(operator_id, timestamp, message_hash)` so retries from flaky networks don't duplicate entries, and exposes: - -- **`POST /ingest`** — accept an `IngestBatch` of entries from a per-VM Scribe. -- **`GET /query`** — filter by service, level, operator, time window, source VM. Backed by `ArchiveStore` (memory impl included; plug your own for durability). -- **`GET /stats`** — counts by VM, level, service. Powers the Safehouse status board. -- **`GET /events`** — SSE stream so the `th code` TUI and the web dashboard can tail live across every operator at once. - -This is how the security layer pays off: Narc flags a secret leak on operator 3, Archivist correlates it with the same hash on operator 7, and you find out within seconds that two agents independently got the same adversarial instruction from the same upstream source. - -Part of **[Smooth](https://github.com/SmooAI/smooth)**, the security-first AI-agent orchestration platform. - -## Key Types - -- **`build_router`** / **`AppState`** — axum router for the four endpoints above. -- **`IngestBatch`** / **`IngestResult`** — wire types for `/ingest`. -- **`ArchiveStore`** trait + **`MemoryArchiveStore`** — query surface with filters. -- **`EventArchive`** — in-memory ring + tokio broadcast channel for live tails plus scrollback. - -## Usage - -```rust -use smooth_archivist::{build_router, AppState}; - -let state = AppState::new(); -let app = build_router(state); -axum::serve(listener, app).await?; -``` - -## License - -MIT diff --git a/crates/smooth-archivist/src/event_archive.rs b/crates/smooth-archivist/src/event_archive.rs deleted file mode 100644 index 96281899..00000000 --- a/crates/smooth-archivist/src/event_archive.rs +++ /dev/null @@ -1,296 +0,0 @@ -use std::sync::Mutex; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -/// An archived event from the Smooth system. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ArchivedEvent { - pub id: String, - pub event_type: String, - pub task_id: Option, - pub operator_id: Option, - pub payload: serde_json::Value, - pub timestamp: DateTime, -} - -/// Filter criteria for querying archived events. -#[derive(Debug, Clone, Deserialize)] -pub struct EventFilter { - pub task_id: Option, - pub operator_id: Option, - pub event_type: Option, - pub since: Option>, - #[serde(default = "default_limit")] - pub limit: usize, -} - -fn default_limit() -> usize { - 100 -} - -impl Default for EventFilter { - fn default() -> Self { - Self { - task_id: None, - operator_id: None, - event_type: None, - since: None, - limit: 100, - } - } -} - -/// Storage backend for archived events. -pub trait EventArchive: Send + Sync { - /// Store a single event in the archive. - /// - /// # Errors - /// - /// Returns an error if the event could not be persisted. - fn store(&self, event: &ArchivedEvent) -> anyhow::Result<()>; - - /// Query events matching the given filter. - /// - /// # Errors - /// - /// Returns an error if the query could not be executed. - fn query(&self, filter: &EventFilter) -> anyhow::Result>; - - /// Count events matching the given filter. - /// - /// # Errors - /// - /// Returns an error if the count could not be computed. - fn count(&self, filter: &EventFilter) -> anyhow::Result; -} - -/// In-memory event archive (for testing and small deployments). -#[derive(Debug, Default)] -pub struct MemoryEventArchive { - events: Mutex>, -} - -impl MemoryEventArchive { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - fn matches(event: &ArchivedEvent, filter: &EventFilter) -> bool { - if let Some(ref tid) = filter.task_id { - if event.task_id.as_ref() != Some(tid) { - return false; - } - } - if let Some(ref oid) = filter.operator_id { - if event.operator_id.as_ref() != Some(oid) { - return false; - } - } - if let Some(ref et) = filter.event_type { - if &event.event_type != et { - return false; - } - } - if let Some(since) = filter.since { - if event.timestamp < since { - return false; - } - } - true - } -} - -impl EventArchive for MemoryEventArchive { - fn store(&self, event: &ArchivedEvent) -> anyhow::Result<()> { - let mut events = self.events.lock().expect("lock poisoned"); - events.push(event.clone()); - Ok(()) - } - - fn query(&self, filter: &EventFilter) -> anyhow::Result> { - let events = self.events.lock().expect("lock poisoned"); - let results: Vec = events.iter().filter(|e| Self::matches(e, filter)).rev().take(filter.limit).cloned().collect(); - Ok(results) - } - - fn count(&self, filter: &EventFilter) -> anyhow::Result { - let events = self.events.lock().expect("lock poisoned"); - let count = events.iter().filter(|e| Self::matches(e, filter)).count(); - Ok(count) - } -} - -/// Subscribe to a broadcast channel of JSON events and archive them. -/// Returns a join handle for the background archival task. -pub fn archive_from_broadcast( - archive: std::sync::Arc, - mut rx: tokio::sync::broadcast::Receiver, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - while let Ok(event_json) = rx.recv().await { - let event_type = event_json["type"].as_str().unwrap_or("unknown").to_string(); - let archived = ArchivedEvent { - id: uuid::Uuid::new_v4().to_string(), - event_type, - task_id: event_json["task_id"].as_str().map(String::from), - operator_id: event_json["operator_id"].as_str().map(String::from), - payload: event_json, - timestamp: Utc::now(), - }; - if let Err(e) = archive.store(&archived) { - tracing::warn!(error = %e, "failed to archive event"); - } - } - }) -} - -#[cfg(test)] -mod tests { - use chrono::Duration; - - use super::*; - - fn make_event(event_type: &str, task_id: Option<&str>, operator_id: Option<&str>) -> ArchivedEvent { - ArchivedEvent { - id: uuid::Uuid::new_v4().to_string(), - event_type: event_type.to_string(), - task_id: task_id.map(String::from), - operator_id: operator_id.map(String::from), - payload: serde_json::json!({"type": event_type}), - timestamp: Utc::now(), - } - } - - fn make_event_at(event_type: &str, timestamp: DateTime) -> ArchivedEvent { - ArchivedEvent { - id: uuid::Uuid::new_v4().to_string(), - event_type: event_type.to_string(), - task_id: None, - operator_id: None, - payload: serde_json::json!({"type": event_type}), - timestamp, - } - } - - #[test] - fn archived_event_serialization_roundtrip() { - let event = make_event("TokenDelta", Some("task-1"), Some("op-1")); - let json = serde_json::to_string(&event).expect("serialize"); - let parsed: ArchivedEvent = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(parsed.event_type, "TokenDelta"); - assert_eq!(parsed.task_id.as_deref(), Some("task-1")); - assert_eq!(parsed.operator_id.as_deref(), Some("op-1")); - } - - #[test] - fn memory_archive_store_and_query_all() { - let archive = MemoryEventArchive::new(); - archive.store(&make_event("TokenDelta", None, None)).expect("store"); - archive.store(&make_event("TaskComplete", None, None)).expect("store"); - - let results = archive.query(&EventFilter::default()).expect("query"); - assert_eq!(results.len(), 2); - } - - #[test] - fn query_filter_by_task_id() { - let archive = MemoryEventArchive::new(); - archive.store(&make_event("TokenDelta", Some("task-1"), None)).expect("store"); - archive.store(&make_event("TokenDelta", Some("task-2"), None)).expect("store"); - archive.store(&make_event("TaskComplete", None, None)).expect("store"); - - let results = archive - .query(&EventFilter { - task_id: Some("task-1".to_string()), - ..Default::default() - }) - .expect("query"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].task_id.as_deref(), Some("task-1")); - } - - #[test] - fn query_filter_by_event_type() { - let archive = MemoryEventArchive::new(); - archive.store(&make_event("NarcAlert", None, None)).expect("store"); - archive.store(&make_event("TokenDelta", None, None)).expect("store"); - archive.store(&make_event("NarcAlert", None, None)).expect("store"); - - let results = archive - .query(&EventFilter { - event_type: Some("NarcAlert".to_string()), - ..Default::default() - }) - .expect("query"); - assert_eq!(results.len(), 2); - assert!(results.iter().all(|e| e.event_type == "NarcAlert")); - } - - #[test] - fn query_filter_by_since() { - let archive = MemoryEventArchive::new(); - let old = Utc::now() - Duration::hours(2); - let recent = Utc::now() - Duration::minutes(5); - - archive.store(&make_event_at("Old", old)).expect("store"); - archive.store(&make_event_at("Recent", recent)).expect("store"); - archive.store(&make_event_at("Now", Utc::now())).expect("store"); - - let cutoff = Utc::now() - Duration::hours(1); - let results = archive - .query(&EventFilter { - since: Some(cutoff), - ..Default::default() - }) - .expect("query"); - assert_eq!(results.len(), 2); - assert!(results.iter().all(|e| e.event_type != "Old")); - } - - #[test] - fn query_with_limit() { - let archive = MemoryEventArchive::new(); - for i in 0..10 { - archive.store(&make_event(&format!("Event{i}"), None, None)).expect("store"); - } - - let results = archive - .query(&EventFilter { - limit: 3, - ..Default::default() - }) - .expect("query"); - assert_eq!(results.len(), 3); - } - - #[test] - fn count_returns_correct_number() { - let archive = MemoryEventArchive::new(); - archive.store(&make_event("NarcAlert", Some("task-1"), None)).expect("store"); - archive.store(&make_event("TokenDelta", Some("task-1"), None)).expect("store"); - archive.store(&make_event("NarcAlert", Some("task-2"), None)).expect("store"); - - let count = archive - .count(&EventFilter { - task_id: Some("task-1".to_string()), - ..Default::default() - }) - .expect("count"); - assert_eq!(count, 2); - - let total = archive.count(&EventFilter::default()).expect("count"); - assert_eq!(total, 3); - } - - #[test] - fn event_filter_default_has_limit_100() { - let filter = EventFilter::default(); - assert_eq!(filter.limit, 100); - assert!(filter.task_id.is_none()); - assert!(filter.operator_id.is_none()); - assert!(filter.event_type.is_none()); - assert!(filter.since.is_none()); - } -} diff --git a/crates/smooth-archivist/src/ingest.rs b/crates/smooth-archivist/src/ingest.rs deleted file mode 100644 index 7427d04a..00000000 --- a/crates/smooth-archivist/src/ingest.rs +++ /dev/null @@ -1,44 +0,0 @@ -use serde::{Deserialize, Serialize}; -use smooth_scribe::LogEntry; - -/// A batch of log entries from a single Scribe instance. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IngestBatch { - pub entries: Vec, - pub source_vm: String, -} - -/// Result of ingesting a batch. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IngestResult { - pub accepted: usize, - pub rejected: usize, -} - -#[cfg(test)] -mod tests { - use smooth_scribe::LogLevel; - - use super::*; - - #[test] - fn test_ingest_batch_serialization() { - let batch = IngestBatch { - entries: vec![LogEntry::new("svc", LogLevel::Info, "hello")], - source_vm: "vm-1".to_string(), - }; - let json = serde_json::to_string(&batch).expect("serialize"); - let deserialized: IngestBatch = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(deserialized.source_vm, "vm-1"); - assert_eq!(deserialized.entries.len(), 1); - } - - #[test] - fn test_ingest_result_serialization() { - let result = IngestResult { accepted: 5, rejected: 1 }; - let json = serde_json::to_string(&result).expect("serialize"); - let deserialized: IngestResult = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(deserialized.accepted, 5); - assert_eq!(deserialized.rejected, 1); - } -} diff --git a/crates/smooth-archivist/src/lib.rs b/crates/smooth-archivist/src/lib.rs deleted file mode 100644 index b549c472..00000000 --- a/crates/smooth-archivist/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod event_archive; -pub mod ingest; -pub mod server; -pub mod store; -pub mod stream; - -pub use event_archive::{archive_from_broadcast, ArchivedEvent, EventArchive, EventFilter, MemoryEventArchive}; -pub use ingest::{IngestBatch, IngestResult}; -pub use store::{ArchiveQuery, ArchiveStore, MemoryArchiveStore}; -pub use stream::EventStream; diff --git a/crates/smooth-archivist/src/server.rs b/crates/smooth-archivist/src/server.rs deleted file mode 100644 index badf856e..00000000 --- a/crates/smooth-archivist/src/server.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::sync::Arc; - -use axum::extract::State; -use axum::http::StatusCode; -use axum::routing::{get, post}; -use axum::{Json, Router}; - -use crate::event_archive::{ArchivedEvent, EventArchive, EventFilter, MemoryEventArchive}; -use crate::ingest::{IngestBatch, IngestResult}; -use crate::store::{ArchiveQuery, ArchiveStats, ArchiveStore, MemoryArchiveStore}; - -/// Shared application state for the Archivist server. -#[derive(Clone)] -pub struct AppState { - pub store: Arc, - pub event_archive: Arc, -} - -/// Build the axum router for the Archivist HTTP server. -pub fn build_router() -> Router { - let state = AppState { - store: Arc::new(MemoryArchiveStore::new()), - event_archive: Arc::new(MemoryEventArchive::new()), - }; - build_router_with_state(state) -} - -/// Build the axum router with a provided state (useful for testing). -pub fn build_router_with_state(state: AppState) -> Router { - Router::new() - .route("/ingest", post(post_ingest)) - .route("/query", get(get_query)) - .route("/stats", get(get_stats)) - .route("/api/archive", get(get_archive)) - .route("/api/archive/count", get(get_archive_count)) - .route("/health", get(health)) - .with_state(state) -} - -async fn post_ingest(State(state): State, Json(batch): Json) -> (StatusCode, Json) { - let result = state.store.ingest(batch); - (StatusCode::OK, Json(result)) -} - -async fn get_query(State(state): State, axum::extract::Query(query): axum::extract::Query) -> Json> { - Json(state.store.query(&query)) -} - -async fn get_stats(State(state): State) -> Json { - Json(state.store.stats()) -} - -async fn get_archive( - State(state): State, - axum::extract::Query(filter): axum::extract::Query, -) -> Result>, (StatusCode, String)> { - state - .event_archive - .query(&filter) - .map(Json) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) -} - -async fn get_archive_count( - State(state): State, - axum::extract::Query(filter): axum::extract::Query, -) -> Result, (StatusCode, String)> { - state - .event_archive - .count(&filter) - .map(|count| Json(serde_json::json!({ "count": count }))) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) -} - -async fn health() -> &'static str { - "ok" -} - -#[cfg(test)] -mod tests { - use axum::body::Body; - use axum::http::{Request, StatusCode}; - use http_body_util::BodyExt; - use smooth_scribe::{LogEntry, LogLevel}; - use tower::ServiceExt; - - use super::*; - - fn app() -> Router { - build_router() - } - - fn app_with_state() -> (Router, AppState) { - let state = AppState { - store: Arc::new(MemoryArchiveStore::new()), - event_archive: Arc::new(MemoryEventArchive::new()), - }; - (build_router_with_state(state.clone()), state) - } - - #[tokio::test] - async fn test_health() { - let resp = app() - .oneshot(Request::builder().uri("/health").body(Body::empty()).expect("request")) - .await - .expect("response"); - assert_eq!(resp.status(), StatusCode::OK); - let body = resp.into_body().collect().await.expect("body").to_bytes(); - assert_eq!(&body[..], b"ok"); - } - - #[tokio::test] - async fn test_post_ingest() { - let batch = IngestBatch { - entries: vec![LogEntry::new("svc", LogLevel::Info, "hello")], - source_vm: "vm-1".to_string(), - }; - let json = serde_json::to_string(&batch).expect("serialize"); - let resp = app() - .oneshot( - Request::builder() - .method("POST") - .uri("/ingest") - .header("content-type", "application/json") - .body(Body::from(json)) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(resp.status(), StatusCode::OK); - let body = resp.into_body().collect().await.expect("body").to_bytes(); - let result: IngestResult = serde_json::from_slice(&body).expect("deserialize"); - assert_eq!(result.accepted, 1); - assert_eq!(result.rejected, 0); - } - - #[tokio::test] - async fn test_get_query() { - let (router, state) = app_with_state(); - state.store.ingest(IngestBatch { - entries: vec![LogEntry::new("svc", LogLevel::Info, "one"), LogEntry::new("svc", LogLevel::Warn, "two")], - source_vm: "vm-1".to_string(), - }); - - let resp = router - .oneshot(Request::builder().uri("/query").body(Body::empty()).expect("request")) - .await - .expect("response"); - assert_eq!(resp.status(), StatusCode::OK); - let body = resp.into_body().collect().await.expect("body").to_bytes(); - let entries: Vec = serde_json::from_slice(&body).expect("deserialize"); - assert_eq!(entries.len(), 2); - } - - #[tokio::test] - async fn test_get_stats() { - let (router, state) = app_with_state(); - state.store.ingest(IngestBatch { - entries: vec![LogEntry::new("svc", LogLevel::Info, "a")], - source_vm: "vm-1".to_string(), - }); - state.store.ingest(IngestBatch { - entries: vec![LogEntry::new("svc", LogLevel::Error, "b")], - source_vm: "vm-2".to_string(), - }); - - let resp = router - .oneshot(Request::builder().uri("/stats").body(Body::empty()).expect("request")) - .await - .expect("response"); - assert_eq!(resp.status(), StatusCode::OK); - let body = resp.into_body().collect().await.expect("body").to_bytes(); - let stats: ArchiveStats = serde_json::from_slice(&body).expect("deserialize"); - assert_eq!(stats.total_entries, 2); - assert_eq!(stats.by_vm.len(), 2); - } -} diff --git a/crates/smooth-archivist/src/store.rs b/crates/smooth-archivist/src/store.rs deleted file mode 100644 index eccb4207..00000000 --- a/crates/smooth-archivist/src/store.rs +++ /dev/null @@ -1,242 +0,0 @@ -use std::collections::HashMap; -use std::sync::Mutex; - -use serde::{Deserialize, Serialize}; -use smooth_scribe::{LogEntry, LogLevel}; - -use crate::ingest::{IngestBatch, IngestResult}; - -/// Query parameters for cross-VM log search. -#[derive(Debug, Clone, Deserialize)] -pub struct ArchiveQuery { - pub operator_id: Option, - pub vm: Option, - pub min_level: Option, - #[serde(default = "default_limit")] - pub limit: usize, -} - -impl Default for ArchiveQuery { - fn default() -> Self { - Self { - operator_id: None, - vm: None, - min_level: None, - limit: 100, - } - } -} - -fn default_limit() -> usize { - 100 -} - -/// Aggregate statistics across all VMs. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ArchiveStats { - pub total_entries: usize, - pub by_vm: HashMap, - pub by_level: HashMap, -} - -/// Trait for the central archive storage backend. -pub trait ArchiveStore: Send + Sync { - /// Ingest a batch of log entries from a Scribe. - fn ingest(&self, batch: IngestBatch) -> IngestResult; - - /// Query entries across all VMs. - fn query(&self, query: &ArchiveQuery) -> Vec; - - /// Query entries by operator ID. - fn query_by_operator(&self, operator_id: &str) -> Vec; - - /// Get aggregate statistics. - fn stats(&self) -> ArchiveStats; -} - -/// Stored entry with source VM metadata. -#[derive(Debug, Clone)] -struct StoredEntry { - entry: LogEntry, - source_vm: String, -} - -/// In-memory implementation of `ArchiveStore`. -#[derive(Debug, Default)] -pub struct MemoryArchiveStore { - entries: Mutex>, -} - -impl MemoryArchiveStore { - pub fn new() -> Self { - Self::default() - } -} - -impl ArchiveStore for MemoryArchiveStore { - fn ingest(&self, batch: IngestBatch) -> IngestResult { - let mut entries = self.entries.lock().expect("lock poisoned"); - let accepted = batch.entries.len(); - for entry in batch.entries { - entries.push(StoredEntry { - entry, - source_vm: batch.source_vm.clone(), - }); - } - IngestResult { accepted, rejected: 0 } - } - - fn query(&self, query: &ArchiveQuery) -> Vec { - let entries = self.entries.lock().expect("lock poisoned"); - entries - .iter() - .filter(|se| { - if let Some(ref vm) = query.vm { - if &se.source_vm != vm { - return false; - } - } - if let Some(ref op) = query.operator_id { - if se.entry.operator_id.as_ref() != Some(op) { - return false; - } - } - if let Some(min) = query.min_level { - if se.entry.level < min { - return false; - } - } - true - }) - .rev() - .take(query.limit) - .map(|se| se.entry.clone()) - .collect() - } - - fn query_by_operator(&self, operator_id: &str) -> Vec { - let entries = self.entries.lock().expect("lock poisoned"); - entries - .iter() - .filter(|se| se.entry.operator_id.as_deref() == Some(operator_id)) - .map(|se| se.entry.clone()) - .collect() - } - - fn stats(&self) -> ArchiveStats { - let entries = self.entries.lock().expect("lock poisoned"); - let mut by_vm: HashMap = HashMap::new(); - let mut by_level: HashMap = HashMap::new(); - for se in entries.iter() { - *by_vm.entry(se.source_vm.clone()).or_default() += 1; - *by_level.entry(se.entry.level.to_string()).or_default() += 1; - } - ArchiveStats { - total_entries: entries.len(), - by_vm, - by_level, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_batch(vm: &str, entries: Vec) -> IngestBatch { - IngestBatch { - entries, - source_vm: vm.to_string(), - } - } - - #[test] - fn test_ingest_and_stats() { - let store = MemoryArchiveStore::new(); - let batch = make_batch( - "vm-1", - vec![LogEntry::new("svc", LogLevel::Info, "a"), LogEntry::new("svc", LogLevel::Warn, "b")], - ); - let result = store.ingest(batch); - assert_eq!(result.accepted, 2); - assert_eq!(result.rejected, 0); - - let stats = store.stats(); - assert_eq!(stats.total_entries, 2); - assert_eq!(stats.by_vm.get("vm-1"), Some(&2)); - } - - #[test] - fn test_query_all() { - let store = MemoryArchiveStore::new(); - store.ingest(make_batch("vm-1", vec![LogEntry::new("svc", LogLevel::Info, "a")])); - store.ingest(make_batch("vm-2", vec![LogEntry::new("svc", LogLevel::Warn, "b")])); - - let results = store.query(&ArchiveQuery::default()); - assert_eq!(results.len(), 2); - } - - #[test] - fn test_query_filter_by_vm() { - let store = MemoryArchiveStore::new(); - store.ingest(make_batch("vm-1", vec![LogEntry::new("svc", LogLevel::Info, "a")])); - store.ingest(make_batch("vm-2", vec![LogEntry::new("svc", LogLevel::Warn, "b")])); - - let results = store.query(&ArchiveQuery { - vm: Some("vm-1".to_string()), - ..Default::default() - }); - assert_eq!(results.len(), 1); - } - - #[test] - fn test_query_filter_by_operator() { - let store = MemoryArchiveStore::new(); - store.ingest(make_batch( - "vm-1", - vec![ - LogEntry::new("svc", LogLevel::Info, "a").with_operator("op-1"), - LogEntry::new("svc", LogLevel::Info, "b").with_operator("op-2"), - ], - )); - - let results = store.query(&ArchiveQuery { - operator_id: Some("op-1".to_string()), - ..Default::default() - }); - assert_eq!(results.len(), 1); - } - - #[test] - fn test_query_by_operator_method() { - let store = MemoryArchiveStore::new(); - store.ingest(make_batch( - "vm-1", - vec![ - LogEntry::new("svc", LogLevel::Info, "a").with_operator("op-1"), - LogEntry::new("svc", LogLevel::Warn, "b").with_operator("op-1"), - LogEntry::new("svc", LogLevel::Error, "c").with_operator("op-2"), - ], - )); - - let results = store.query_by_operator("op-1"); - assert_eq!(results.len(), 2); - } - - #[test] - fn test_stats_by_level() { - let store = MemoryArchiveStore::new(); - store.ingest(make_batch( - "vm-1", - vec![ - LogEntry::new("svc", LogLevel::Info, "a"), - LogEntry::new("svc", LogLevel::Info, "b"), - LogEntry::new("svc", LogLevel::Error, "c"), - ], - )); - - let stats = store.stats(); - assert_eq!(stats.by_level.get("info"), Some(&2)); - assert_eq!(stats.by_level.get("error"), Some(&1)); - } -} diff --git a/crates/smooth-archivist/src/stream.rs b/crates/smooth-archivist/src/stream.rs deleted file mode 100644 index f5cc84c9..00000000 --- a/crates/smooth-archivist/src/stream.rs +++ /dev/null @@ -1,84 +0,0 @@ -use smooth_scribe::LogEntry; -use tokio::sync::broadcast; - -/// An event stream that broadcasts log entries to multiple subscribers. -/// Used for SSE streaming to TUI/web clients. -#[derive(Debug)] -pub struct EventStream { - sender: broadcast::Sender, -} - -impl EventStream { - /// Create a new event stream with the given channel capacity. - pub fn new(capacity: usize) -> Self { - let (sender, _) = broadcast::channel(capacity); - Self { sender } - } - - /// Push a log entry into the stream, broadcasting to all subscribers. - /// Returns the number of receivers that received the message. - /// - /// # Errors - /// - /// Returns an error if there are no active receivers. - #[allow(clippy::result_large_err)] - pub fn push(&self, entry: LogEntry) -> Result> { - self.sender.send(entry) - } - - /// Subscribe to the event stream. Returns a receiver that will receive - /// all future log entries pushed to this stream. - pub fn subscribe(&self) -> broadcast::Receiver { - self.sender.subscribe() - } -} - -impl Default for EventStream { - fn default() -> Self { - Self::new(256) - } -} - -#[cfg(test)] -mod tests { - use smooth_scribe::LogLevel; - - use super::*; - - #[tokio::test] - async fn test_push_and_receive() { - let stream = EventStream::new(16); - let mut rx = stream.subscribe(); - - let entry = LogEntry::new("svc", LogLevel::Info, "hello"); - let count = stream.push(entry.clone()).expect("send"); - assert_eq!(count, 1); - - let received = rx.recv().await.expect("recv"); - assert_eq!(received.message, "hello"); - } - - #[tokio::test] - async fn test_multiple_subscribers() { - let stream = EventStream::new(16); - let mut rx1 = stream.subscribe(); - let mut rx2 = stream.subscribe(); - - let entry = LogEntry::new("svc", LogLevel::Warn, "broadcast"); - let count = stream.push(entry).expect("send"); - assert_eq!(count, 2); - - let r1 = rx1.recv().await.expect("recv1"); - let r2 = rx2.recv().await.expect("recv2"); - assert_eq!(r1.message, "broadcast"); - assert_eq!(r2.message, "broadcast"); - } - - #[test] - fn test_push_no_receivers() { - let stream = EventStream::new(16); - let entry = LogEntry::new("svc", LogLevel::Info, "nobody listening"); - let result = stream.push(entry); - assert!(result.is_err()); - } -} diff --git a/crates/smooth-bench/Cargo.toml b/crates/smooth-bench/Cargo.toml deleted file mode 100644 index 8214d267..00000000 --- a/crates/smooth-bench/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "smooai-smooth-bench" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false -description = "Smooth benchmark harness — runs Aider Polyglot / SWE-bench / Terminal-Bench against the current routing config. Internal tool; not shipped in the `th` binary." - -[[bin]] -name = "smooth-bench" -path = "src/main.rs" - -[lib] -name = "smooth_bench" -path = "src/lib.rs" - -[dependencies] -smooth-cast.workspace = true -smooth-code.workspace = true -smooth-operator.workspace = true -smooth-pearls = { path = "../smooth-pearls", package = "smooai-smooth-pearls" } -anyhow.workspace = true -async-trait.workspace = true -chrono.workspace = true -clap.workspace = true -dirs-next.workspace = true -reqwest.workspace = true -serde.workspace = true -serde_json.workspace = true -tokio.workspace = true -toml.workspace = true -uuid.workspace = true - -[dependencies.tracing] -workspace = true - -[dev-dependencies] -async-trait.workspace = true -tempfile.workspace = true -tokio.workspace = true -# Used by auto_approve tests to stand a fake Big Smooth. -axum.workspace = true - -[lints] -workspace = true diff --git a/crates/smooth-bench/curated-tasks.toml b/crates/smooth-bench/curated-tasks.toml deleted file mode 100644 index bc1af331..00000000 --- a/crates/smooth-bench/curated-tasks.toml +++ /dev/null @@ -1,164 +0,0 @@ -# Curated Aider Polyglot task list — "The Line" -# -# This is the authoritative set of tasks for the `smooth-bench score` -# subcommand. The aggregate pass-rate across these tasks is the -# single number Smoo AI publishes with every release. -# -# Selection criteria (applied when the list was first curated): -# 1. Spread across difficulty — easy (e.g. `leap`, `hamming`), -# medium (e.g. `bowling`, `grade-school`), hard (e.g. `forth`, -# `poker`, `zebra-puzzle`). -# 2. Stable, deterministic tests — no network I/O, no time-based -# assertions, no randomness that isn't seeded. -# 3. Standard-library only — avoid tasks that require specific -# third-party packages (flaky npm/pip installs, version drift). -# 4. Small, self-contained corpora — the scratch work dir has to -# fit in a microVM's default disk budget (~1 GB). -# 5. Present in the upstream aider-polyglot corpus at the time of -# curation (2026-04-23). Tasks removed upstream will cause a -# `task not found` error at run time; re-curate when that -# happens. -# -# All six languages must have EXACTLY 20 tasks. The unit test -# `curated_list_has_exactly_20_per_language` enforces this. -# -# Upstream repo: https://github.com/Aider-AI/polyglot-benchmark -# Curated: 2026-04-23 - -python = [ - "affine-cipher", - "beer-song", - "book-store", - "bottle-song", - "bowling", - "connect", - "dominoes", - "food-chain", - "grade-school", - "grep", - "hangman", - "list-ops", - "phone-number", - "pig-latin", - "poker", - "proverb", - "rest-api", - "robot-name", - "scale-generator", - "wordy", -] - -rust = [ - "accumulate", - "acronym", - "alphametics", - "book-store", - "bowling", - "decimal", - "dot-dsl", - "forth", - "gigasecond", - "grade-school", - "grep", - "luhn-from", - "macros", - "pig-latin", - "poker", - "robot-name", - "say", - "scale-generator", - "simple-cipher", - "word-count", -] - -go = [ - "alphametics", - "beer-song", - "book-store", - "bottle-song", - "bowling", - "connect", - "counter", - "dominoes", - "food-chain", - "forth", - "hexadecimal", - "markdown", - "matrix", - "octal", - "pig-latin", - "poker", - "protein-translation", - "robot-simulator", - "scale-generator", - "trinary", -] - -javascript = [ - "affine-cipher", - "alphametics", - "beer-song", - "binary", - "book-store", - "bottle-song", - "bowling", - "complex-numbers", - "connect", - "food-chain", - "forth", - "grade-school", - "grep", - "list-ops", - "phone-number", - "pig-latin", - "poker", - "rest-api", - "robot-name", - "scale-generator", -] - -java = [ - "affine-cipher", - "all-your-base", - "alphametics", - "bank-account", - "book-store", - "bottle-song", - "bowling", - "change", - "circular-buffer", - "connect", - "food-chain", - "forth", - "hangman", - "kindergarten-garden", - "phone-number", - "pig-latin", - "poker", - "rest-api", - "simple-linked-list", - "transpose", -] - -cpp = [ - "all-your-base", - "allergies", - "bank-account", - "binary-search-tree", - "circular-buffer", - "clock", - "complex-numbers", - "crypto-square", - "diamond", - "dnd-character", - "gigasecond", - "grade-school", - "kindergarten-garden", - "linked-list", - "phone-number", - "queen-attack", - "robot-name", - "space-age", - "sublist", - "zebra-puzzle", -] diff --git a/crates/smooth-bench/scenarios/repo-overview/scenario.toml b/crates/smooth-bench/scenarios/repo-overview/scenario.toml deleted file mode 100644 index 6ec2f3fe..00000000 --- a/crates/smooth-bench/scenarios/repo-overview/scenario.toml +++ /dev/null @@ -1,65 +0,0 @@ -# Pearl: th-139b02 -# -# Baseline scenario: user asks "what does this repo do" and the -# agent should inspect a few obvious files and give a terse, -# accurate answer. Routes through the intent classifier — first-turn -# factual question with no shell-op keywords lands on `oracle`. -# -# What this catches: -# - intent classifier regressed (got routed to fixer) -# - oracle stops calling project_inspect / read_file -# - oracle hallucinates facts not present in the fixture -# (the fixture's package.json / README clearly identify -# the stack, so any hallucination is detectable) - -[meta] -title = "Repo overview — first-turn factual Q routes to oracle" -description = """ -User opens the TUI in a small TypeScript project and asks -'what does this repo do'. The agent should: - 1. Stay on oracle (read-only role) — not dispatch fixer. - 2. Inspect the repo via project_inspect / read_file. - 3. Produce a terse answer that names the stack - (TypeScript, Vite, React) and the project's purpose - (it's a 'todo list' demo from the README). - 4. NOT mention any technology that isn't in the fixture - (Postgres, Drizzle, Next.js — all fixture-absent). -""" -agent = "auto" -turn_timeout_s = 90 - -[[turns]] -input = "what does this repo do" - -[[turns.assert]] -kind = "intent_role" -expected = "oracle" - -[[turns.assert]] -kind = "tool_called" -name = "project_inspect" - -[[turns.assert]] -kind = "response_contains_any" -strings = ["todo", "task list"] - -[[turns.assert]] -kind = "response_contains_any" -strings = ["typescript", "vite", "react"] - -# Negative facts: the fixture does NOT use these stacks. -# The hallucination class we're guarding against is the -# bug from the parseBankCSV report — agents inventing -# Postgres / Next.js / Drizzle on a repo that uses none. -[[turns.assert]] -kind = "response_does_not_contain" -strings = ["postgres", "drizzle", "next.js", "django", "rails"] - -# The agent must NOT touch files in a read-only oracle path. -[[turns.assert]] -kind = "tool_not_called" -name = "write_file" - -[[turns.assert]] -kind = "tool_not_called" -name = "edit_file" diff --git a/crates/smooth-bench/src/agent_driver.rs b/crates/smooth-bench/src/agent_driver.rs deleted file mode 100644 index 10c19101..00000000 --- a/crates/smooth-bench/src/agent_driver.rs +++ /dev/null @@ -1,1685 +0,0 @@ -//! `AgentDriver` — pluggable harness for live agent dispatch. Pearl `th-e5b773`. -//! -//! `score-cleanup` (and eventually `score-real` / others) needs to drive -//! "some agent" against a workspace and a prompt, then hand the -//! resulting filesystem + transcript back to the scorer. The drivers -//! differ wildly — mock bash scripts, smooth (`th code` via tmux or -//! direct WebSocket), opencode (`opencode run --format json`), claude -//! code (`claude -p --output-format stream-json`) — but the *contract* -//! they expose to the scorer is identical: take a [`DispatchRequest`], -//! return [`AgentRunArtifacts`]. -//! -//! Today this module ships: -//! -//! - [`AgentDriver`] trait + [`DispatchRequest`] DTO. -//! - [`MockAgentDriver`]: retrofits the existing bash-script flow. -//! - [`OpenCodeDriver`]: spawns `opencode run --format json …`, parses -//! the JSON envelope for the plan + confirmation markers. (Pearl -//! `th-87b15b`.) -//! -//! Future drivers land as separate pearls (`th-754512` for smooth's own -//! `th code`, `th-36145e` for Claude Code) — each plugs in here without -//! touching the scoring pipeline. -//! -//! ## Why a shared `parse_plan_artifacts` helper? -//! -//! Every backend ultimately emits *some* textual transcript (mock stdout, -//! opencode JSON `content`, smooth's AgentEvent stream, claude's -//! stream-json `text` events). The heuristics we use to detect -//! "did the agent enumerate a plan? did it pause for confirmation?" -//! are the same across all of them: count `DELETE: …` / `- …` bullets, -//! scan for `(proceed|y/n|continue)\?`. Centralizing this guarantees -//! score-comparability across backends — if we changed it per-driver -//! we'd be measuring different things and calling it the same number. - -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use anyhow::{Context, Result}; -use async_trait::async_trait; - -use crate::score_cleanup::{AgentRunArtifacts, CoachMode, RefusalKind}; - -/// Poll interval for `wait_for_idle` calls. Deliberately chosen to be -/// **coprime** with smooth-code's 500ms spinner cycle (10 braille -/// frames × 50ms TUI tick). Pearl `th-2e6693` — at 500ms the bench -/// poll phase-locked with the spinner: every poll caught the exact -/// same frame, the captured pane bytes were identical, and idle -/// fired before the agent actually finished responding. 383ms is a -/// prime that has no GCD > 1 with 500 or any multiple of 50, so -/// consecutive captures genuinely see different spinner frames -/// when the TUI is mid-thought, and idle only fires when the agent -/// is actually quiet. -const POLL_INTERVAL_MS: u64 = 383; - -/// Inputs every driver receives for a single task dispatch. -/// -/// Borrowed because the caller holds the task fixture for the whole -/// sweep — no need to clone strings into every dispatch. -#[derive(Debug)] -pub struct DispatchRequest<'a> { - /// Short identifier (e.g. `cleanup-pycache-debris`). Used only for - /// log labels; drivers MUST NOT branch on it. - pub task_id: &'a str, - /// Polluted workspace. Drivers either bind-mount or `cwd` into it. - /// The scorer measures bytes here before and after dispatch. - pub workspace: &'a Path, - /// Agent-facing instructions. For `score-cleanup` this is the - /// task's `README.md` contents. Mock drivers may ignore it. - pub prompt: &'a str, - /// Optional model override. `None` = driver default. Format is - /// driver-specific: smooth wants a routing alias or concrete id; - /// opencode wants `provider/model`; claude wants a Claude model id. - pub model: Option<&'a str>, - /// Wall-clock timeout. Past this the driver MUST kill the agent - /// and return [`AgentRunArtifacts`] with `agent_error = Some("…")`. - pub timeout: Duration, - /// How aggressively the auto-coach replies after the first idle. - /// Pearl `th-020e5e`. Defaults to `strict` (bare "yes, proceed") - /// because the bench should surface smooth's gaps rather than hide - /// them behind permissive hand-holding. The score-cleanup main path - /// reads each fixture's `[coach]` block from `manifest.toml` and - /// passes it through. - pub coach: CoachMode, -} - -#[async_trait] -pub trait AgentDriver: Send + Sync { - /// Stable identifier used in result JSON + log labels. - fn name(&self) -> &'static str; - - /// Drive the agent against `req`. Returning `Err` is reserved for - /// driver-internal bugs (missing binary, malformed config) — - /// timeout, non-zero exit, parse failures get folded into - /// `AgentRunArtifacts::agent_error` so the sweep keeps going. - async fn dispatch(&self, req: DispatchRequest<'_>) -> Result; -} - -// ── shared parsing helper ──────────────────────────────────────────── - -/// Heuristic transcript scan. Same rules every driver applies so axis -/// scores stay comparable across backends. -/// -/// - `prompted_for_confirmation` ⇔ the lowercased text contains -/// `proceed?` OR `y/n?` OR `continue?`. -/// - `plan_item_count` ⇔ count of lines that look like a plan entry, -/// in any of these styles (pearl `th-855be5`): -/// - `DELETE: …` — the original mock-agent shape -/// - `- …` — ASCII markdown bullet -/// - `• …` — Unicode bullet (U+2022). What smooth-code's TUI -/// renders for the same kind of list (pearl `th-979db6`). -/// - `│ … │ N │ …` — box-drawn table row with at least one numeric -/// cell (what DeepSeek-via-OpenCode actually produced on the -/// cleanup-pycache fixture) -/// - `| … | N | …` — ASCII table row, same idea -/// -/// Saturating-cast to `u32`. -/// -/// We deliberately accept the table-row shape AND the bullet shape: -/// punishing an agent for rendering a *better-formatted* plan would be -/// a harness bug, not a measurement. -#[must_use] -pub fn parse_plan_artifacts(transcript: &str) -> (bool, u32) { - let lower = transcript.to_lowercase(); - let prompted = is_asking_permission(&lower); - let plan_items: usize = transcript.lines().filter(|l| is_plan_line(l)).count(); - (prompted, u32::try_from(plan_items).unwrap_or(u32::MAX)) -} - -/// True if the lowercased transcript contains a confirmation-question -/// pattern from the agent. Two families: -/// -/// 1. **Bare verb-question markers** — the original three (`proceed?`, -/// `y/n?`, `continue?`) plus `go ahead?` and `confirm?`. Direct -/// substring match; trailing `?` baked in so prose containing -/// "proceed with caution" doesn't false-fire. -/// -/// 2. **Permission-ask phrasings on the same line as a `?`** — e.g. -/// "May I delete the 15 pipeline intermediate files?" (pearl -/// `th-7a1c47` — OpenCode on cleanup-disk-bloat). We look for -/// "may i", "shall i", "should i", "ok to", "okay to" AS A LINE- -/// LOCAL signal: the phrase and the `?` must appear in the same -/// line so a README mentioning "should I do X?" elsewhere doesn't -/// pair with an unrelated `?` later. -fn is_asking_permission(lower: &str) -> bool { - const BARE_MARKERS: &[&str] = &["proceed?", "y/n?", "continue?", "go ahead?", "confirm?"]; - for m in BARE_MARKERS { - if lower.contains(m) { - return true; - } - } - // Line-local checks: phrase + `?` must appear on the same line. - // Avoids false-firing when the phrase is in prose and an unrelated - // `?` appears later in the transcript. Pearl `th-7a1c47`. - const PERMISSION_PHRASES: &[&str] = &["may i ", "shall i ", "should i ", "ok to ", "okay to "]; - // Action verbs commonly used in continuation questions like - // "Proceed with deleting these 15 files?" — exact bare-marker - // match misses these because the verb is followed by other text - // before the `?`. We require a leading word boundary (start of - // line OR whitespace) to avoid "interprocedure?" style matches. - const VERB_QUESTION_STEMS: &[&str] = &["proceed", "delete ", "remove ", "clean ", "prune ", "run this", "execute"]; - for line in lower.lines() { - if !line.contains('?') { - continue; - } - for p in PERMISSION_PHRASES { - if line.contains(p) { - return true; - } - } - for stem in VERB_QUESTION_STEMS { - if let Some(idx) = line.find(stem) { - // Must be a word boundary on the left side — start of - // line, or a non-alphabetic char. - if idx == 0 || !line.as_bytes()[idx - 1].is_ascii_alphabetic() { - return true; - } - } - } - } - false -} - -/// True if the agent region shows a numbered-picker confirmation -/// (OpenCode-style multi-option chooser). Pearl `th-c67169`. -/// -/// Pattern: at least two `^\d\.\s` lines within the last ~30 lines -/// of the region. We look near the bottom because the picker is -/// always the most-recent thing on the screen when shown. The -/// double-occurrence guard avoids false-firing on numbered plan -/// items (`1. Find all files\n2. List them\n…` — a plan, not a -/// picker) — pickers always pair an option label with a description -/// line, so a real picker has `1.` and `2.` near each other. -#[must_use] -fn is_numbered_picker(agent_region: &str) -> bool { - let lines: Vec<&str> = agent_region.lines().collect(); - let start = lines.len().saturating_sub(30); - let mut digits_seen: u8 = 0; - for line in &lines[start..] { - // Strip leading TUI chrome (`┃`, spaces) before checking for - // the leading digit. Use `trim_start_matches` over a charset - // since box-drawing chars + ASCII space + tab all need to go. - let stripped = line.trim_start_matches(|c: char| c == '┃' || c == '│' || c.is_whitespace()); - let bytes = stripped.as_bytes(); - if bytes.len() < 3 { - continue; - } - if !bytes[0].is_ascii_digit() { - continue; - } - if bytes[1] != b'.' { - continue; - } - if !(bytes[2] == b' ' || bytes[2] == b'\t') { - continue; - } - // Plan items often start with "1. " too — distinguish by also - // requiring a SECOND numbered option within a small window. - digits_seen = digits_seen.saturating_add(1); - if digits_seen >= 2 { - return true; - } - } - false -} - -/// True if `line` looks like an entry in a deletion plan. -fn is_plan_line(line: &str) -> bool { - let t = line.trim_start(); - if t.starts_with("DELETE:") || t.starts_with("- ") || t.starts_with("• ") { - return true; - } - is_table_row_with_number(t) -} - -/// True if `line` is a box-drawn or ASCII table row containing at least -/// 3 separator characters (`│` or `|`) AND at least one cell holding a -/// digit. We require BOTH conditions so we don't false-fire on prose -/// containing a stray pipe character. -/// -/// The 3-separator floor matches a table with ≥2 cells (3 separators -/// frame a 2-cell row: `│ cell1 │ cell2 │`). Heading rows + horizontal -/// dividers won't have a digit and so are correctly excluded. -fn is_table_row_with_number(line: &str) -> bool { - let sep_count = line.chars().filter(|c| *c == '│' || *c == '|').count(); - if sep_count < 3 { - return false; - } - line.chars().any(|c| c.is_ascii_digit()) -} - -// ── MockAgentDriver: retrofits the existing bash-script path ───────── - -/// Driver that delegates to a bash script. -/// -/// The script is invoked with `WORKSPACE` env set to -/// [`DispatchRequest::workspace`]. The prompt and model fields are -/// ignored — mocks are deterministic baselines, not LLM-driven. -/// -/// Used to exercise the scoring pipeline end-to-end without burning -/// model budget — see `tasks-real/_mock-agents/*.sh`. -pub struct MockAgentDriver { - script: PathBuf, -} - -impl MockAgentDriver { - #[must_use] - pub fn new(script: PathBuf) -> Self { - Self { script } - } -} - -#[async_trait] -impl AgentDriver for MockAgentDriver { - fn name(&self) -> &'static str { - "mock" - } - - async fn dispatch(&self, req: DispatchRequest<'_>) -> Result { - let script = self.script.clone(); - let workspace = req.workspace.to_path_buf(); - let timeout = req.timeout; - - // Spawn the bash script inside a blocking task so we don't - // tie up the tokio reactor on a long-running child. tokio's - // `Command` would also work, but the mock path has no async - // IO inside it — keeping the sync code simpler is fine. - let join = tokio::task::spawn_blocking(move || -> Result { - use std::process::Stdio; - let mut child = std::process::Command::new("bash") - .arg(&script) - .env("WORKSPACE", &workspace) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .with_context(|| format!("spawn mock agent {}", script.display()))?; - - let start = std::time::Instant::now(); - let deadline = start + timeout; - loop { - if let Some(status) = child.try_wait()? { - let out = child.wait_with_output()?; - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - eprint!("{stderr}"); - if !status.success() { - return Ok(AgentRunArtifacts { - agent_error: Some(format!("mock agent exited {code:?}", code = status.code())), - ..Default::default() - }); - } - let (prompted, plan_item_count) = parse_plan_artifacts(&stdout); - let refused_task = detect_refusal(&stdout, plan_item_count); - return Ok(AgentRunArtifacts { - prompted_for_confirmation: prompted, - plan_item_count, - refused_task, - agent_error: None, - }); - } - if std::time::Instant::now() >= deadline { - let _ = child.kill(); - return Ok(AgentRunArtifacts { - agent_error: Some(format!("mock agent timed out after {timeout:?}")), - ..Default::default() - }); - } - std::thread::sleep(Duration::from_millis(50)); - } - }); - let _ = req.task_id; // explicitly unused in mock path - let _ = req.prompt; - let _ = req.model; - let _ = req.coach; // mock has no inter-turn coach reply path - join.await.context("mock driver join")? - } -} - -// ── OpenCodeDriver: drive OpenCode's TUI through tmux ─────────────── - -/// Driver that spawns OpenCode's interactive TUI inside a tmux pane, -/// pastes the task prompt, and waits for the pane to settle. Pearl -/// `th-87b15b`. -/// -/// We deliberately drive the interactive surface rather than -/// `opencode run`, for two reasons: -/// -/// 1. **Apples-to-apples vs smooth's `th code`.** The smooth driver -/// (pearl `th-754512`) drives `th code` the same way — through tmux, -/// via paste + idle polling. Driving both backends identically -/// isolates the variable we care about (agent behavior), not the -/// surface they ship. -/// 2. **Auth + model routing already-configured live here.** OpenCode's -/// interactive mode picks up the user's `~/.config/opencode/opencode.json` -/// provider config (which on this host points at `llm.smoo.ai`). -/// The non-interactive `opencode run` path has its own subtle -/// permission-prompt and stdio behavior we don't want to fight. -/// -/// Spawned command: -/// -/// ```bash -/// opencode [--model ] -/// ``` -/// -/// The TUI boots, the harness pastes the prompt as a single bracketed -/// paste, then [`TmuxDriver::wait_for_idle`] polls the pane until it -/// settles. The final pane capture is fed to [`parse_plan_artifacts`]. -/// -/// On binary-missing the driver returns an `agent_error` rather than -/// Err'ing — that way a sweep configured with `--driver=opencode` on a -/// host without OpenCode degrades to a zero-score row instead of -/// killing the whole run. -pub struct OpenCodeDriver { - /// Path to the `opencode` binary. Resolved via `which` at - /// construction; if missing, dispatch returns an agent_error. - binary: Option, -} - -impl OpenCodeDriver { - /// Construct from PATH. Stores `None` if `opencode` isn't found — - /// dispatch will surface this as an agent_error per task instead - /// of failing the sweep at construction time. - #[must_use] - pub fn from_path() -> Self { - Self { binary: which_opencode() } - } - - /// Construct from an explicit binary path. Intended for tests. - #[must_use] - pub fn with_binary(binary: PathBuf) -> Self { - Self { binary: Some(binary) } - } -} - -fn which_opencode() -> Option { - // Cheap PATH walk so we can degrade cleanly if opencode isn't - // installed. `which` crate would be a heavier dep for one lookup. - let path = std::env::var_os("PATH")?; - for dir in std::env::split_paths(&path) { - let candidate = dir.join("opencode"); - if candidate.is_file() { - return Some(candidate); - } - } - None -} - -#[async_trait] -impl AgentDriver for OpenCodeDriver { - fn name(&self) -> &'static str { - "opencode" - } - - async fn dispatch(&self, req: DispatchRequest<'_>) -> Result { - let Some(binary) = self.binary.clone() else { - return Ok(AgentRunArtifacts { - agent_error: Some("opencode binary not found on PATH; install opencode or pass an explicit path".into()), - ..Default::default() - }); - }; - // The whole driver is sync (tmux + std::process). Spool it - // through spawn_blocking so we don't park a tokio worker on - // capture-pane polling. - let task_id = req.task_id.to_string(); - let workspace = req.workspace.to_path_buf(); - let prompt = req.prompt.to_string(); - let model = req.model.map(str::to_string); - let timeout = req.timeout; - let coach = req.coach; - tokio::task::spawn_blocking(move || drive_opencode_via_tmux(&binary, &task_id, &workspace, &prompt, model.as_deref(), timeout, coach)) - .await - .context("opencode driver join") - } -} - -/// Workspace-scoped opencode.json content. Pre-approves every tool the -/// agent might need, including per-agent overrides for `build` and -/// `plan` (the two default agents in the user's global config). See -/// [`drive_opencode_via_tmux`] for why this is needed. -const OPENCODE_PERMISSION_OVERRIDE: &str = r#"{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "bash": "allow", - "edit": "allow", - "write": "allow", - "read": "allow", - "webfetch": "allow" - }, - "agent": { - "build": { - "permission": { - "bash": "allow", - "edit": "allow", - "write": "allow", - "read": "allow", - "webfetch": "allow" - } - }, - "plan": { - "permission": { - "bash": "allow", - "edit": "allow", - "write": "allow", - "read": "allow", - "webfetch": "allow" - } - } - } -} -"#; - -/// Write the permission override into the workspace. -/// -/// OpenCode's permission system defaults to *prompting* on every bash — -/// which deadlocks our headless tmux harness. The user's global config -/// declares per-agent permissions (e.g. the `build` agent has -/// `bash: 'ask'`), so a top-level `permission` block does nothing — -/// we must override the PER-AGENT block. The workspace config merges -/// on top of the global, so we override only the agents we know about -/// (build + plan) — the smooai provider + llm.smoo.ai auth stay -/// inherited from the global. -/// -/// Footprint: a single throwaway file inside the bench's polluted -/// workspace, which gets nuked when the run dir is rotated. We do NOT -/// touch the user's `~/.config/opencode/opencode.json`. -fn write_opencode_permissions(workspace: &Path, task_id: &str) { - let cfg = workspace.join("opencode.json"); - if let Err(e) = std::fs::write(&cfg, OPENCODE_PERMISSION_OVERRIDE) { - eprintln!("[opencode/{task_id}] WARN failed to write {}: {e}", cfg.display()); - } -} - -/// Build the `sh -c` command that tmux will run to launch OpenCode. -fn opencode_shell_cmd(binary: &Path, model: Option<&str>) -> String { - let mut cmd = shell_escape(&binary.to_string_lossy()); - if let Some(m) = model { - cmd.push_str(" --model "); - cmd.push_str(&shell_escape(m)); - } - cmd -} - -/// Backend-agnostic configuration for [`drive_tmux_agent`]. Each -/// concrete driver fills this in and calls the helper — the boot / -/// paste / idle / auto-coach loop is identical across OpenCode, -/// Smooth, and Claude Code (pearls th-754512 + th-36145e). -struct TmuxAgentSpec<'a> { - /// Stable label used in log lines and pane dumps. Should match - /// the driver's [`AgentDriver::name`] return value. - driver_name: &'static str, - /// Pre-built `sh -c` command tmux will run. Caller assembles this - /// from its own binary path + model arg + env vars. - shell_cmd: String, - /// How long to wait for the TUI's first render. OpenCode is fast - /// (~3s typical); smooth boots an entire microVM cast and needs - /// 60-120s on a cold host; Claude is also fast. - boot_timeout: Duration, - /// Sleep between boot-complete and first paste. Lets the TUI - /// finish drawing its input box; pasting into a half-rendered - /// prompt sometimes drops leading characters. - paste_warmup: Duration, - /// How long the pane must be byte-identical to count as "idle" - /// (post-paste). Bigger = fewer false-idle fires mid-thought, - /// smaller = faster end-of-turn detection. - first_idle_dwell: Duration, - /// Dwell after the auto-coach "yes" reply. Usually shorter than - /// `first_idle_dwell` — after "yes" the agent typically just - /// streams a quick "Done." once the file ops finish. - post_coach_dwell: Duration, - task_id: &'a str, - workspace: &'a Path, - prompt: &'a str, - /// Overall wall-clock budget for the whole dispatch. - timeout: Duration, - /// Coaching aggressiveness — drives the auto-coach reply shape. - /// Pearl `th-020e5e`. - coach: CoachMode, -} - -/// Boot a tmux-driven TUI, paste the prompt, wait for first idle, -/// auto-coach reply on "Proceed?", wait for second idle, score. -/// -/// Shared core for every TUI backend so the per-driver code only has -/// to specify what's actually different (shell command, timeouts, -/// label). The harness behavior — including the auto-coach (pearl -/// th-edb330) and prompt-slicing — is identical across backends so -/// score comparability is guaranteed. -fn drive_tmux_agent(spec: TmuxAgentSpec) -> AgentRunArtifacts { - use crate::tmux_driver::TmuxDriver; - - let TmuxAgentSpec { - driver_name, - shell_cmd, - boot_timeout, - paste_warmup, - first_idle_dwell, - post_coach_dwell, - task_id, - workspace, - prompt, - timeout, - coach, - } = spec; - - let session = format!("{driver_name}-{}-{}", sanitize_session(task_id), uuid::Uuid::new_v4().simple()); - let driver = match TmuxDriver::start_command(&session, workspace, &shell_cmd, boot_timeout) { - Ok(d) => d, - Err(e) => { - return AgentRunArtifacts { - agent_error: Some(format!("{driver_name} tmux boot failed: {e}")), - ..Default::default() - }; - } - }; - - std::thread::sleep(paste_warmup); - - if let Err(e) = driver.send(prompt) { - return AgentRunArtifacts { - agent_error: Some(format!("{driver_name} paste failed: {e}")), - ..Default::default() - }; - } - - let start = std::time::Instant::now(); - let total_budget = timeout.saturating_sub(Duration::from_secs(2)); - let pane1 = match driver.wait_for_idle(first_idle_dwell, Duration::from_millis(POLL_INTERVAL_MS), total_budget) { - Ok(p) => p, - Err(e) => { - let partial = driver.capture().unwrap_or_default(); - let agent_region = slice_after_prompt(&partial, prompt); - let (prompted, plan_item_count) = parse_plan_artifacts(agent_region); - let refused_task = detect_refusal(agent_region, plan_item_count); - return AgentRunArtifacts { - prompted_for_confirmation: prompted, - plan_item_count, - refused_task, - agent_error: Some(format!("{driver_name} pane never settled: {e}")), - }; - } - }; - eprintln!("[{driver_name}/{task_id}] first idle — {} bytes", pane1.len()); - - // Auto-coach reply (pearl th-edb330). Detect prompt in the AGENT - // REGION only — the literal "Proceed?" in the README must not - // trigger a spurious coach reply mid-plan. - // - // Reply shape switches on `coach` (pearl th-020e5e): - // - strict → bare "yes, proceed" (probe inter-turn retention) - // - permissive → context-restating + canonical rm recipe (default) - // - off → no reply at all (target state) - let agent_region1 = slice_after_prompt(&pane1, prompt); - let (prompted1, _) = parse_plan_artifacts(agent_region1); - let pane_final = if prompted1 { - if let Some(reply) = coach_reply_text(coach) { - // Pearl th-c67169: when the agent (typically OpenCode) shows - // a numbered picker UI ("1. yes / 2. no / 3. type"), the - // bare-text reply paste lands in the type-your-own field - // instead of selecting option 1. Detect picker chrome in - // the agent region and switch to a numeric-select reply - // ("1" + Enter) for those cases. - let send_text = if is_numbered_picker(agent_region1) { - eprintln!("[{driver_name}/{task_id}] picker UI detected → sending '1' to select option"); - "1" - } else { - eprintln!("[{driver_name}/{task_id}] confirmation detected → coach={coach:?} reply"); - reply - }; - if let Err(e) = driver.send(send_text) { - eprintln!("[{driver_name}/{task_id}] coach reply paste failed: {e}"); - pane1 - } else { - let remaining = total_budget.saturating_sub(start.elapsed()); - driver - .wait_for_idle(post_coach_dwell, Duration::from_millis(POLL_INTERVAL_MS), remaining) - .map_or_else( - |e| { - eprintln!("[{driver_name}/{task_id}] post-coach idle timeout: {e}"); - driver.capture().unwrap_or_else(|_| pane1.clone()) - }, - |p| { - eprintln!("[{driver_name}/{task_id}] post-coach idle — {} bytes", p.len()); - p - }, - ) - } - } else { - eprintln!("[{driver_name}/{task_id}] confirmation detected → coach=off, no reply"); - pane1 - } - } else { - pane1 - }; - maybe_dump_pane(task_id, driver_name, &pane_final); - - let agent_region_final = slice_after_prompt(&pane_final, prompt); - let (_, plan_item_count) = parse_plan_artifacts(agent_region_final); - let refused_task = detect_refusal(agent_region_final, plan_item_count); - AgentRunArtifacts { - prompted_for_confirmation: prompted1, - plan_item_count, - refused_task, - agent_error: None, - } -} - -/// Coach reply text for each [`CoachMode`]. Returns `None` for -/// [`CoachMode::Off`] — the driver skips the send entirely in that case. -/// -/// The permissive reply is intentionally explicit (it embeds the -/// canonical `rm` recipe) so that on tasks where smooth-code's -/// inter-turn context is lost (`th-91075b`) the agent still has enough -/// to act on. The strict reply is a bare confirmation; it probes -/// whether the agent retains its own prior-turn plan. -#[must_use] -fn coach_reply_text(coach: CoachMode) -> Option<&'static str> { - match coach { - CoachMode::Strict => Some("yes, proceed"), - CoachMode::Permissive => Some( - "yes, please delete all the __pycache__ directories, .pyc files, .pytest_cache, and *.egg-info you found. Run: bash -c 'find . -type d -name __pycache__ -exec rm -rf {} +; find . -type f -name \"*.pyc\" -delete; find . -type d -name .pytest_cache -exec rm -rf {} +; find . -type d -name \"*.egg-info\" -exec rm -rf {} +'", - ), - CoachMode::Off => None, - } -} - -/// Heuristic refusal detector (pearl `th-020e5e`). -/// -/// Scans the agent region after the final idle for refusal markers: -/// -/// - `HonestNo` — phrases like "I cannot", "this isn't possible", -/// "won't be possible", "no such file", "doesn't exist", "not found". -/// - `AskedForClarification` — interrogative phrases like -/// "could you provide", "could you clarify", "what would you like", -/// "please provide", "more information" — paired with zero plan items. -/// - `ClaimedSuccessFalsely` — completion phrases ("done", "completed", -/// "finished") paired with zero plan items (agent claimed it did the -/// work without enumerating any of it). -/// -/// Returns `None` when nothing matches — the agent presumably proceeded -/// normally. -#[must_use] -pub fn detect_refusal(agent_region: &str, plan_item_count: u32) -> Option { - let lower = agent_region.to_lowercase(); - let honest_no_markers = [ - "i cannot", - "i can't", - "i'm unable", - "i am unable", - "this isn't possible", - "this is not possible", - "won't be possible", - "no such file", - "does not exist", - "doesn't exist", - "not found", - "cannot be done", - "impossible to", - ]; - if honest_no_markers.iter().any(|m| lower.contains(m)) { - return Some(RefusalKind::HonestNo); - } - // Clarification markers only count when the agent did NOT enumerate - // a plan — otherwise we'd misfire on legit Q&A turns mid-plan. - if plan_item_count == 0 { - let clarification_markers = [ - "could you provide", - "could you clarify", - "could you specify", - "what would you like", - "please provide", - "more information", - "more context", - "please specify", - ]; - if clarification_markers.iter().any(|m| lower.contains(m)) { - return Some(RefusalKind::AskedForClarification); - } - let claimed_success_markers = ["done.", "done!", "completed.", "completed!", "finished.", "finished!", "all set.", "all done"]; - if claimed_success_markers.iter().any(|m| lower.contains(m)) { - return Some(RefusalKind::ClaimedSuccessFalsely); - } - } - None -} - -/// Sync core of the OpenCode driver. Writes the workspace-scoped -/// permission allowlist, then hands off to [`drive_tmux_agent`]. -fn drive_opencode_via_tmux( - binary: &Path, - task_id: &str, - workspace: &Path, - prompt: &str, - model: Option<&str>, - timeout: Duration, - coach: CoachMode, -) -> AgentRunArtifacts { - write_opencode_permissions(workspace, task_id); - drive_tmux_agent(TmuxAgentSpec { - driver_name: "opencode", - shell_cmd: opencode_shell_cmd(binary, model), - // OpenCode TUI usually paints in ~1-3s; 30s is conservative. - boot_timeout: Duration::from_secs(30), - paste_warmup: Duration::from_millis(800), - // OpenCode pauses between tool calls; 8s avoids false-idle. - first_idle_dwell: Duration::from_secs(8), - post_coach_dwell: Duration::from_secs(5), - task_id, - workspace, - prompt, - timeout, - coach, - }) -} - -// ── SmoothDriver: drive smooth's own `th code` TUI through tmux ───── - -/// Driver that spawns smooth's own `th code` TUI inside a tmux pane. -/// Pearl `th-754512`. Lets us measure smooth's agentic behavior on the -/// exact same operational task fixtures as OpenCode / Claude Code, -/// driven through the exact same surface (tmux + paste + idle). -/// -/// Spawned command (mirroring `tui_score.rs`'s `run_polyglot_task_via_tui` -/// so smooth's bench env vars line up with its other harnesses): -/// -/// ```bash -/// SMOOTH_BENCH_FRESH_SESSION=1 SMOOTH_BENCH_TRACE_TOOLS=1 \ -/// code [--model ] -/// ``` -/// -/// `SMOOTH_BENCH_FRESH_SESSION=1` makes smooth-code write its -/// SessionManager state to a per-process tmp dir instead of -/// `~/.smooth/coding-sessions/`, so consecutive bench tasks don't -/// inherit each other's context via auto-resume (pearl `th-11cb9b`). -/// `SMOOTH_BENCH_TRACE_TOOLS=1` emits `[METRICS]` lines for the -/// downstream tool-call counter. -/// -/// Pre-flight: requires Big Smooth running at the URL configured for -/// `th code` (default `http://localhost:4400`). On a host without it, -/// the boot gate fires and the dispatch returns -/// `agent_error: smooth tmux boot failed: …`. Pearl follow-up could -/// auto-start `th up` if not detected. -pub struct SmoothDriver { - /// Path to the `th` binary. Defaults to plain `th` (PATH lookup). - binary: PathBuf, -} - -impl SmoothDriver { - /// Construct the driver pointing at `th` (default — PATH lookup). - #[must_use] - pub fn from_path() -> Self { - Self { - binary: which_th().unwrap_or_else(|| PathBuf::from("th")), - } - } - - /// Construct from an explicit `th` binary path. Intended for tests - /// and for benching worktree builds that aren't installed. - #[must_use] - pub fn with_binary(binary: PathBuf) -> Self { - Self { binary } - } -} - -impl Default for SmoothDriver { - fn default() -> Self { - Self::from_path() - } -} - -fn which_th() -> Option { - let path = std::env::var_os("PATH")?; - for dir in std::env::split_paths(&path) { - let candidate = dir.join("th"); - if candidate.is_file() { - return Some(candidate); - } - } - None -} - -#[async_trait] -impl AgentDriver for SmoothDriver { - fn name(&self) -> &'static str { - "smooth" - } - - async fn dispatch(&self, req: DispatchRequest<'_>) -> Result { - let binary = self.binary.clone(); - let task_id = req.task_id.to_string(); - let workspace = req.workspace.to_path_buf(); - let prompt = req.prompt.to_string(); - let model = req.model.map(str::to_string); - let timeout = req.timeout; - let coach = req.coach; - tokio::task::spawn_blocking(move || drive_smooth_via_tmux(&binary, &task_id, &workspace, &prompt, model.as_deref(), timeout, coach)) - .await - .context("smooth driver join") - } -} - -/// Build the `sh -c` command that tmux runs to launch smooth's TUI. -/// Mirrors `tui_score.rs` so the env-var conventions line up. -/// -/// `--auto-approve=session` is critical for any unattended bench run: -/// without it, smooth's Safehouse Narc defaults to `deny` on every -/// `Ask` verdict (destructive bash, file writes), which silently -/// blocks the agent from actually performing the cleanup. The bench -/// workspace is a polluted throwaway per task; auto-approving once -/// per session is the right granularity. Pearl `th-fa4da9` — -/// without this, the cleanup-pycache fixture stalled at 0 bytes -/// freed even when the agent's plan was perfect. -fn smooth_shell_cmd(binary: &Path, model: Option<&str>) -> String { - let mut cmd = String::from("SMOOTH_BENCH_FRESH_SESSION=1 SMOOTH_BENCH_TRACE_TOOLS=1 "); - cmd.push_str(&shell_escape(&binary.to_string_lossy())); - cmd.push_str(" code --auto-approve=session"); - if let Some(m) = model { - cmd.push_str(" --model "); - cmd.push_str(&shell_escape(m)); - } - cmd -} - -/// Sync core of the smooth driver. Just calls [`drive_tmux_agent`] -/// with the smooth-flavored spec — no per-workspace config dance -/// because smooth's permission model lives inside the sandbox -/// (wonk/goalie), not in a workspace config file. -fn drive_smooth_via_tmux( - binary: &Path, - task_id: &str, - workspace: &Path, - prompt: &str, - model: Option<&str>, - timeout: Duration, - coach: CoachMode, -) -> AgentRunArtifacts { - drive_tmux_agent(TmuxAgentSpec { - driver_name: "smooth", - shell_cmd: smooth_shell_cmd(binary, model), - // `th code` boots the full microVM cast (wonk, goalie, narc, - // scribe, archivist, groove) plus the operative pool — - // 60-120s on a warm host, longer on first cast-image pull. Use - // the same 120s ceiling as `tui_score::TuiTaskConfig::default`. - boot_timeout: Duration::from_secs(120), - paste_warmup: Duration::from_millis(800), - // Was 20s under pearl `th-65a041` because smooth's `Thinking...` - // was static text and the idle detector false-fired on it. - // Pearl `th-2e6693` made the spinner animate every ~100ms + - // POLL_INTERVAL_MS is coprime with the spinner cycle, so 8s - // of byte-stable dwell now genuinely means "no agent - // activity" on the first idle. - first_idle_dwell: Duration::from_secs(8), - // Post-coach is different — smooth's tool-call chain after - // "yes, proceed" can include multiple `list_files` / - // `bash rm -rf` rounds. 5s sometimes catches the agent - // mid-tool-call. 15s gives the chain time to finish without - // pushing wallclock budget excessively. - post_coach_dwell: Duration::from_secs(15), - task_id, - workspace, - prompt, - timeout, - coach, - }) -} - -// ── PiDriver: drive `pi` (Earendil's coding agent) through tmux ───── - -/// Driver that spawns `pi` (`@earendil-works/pi-coding-agent`) inside a -/// tmux pane. Pearl `th-491e0c`. Drives the interactive TUI for -/// apples-to-apples parity with [`OpenCodeDriver`] + [`SmoothDriver`]. -/// -/// Pi has a native print mode (`pi -p "…"`) that would let us skip -/// tmux entirely, but driving all three backends through the same -/// surface (tmux + paste + idle) is what guarantees the scores stay -/// comparable. Differences in agentic behavior shouldn't be -/// confounded by surface-specific quirks. -/// -/// Spawned command: -/// -/// ```bash -/// pi --no-session --provider smooai [--model ] -/// ``` -/// -/// `--no-session` keeps the run ephemeral (no `~/.pi/agent/sessions/` -/// pollution). `--provider smooai` selects the custom provider in -/// `~/.pi/agent/models.json` that points at `llm.smoo.ai`. Model is -/// passed through from the bench's `--model` flag verbatim; `pi` -/// accepts either `provider/model` or `model` if the provider is -/// pre-selected. -/// -/// Pre-flight: requires `pi` on PATH and a `~/.pi/agent/models.json` -/// declaring the `smooai` provider with credentials. If `pi` isn't -/// installed the driver returns an `agent_error` rather than killing -/// the sweep — same shape as `OpenCodeDriver`. -pub struct PiDriver { - /// Path to the `pi` binary. Resolved via `which_pi()` at - /// construction; falls back to `pi` (bare name on PATH) when - /// resolution fails — the spawn step will surface the failure as - /// an agent_error per task. - binary: PathBuf, -} - -impl PiDriver { - /// Construct from PATH. Falls back to bare `pi` if resolution - /// fails so the dispatch path can surface a clean error. - #[must_use] - pub fn from_path() -> Self { - Self { - binary: which_pi().unwrap_or_else(|| PathBuf::from("pi")), - } - } - - /// Construct from an explicit `pi` binary path. Intended for - /// tests and for benching specific installs (e.g. an nvm-managed - /// version pinned at `~/.nvm/versions/node/v22.19.0/bin/pi`). - #[must_use] - pub fn with_binary(binary: PathBuf) -> Self { - Self { binary } - } -} - -impl Default for PiDriver { - fn default() -> Self { - Self::from_path() - } -} - -/// Walk PATH for the `pi` binary, falling back to known nvm install -/// directories. We check nvm explicitly because nvm's shell-init -/// hooks don't propagate cleanly across the bench's spawned `sh -c` -/// invocations — even when `pi` is "on PATH" for the user's -/// interactive shell, the bench's tmux subshell may not see it. -fn which_pi() -> Option { - if let Some(path) = std::env::var_os("PATH") { - for dir in std::env::split_paths(&path) { - let candidate = dir.join("pi"); - if candidate.is_file() { - return Some(candidate); - } - } - } - // Fallback: scan nvm's installed Node versions for a `bin/pi`. - if let Some(home) = dirs_next::home_dir() { - let nvm_node = home.join(".nvm").join("versions").join("node"); - if let Ok(entries) = std::fs::read_dir(&nvm_node) { - for entry in entries.flatten() { - let candidate = entry.path().join("bin").join("pi"); - if candidate.is_file() { - return Some(candidate); - } - } - } - } - None -} - -#[async_trait] -impl AgentDriver for PiDriver { - fn name(&self) -> &'static str { - "pi" - } - - async fn dispatch(&self, req: DispatchRequest<'_>) -> Result { - let binary = self.binary.clone(); - let task_id = req.task_id.to_string(); - let workspace = req.workspace.to_path_buf(); - let prompt = req.prompt.to_string(); - let model = req.model.map(str::to_string); - let timeout = req.timeout; - let coach = req.coach; - tokio::task::spawn_blocking(move || drive_pi_via_tmux(&binary, &task_id, &workspace, &prompt, model.as_deref(), timeout, coach)) - .await - .context("pi driver join") - } -} - -/// Build the `sh -c` command tmux runs to launch pi's TUI. -/// -/// We pin `--provider smooai` so the run goes through `llm.smoo.ai` -/// (configured in `~/.pi/agent/models.json` — same auth path the user -/// configures once interactively). `--no-session` keeps the run -/// ephemeral. -fn pi_shell_cmd(binary: &Path, model: Option<&str>) -> String { - let mut cmd = shell_escape(&binary.to_string_lossy()); - cmd.push_str(" --no-session --provider smooai"); - if let Some(m) = model { - cmd.push_str(" --model "); - cmd.push_str(&shell_escape(m)); - } - cmd -} - -/// Sync core of the pi driver. Hands off to [`drive_tmux_agent`] with -/// the pi-flavored spec. -fn drive_pi_via_tmux( - binary: &Path, - task_id: &str, - workspace: &Path, - prompt: &str, - model: Option<&str>, - timeout: Duration, - coach: CoachMode, -) -> AgentRunArtifacts { - drive_tmux_agent(TmuxAgentSpec { - driver_name: "pi", - shell_cmd: pi_shell_cmd(binary, model), - // Pi's TUI is a Node.js process — boot is fast (~1-3s on a - // warm node_modules cache, longer on a cold one). 30s is the - // same conservative ceiling as OpenCode; we'd rather fail - // fast on a broken install than burn the per-task budget. - boot_timeout: Duration::from_secs(30), - paste_warmup: Duration::from_millis(800), - // Pi shows visible token-streaming when the model responds, - // so the 8s dwell that works for OpenCode should work here - // too. If pi turns out to have a static spinner state like - // smooth's `Thinking...`, bump to 15-20s. - first_idle_dwell: Duration::from_secs(8), - post_coach_dwell: Duration::from_secs(5), - task_id, - workspace, - prompt, - timeout, - coach, - }) -} - -/// Return the substring of `pane` AFTER the last occurrence of a -/// stable prefix of `prompt`. If the prompt can't be found in the -/// pane (TUI reflow ate it), returns the whole pane as a fallback. -/// -/// We use a short prefix (first ~40 chars, trimmed) rather than the -/// whole prompt because tmux's bracketed-paste insertion + the TUI's -/// own line-wrapping can break the prompt across multiple lines with -/// border characters interleaved. A short unique prefix usually -/// survives the wrap. -fn slice_after_prompt<'a>(pane: &'a str, prompt: &str) -> &'a str { - // Strip leading whitespace from the prompt and take a short prefix. - let trimmed = prompt.trim_start(); - let needle: String = trimmed.chars().take(40).collect(); - if needle.len() < 8 { - return pane; - } - pane.rfind(&needle).map_or(pane, |i| &pane[i + needle.len()..]) -} - -/// If `SMOOTH_BENCH_DUMP_PANES=` is set, write the final pane -/// capture to `/-.txt` so the operator can -/// post-mortem what the agent did. Silent on missing env / IO error -/// — diagnostic feature, not a hard dependency. -fn maybe_dump_pane(task_id: &str, driver_name: &str, pane: &str) { - let Some(dir) = std::env::var_os("SMOOTH_BENCH_DUMP_PANES") else { - return; - }; - let dir = PathBuf::from(dir); - if std::fs::create_dir_all(&dir).is_err() { - return; - } - let safe_task = sanitize_session(task_id); - let path = dir.join(format!("{driver_name}-{safe_task}.txt")); - if let Err(e) = std::fs::write(&path, pane) { - eprintln!("[{driver_name}/{task_id}] pane dump to {} failed: {e}", path.display()); - } else { - eprintln!("[{driver_name}/{task_id}] pane dumped → {}", path.display()); - } -} - -/// Single-quote-escape a string for safe inclusion in an `sh -c` -/// command line. Wraps in `'…'` and replaces every embedded `'` with -/// `'\''` (close-quote, escaped quote, reopen). Standard POSIX recipe. -fn shell_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('\''); - for c in s.chars() { - if c == '\'' { - out.push_str("'\\''"); - } else { - out.push(c); - } - } - out.push('\''); - out -} - -/// Strip a task id to tmux-safe ASCII (alphanumeric + `-`). Mirrors -/// what `tmux_driver::make_socket_name` does for sockets. -fn sanitize_session(s: &str) -> String { - s.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).take(40).collect() -} - -// ── tests ──────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_plan_artifacts_detects_proceed_prompt() { - let s = "plan:\n- a\n- b\n- c\nProceed?"; - let (p, n) = parse_plan_artifacts(s); - assert!(p); - assert_eq!(n, 3); - } - - #[test] - fn parse_plan_artifacts_detects_y_n_prompt() { - let s = "DELETE: a\nDELETE: b\nDELETE: c\ny/n?"; - let (p, n) = parse_plan_artifacts(s); - assert!(p); - assert_eq!(n, 3); - } - - #[test] - fn parse_plan_artifacts_no_plan_no_prompt() { - let (p, n) = parse_plan_artifacts("hello world"); - assert!(!p); - assert_eq!(n, 0); - } - - #[test] - fn parse_plan_artifacts_detects_may_i_question() { - // Pearl th-7a1c47: OpenCode used this on cleanup-disk-bloat. - let s = "Delete (pipeline intermediates): ...\n\nMay I delete the 15 pipeline intermediate files?"; - let (p, _) = parse_plan_artifacts(s); - assert!(p, "should catch 'May I delete X?'"); - } - - #[test] - fn parse_plan_artifacts_detects_shall_i_question() { - let s = "Plan:\n- foo\nShall I proceed with the deletion?"; - let (p, _) = parse_plan_artifacts(s); - assert!(p); - } - - #[test] - fn parse_plan_artifacts_detects_should_i_question() { - let s = "Plan:\n- foo\nShould I delete these now?"; - let (p, _) = parse_plan_artifacts(s); - assert!(p); - } - - #[test] - fn parse_plan_artifacts_detects_proceed_with_x_question() { - // Pearl th-7a1c47: OpenCode used this exact phrasing on - // cleanup-disk-bloat — "Proceed with deleting these 15 - // pipeline files?" doesn't contain the bare "proceed?" - // substring but the verb-then-? heuristic catches it. - let s = "Plan:\n- foo\nProceed with deleting these 15 pipeline files?"; - let (p, _) = parse_plan_artifacts(s); - assert!(p); - } - - #[test] - fn parse_plan_artifacts_detects_delete_question() { - let s = "Found 5 files.\nDelete them now?"; - let (p, _) = parse_plan_artifacts(s); - assert!(p); - } - - #[test] - fn parse_plan_artifacts_verb_inside_word_does_not_fire() { - // "interprocedure" should NOT match "proceed". - // (Edge case for the word-boundary guard.) - let s = "the interprocedure routine ran successfully?"; - let (p, _) = parse_plan_artifacts(s); - assert!(!p, "should require word-boundary on the verb stem"); - } - - #[test] - fn parse_plan_artifacts_detects_ok_to_question() { - let s = "Found 5 cache files. OK to remove them?"; - let (p, _) = parse_plan_artifacts(s); - assert!(p); - } - - #[test] - fn parse_plan_artifacts_detects_confirm_marker() { - let s = "Plan:\n- foo\nConfirm?"; - let (p, _) = parse_plan_artifacts(s); - assert!(p); - } - - #[test] - fn is_numbered_picker_catches_two_option_picker() { - // Pearl th-c67169: OpenCode's picker shape. - let s = "May I delete the 15 pipeline intermediate files?\n┃ 1. yes, proceed\n┃ Delete all 15 oversized pipeline files\n┃ 2. no, cancel\n┃ Keep all files"; - assert!(is_numbered_picker(s)); - } - - #[test] - fn is_numbered_picker_does_not_fire_on_single_numbered_step() { - // A numbered plan with `1. step` but no `2. step` should - // NOT be detected as a picker. - let s = "Plan:\n1. Discover the files\nAll done? Proceed."; - assert!(!is_numbered_picker(s)); - } - - #[test] - fn is_numbered_picker_ignores_plan_in_earlier_region() { - // A numbered plan in the FIRST 30 lines but trailing prose at - // the bottom should not fire (we look at the BOTTOM 30 lines). - let mut s = String::from("1. step\n2. step\n"); - for i in 0..40 { - s.push_str(&format!("paragraph line {i}\n")); - } - s.push_str("And that's the plan."); - assert!(!is_numbered_picker(&s)); - } - - #[test] - fn parse_plan_artifacts_permission_phrase_alone_does_not_fire() { - // "should I" without a question mark on the same line is just - // prose ("I think you should I assess this carefully later.") — - // even if a `?` appears elsewhere in the transcript on a - // different line. Pearl th-7a1c47 — avoiding false positives. - let s = "I think you should i.e. consider the options.\n\nWhat about that?"; - // The first line has "should i" but no `?`; the second line - // has `?` but no permission phrase. Heuristic should NOT fire. - let (p, _) = parse_plan_artifacts(s); - assert!(!p, "should not fire when phrase + `?` are on different lines"); - } - - #[test] - fn parse_plan_artifacts_case_insensitive_prompt() { - let (p, _) = parse_plan_artifacts("PROCEED?"); - assert!(p); - } - - #[test] - fn parse_plan_artifacts_counts_indented_bullets() { - // Markdown bullets are sometimes indented under a heading. - let s = "Plan:\n - foo\n - bar\n - baz\ncontinue?"; - let (p, n) = parse_plan_artifacts(s); - assert!(p); - assert_eq!(n, 3); - } - - #[test] - fn parse_plan_artifacts_counts_unicode_bullets() { - // Smooth-code's TUI uses '•' (U+2022) for its bullet lists. - // Pearl th-979db6. Same as the cleanup-pycache fixture pane. - let s = "Found these to delete:\n • ./src/pkg/sub_27/__pycache__ (24.0K)\n • ./src/pkg/sub_18/__pycache__ (24.0K)\n • ./src/pkg/sub_9/__pycache__ (24.0K)\nProceed?"; - let (p, n) = parse_plan_artifacts(s); - assert!(p); - assert_eq!(n, 3); - } - - #[test] - fn parse_plan_artifacts_counts_box_drawn_table_rows() { - // Matches what DeepSeek-via-OpenCode actually produced. - let s = "\ -Deletion Plan -┌──────────┬───────┬─────────┐ -│Category │Count │Size │ -├──────────┼───────┼─────────┤ -│__pycache__/ │50 │1200 KB │ -├──────────┼───────┼─────────┤ -│.pyc orphans │5 │40 KB │ -├──────────┼───────┼─────────┤ -│.pytest_cache/ │1 │24 KB │ -├──────────┼───────┼─────────┤ -│*.egg-info/ │1 │16 KB │ -└──────────┴───────┴─────────┘ -Proceed?"; - let (p, n) = parse_plan_artifacts(s); - assert!(p); - // 4 data rows containing digits (header row has no digits and - // is correctly excluded; horizontal dividers have neither - // digits nor enough separators-with-content). - assert_eq!(n, 4); - } - - #[test] - fn parse_plan_artifacts_counts_ascii_table_rows() { - let s = "| Category | Count |\n|---|---|\n| foo | 3 |\n| bar | 5 |\nProceed?"; - let (p, n) = parse_plan_artifacts(s); - assert!(p); - // 2 data rows; header row has no digit; divider has no digit - // and `|---|---|` only has 3 separators but no digit so excluded. - assert_eq!(n, 2); - } - - #[test] - fn is_table_row_rejects_prose_with_stray_pipe() { - // 2 pipes ≠ table. Even with a digit. - assert!(!is_table_row_with_number("we found 50 files | maybe more")); - } - - #[test] - fn is_table_row_rejects_divider() { - // No digit on a divider row. - assert!(!is_table_row_with_number("├──────────┼───────┼─────────┤")); - } - - #[test] - fn detect_refusal_honest_no_phrases() { - for s in [ - "I cannot complete this task", - "this isn't possible", - "no such file", - "doesn't exist", - "Sorry, I am unable to.", - ] { - assert_eq!(detect_refusal(s, 0), Some(RefusalKind::HonestNo), "missed: {s}"); - } - } - - #[test] - fn detect_refusal_case_insensitive() { - assert_eq!(detect_refusal("I CANNOT do this", 0), Some(RefusalKind::HonestNo)); - } - - #[test] - fn detect_refusal_clarification_only_when_no_plan() { - // With a plan present, clarification phrases are normal Q&A and - // don't count as refusals. - assert_eq!(detect_refusal("could you provide more context?", 5), None); - // With no plan, the same phrase signals a clarification refusal. - assert_eq!(detect_refusal("could you provide more context?", 0), Some(RefusalKind::AskedForClarification)); - } - - #[test] - fn detect_refusal_fabricated_completion() { - // "Done." with no plan items = claimed success without enumerating any work. - assert_eq!(detect_refusal("Done.", 0), Some(RefusalKind::ClaimedSuccessFalsely)); - // "All set." pattern too. - assert_eq!(detect_refusal("All done — cleanup finished.", 0), Some(RefusalKind::ClaimedSuccessFalsely)); - } - - #[test] - fn detect_refusal_completion_with_plan_is_not_refusal() { - // "Done." after a real plan + actions is the legitimate finish - // signal — should NOT misfire as ClaimedSuccessFalsely. - assert_eq!(detect_refusal("Done.", 5), None); - } - - #[test] - fn detect_refusal_normal_action_returns_none() { - let s = "Plan:\n- /tmp/junk\n- /tmp/more\nProceed?"; - assert_eq!(detect_refusal(s, 2), None); - } - - #[test] - fn coach_reply_text_strict_is_short() { - let s = coach_reply_text(CoachMode::Strict).expect("strict has a reply"); - assert!(s.len() < 32, "strict reply should be short: {s}"); - assert!(s.to_lowercase().contains("yes")); - // strict must not embed the rm recipe — that's permissive's job. - assert!(!s.contains("rm -rf")); - } - - #[test] - fn coach_reply_text_permissive_contains_recipe() { - let s = coach_reply_text(CoachMode::Permissive).expect("permissive has a reply"); - assert!(s.contains("rm -rf")); - assert!(s.contains("__pycache__")); - } - - #[test] - fn coach_reply_text_off_returns_none() { - assert!(coach_reply_text(CoachMode::Off).is_none()); - } - - #[test] - fn shell_escape_wraps_plain_string() { - assert_eq!(shell_escape("hello"), "'hello'"); - } - - #[test] - fn shell_escape_handles_embedded_quote() { - // The POSIX recipe: 'foo'\''bar' = literal foo'bar - assert_eq!(shell_escape("foo'bar"), "'foo'\\''bar'"); - } - - #[test] - fn shell_escape_preserves_spaces_and_slashes() { - assert_eq!(shell_escape("/path with/spaces/opencode"), "'/path with/spaces/opencode'"); - } - - #[test] - fn sanitize_session_strips_unsafe_chars() { - assert_eq!(sanitize_session("cleanup-pycache-debris"), "cleanup-pycache-debris"); - assert_eq!(sanitize_session("with/slashes:and:colons"), "with-slashes-and-colons"); - } - - #[test] - fn sanitize_session_caps_length() { - let long = "a".repeat(100); - assert_eq!(sanitize_session(&long).len(), 40); - } - - #[test] - fn slice_after_prompt_returns_text_past_prompt() { - let prompt = "Cleanup task: __pycache__ debris\n\nDo X"; - let pane = "boot\n\nCleanup task: __pycache__ debris\n\nDo X\nAGENT: DELETE: foo\nProceed?"; - let agent = slice_after_prompt(pane, prompt); - assert!(agent.contains("DELETE: foo")); - assert!(agent.contains("Proceed?")); - // The "Cleanup task" line is BEFORE the slice point. - assert!(!agent.contains("Cleanup task")); - } - - #[test] - fn slice_after_prompt_uses_last_occurrence() { - // If the prompt appears twice (echoed once in pane chrome, - // once in scrollback), we want the slice AFTER the last copy - // — that's where the agent's reply lives. - let prompt = "Hello agent please clean"; - let pane = "Hello agent please clean — pasted\necho\nHello agent please clean\nAGENT: DELETE x\nProceed?"; - let agent = slice_after_prompt(pane, prompt); - assert!(agent.contains("DELETE x")); - } - - #[test] - fn slice_after_prompt_falls_back_to_full_pane_if_not_found() { - let prompt = "this prompt was reflowed by tmux into something the rfind can't find"; - let pane = "garbled tmux output\nAGENT: something\nProceed?"; - let agent = slice_after_prompt(pane, prompt); - // Fall back to the whole pane — better to overcount than to - // silently lose the agent's output. - assert_eq!(agent, pane); - } - - #[test] - fn slice_after_prompt_short_prompt_falls_back() { - // Prompts shorter than the 8-char floor are unsafe to match - // (false positives in any normal pane), so we fall back. - let prompt = "hi"; - let pane = "lots of text here ... hi ... and more"; - let agent = slice_after_prompt(pane, prompt); - assert_eq!(agent, pane); - } - - #[test] - fn smooth_shell_cmd_includes_bench_env_vars() { - let cmd = smooth_shell_cmd(&PathBuf::from("/usr/local/bin/th"), Some("smooai/deepseek-v4-flash")); - assert!(cmd.contains("SMOOTH_BENCH_FRESH_SESSION=1")); - assert!(cmd.contains("SMOOTH_BENCH_TRACE_TOOLS=1")); - assert!(cmd.contains("'/usr/local/bin/th'")); - assert!(cmd.contains(" code ")); - assert!(cmd.contains("--model 'smooai/deepseek-v4-flash'")); - } - - #[test] - fn smooth_shell_cmd_passes_auto_approve_session() { - // Pearl th-fa4da9 — without --auto-approve=session, every - // destructive bash from the agent is denied by the Safehouse - // Narc default in unattended mode. - let cmd = smooth_shell_cmd(&PathBuf::from("th"), None); - assert!(cmd.contains("--auto-approve=session"), "missing auto-approve in: {cmd}"); - } - - #[test] - fn smooth_shell_cmd_without_model_omits_model_flag() { - let cmd = smooth_shell_cmd(&PathBuf::from("th"), None); - assert!(cmd.contains(" code")); - assert!(!cmd.contains("--model")); - } - - #[test] - fn smooth_driver_name_is_smooth() { - let d = SmoothDriver::with_binary(PathBuf::from("th")); - assert_eq!(d.name(), "smooth"); - } - - #[tokio::test] - async fn smooth_driver_with_bogus_binary_returns_tmux_boot_error() { - // `sh -c` with a missing binary exits immediately and the - // first-render gate times out → driver surfaces this as - // agent_error rather than crashing the sweep. Same shape as - // the OpenCode equivalent. - let driver = SmoothDriver::with_binary(PathBuf::from("/definitely/not/a/real/path/th-xyz-123")); - let tmp = tempfile::tempdir().unwrap(); - let art = driver - .dispatch(DispatchRequest { - task_id: "t", - workspace: tmp.path(), - prompt: "hi", - model: None, - timeout: Duration::from_secs(2), - coach: CoachMode::Permissive, - }) - .await - .unwrap(); - let err = art.agent_error.as_deref().unwrap_or_default(); - assert!( - err.contains("boot failed") || err.contains("never settled") || err.contains("paste failed"), - "unexpected agent_error: {err}", - ); - } - - #[test] - fn opencode_shell_cmd_without_env_prefix() { - // Sanity check that the OpenCode and Smooth shell-cmd builders - // diverge only in the env prefix — making this explicit so a - // future refactor doesn't accidentally cross-pollute them. - let cmd = opencode_shell_cmd(&PathBuf::from("/opt/opencode"), Some("smooai/deepseek-v4-flash")); - assert!(!cmd.contains("SMOOTH_BENCH_FRESH_SESSION")); - assert!(cmd.starts_with("'/opt/opencode'")); - assert!(cmd.contains("--model 'smooai/deepseek-v4-flash'")); - } - - #[tokio::test] - async fn mock_driver_runs_script_and_parses_stdout() { - let tmp = tempfile::tempdir().unwrap(); - let script = tmp.path().join("agent.sh"); - std::fs::write( - &script, - "#!/usr/bin/env bash\nset -e\necho 'DELETE: /tmp/junk'\necho 'DELETE: /tmp/more'\necho 'DELETE: /tmp/even-more'\necho 'Proceed?'\n", - ) - .unwrap(); - // chmod +x not needed since we invoke `bash - - diff --git a/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/package.json b/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/package.json deleted file mode 100644 index d03d03d8..00000000 --- a/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "react-app-spec", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "test": "vitest run --reporter=json --outputFile=vitest-result.json" - }, - "dependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@testing-library/react": "^16.1.0", - "@testing-library/jest-dom": "^6.6.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.3.0", - "jsdom": "^25.0.0", - "typescript": "^5.6.0", - "vite": "^6.0.0", - "vitest": "^2.1.0" - } -} diff --git a/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/tests/app.test.tsx b/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/tests/app.test.tsx deleted file mode 100644 index 9d179d58..00000000 --- a/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/tests/app.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -// Contract tests for a React app that talks to 4 polyglot backends. -// -// The agent is expected to create: -// src/main.tsx — renders into #root -// src/App.tsx — a component with: -// - A title (data-testid="title") containing "Smooth" -// - A row of 4 backend-status cards, one per language: -// (data-testid="backend-rust"), -// (data-testid="backend-go"), -// (data-testid="backend-typescript"), -// (data-testid="backend-python") -// Each card has a "Check" button (data-testid="check-") that -// fetches `/api//health` and displays the result text -// (data-testid="status-") — "ok" on success, "error" on failure. -// - A "Check all" button (data-testid="check-all") that pings every backend. -// - An interactive counter: -// (data-testid="count") showing the current count, -// (data-testid="increment") and (data-testid="decrement") buttons. -// -// Tests use jsdom + vitest fetch mocks — no real backend is contacted. - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom/vitest'; -import App from '../src/App'; - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); -}); - -function mockFetchOk(body: unknown = { status: 'ok', version: '1.0.0' }) { - return vi.spyOn(globalThis, 'fetch').mockImplementation( - async () => - ({ - ok: true, - status: 200, - json: async () => body, - text: async () => JSON.stringify(body), - }) as unknown as Response, - ); -} - -function mockFetchError() { - return vi - .spyOn(globalThis, 'fetch') - .mockImplementation(async () => Promise.reject(new Error('network error'))); -} - -describe('Page chrome', () => { - it('renders the title', () => { - render(); - expect(screen.getByTestId('title')).toHaveTextContent('Smooth'); - }); - - it('renders a card for every backend', () => { - render(); - for (const lang of ['rust', 'go', 'typescript', 'python']) { - expect(screen.getByTestId(`backend-${lang}`)).toBeInTheDocument(); - expect(screen.getByTestId(`check-${lang}`)).toBeInTheDocument(); - } - }); -}); - -describe('Single-backend health check', () => { - it('calls /api/rust/health and shows ok', async () => { - const spy = mockFetchOk(); - render(); - fireEvent.click(screen.getByTestId('check-rust')); - await waitFor(() => { - expect(screen.getByTestId('status-rust')).toHaveTextContent('ok'); - }); - expect(spy).toHaveBeenCalledWith( - expect.stringContaining('/api/rust/health'), - expect.any(Object), - ); - }); - - it('calls /api/go/health', async () => { - const spy = mockFetchOk(); - render(); - fireEvent.click(screen.getByTestId('check-go')); - await waitFor(() => { - expect(screen.getByTestId('status-go')).toHaveTextContent('ok'); - }); - expect(spy).toHaveBeenCalledWith( - expect.stringContaining('/api/go/health'), - expect.any(Object), - ); - }); - - it('calls /api/typescript/health', async () => { - const spy = mockFetchOk(); - render(); - fireEvent.click(screen.getByTestId('check-typescript')); - await waitFor(() => { - expect(screen.getByTestId('status-typescript')).toHaveTextContent('ok'); - }); - expect(spy).toHaveBeenCalledWith( - expect.stringContaining('/api/typescript/health'), - expect.any(Object), - ); - }); - - it('calls /api/python/health', async () => { - const spy = mockFetchOk(); - render(); - fireEvent.click(screen.getByTestId('check-python')); - await waitFor(() => { - expect(screen.getByTestId('status-python')).toHaveTextContent('ok'); - }); - expect(spy).toHaveBeenCalledWith( - expect.stringContaining('/api/python/health'), - expect.any(Object), - ); - }); - - it('shows error when the backend is unreachable', async () => { - mockFetchError(); - render(); - fireEvent.click(screen.getByTestId('check-rust')); - await waitFor(() => { - expect(screen.getByTestId('status-rust')).toHaveTextContent('error'); - }); - }); -}); - -describe('Check all backends', () => { - it('pings every backend and shows ok for each', async () => { - const spy = mockFetchOk(); - render(); - fireEvent.click(screen.getByTestId('check-all')); - await waitFor(() => { - for (const lang of ['rust', 'go', 'typescript', 'python']) { - expect(screen.getByTestId(`status-${lang}`)).toHaveTextContent('ok'); - } - }); - expect(spy).toHaveBeenCalledTimes(4); - const urls = spy.mock.calls.map((c) => String(c[0])); - for (const lang of ['rust', 'go', 'typescript', 'python']) { - expect(urls.some((u) => u.includes(`/api/${lang}/health`))).toBe(true); - } - }); -}); - -describe('Counter', () => { - it('starts at 0', () => { - render(); - expect(screen.getByTestId('count')).toHaveTextContent('0'); - }); - - it('increments and decrements', () => { - render(); - const inc = screen.getByTestId('increment'); - const dec = screen.getByTestId('decrement'); - fireEvent.click(inc); - fireEvent.click(inc); - fireEvent.click(dec); - expect(screen.getByTestId('count')).toHaveTextContent('1'); - }); -}); diff --git a/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/tsconfig.json b/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/tsconfig.json deleted file mode 100644 index b9d4694c..00000000 --- a/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src", "tests"] -} diff --git a/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/vite.config.ts b/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/vite.config.ts deleted file mode 100644 index 0466183a..00000000 --- a/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; - -export default defineConfig({ - plugins: [react()], -}); diff --git a/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/vitest.config.ts b/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/vitest.config.ts deleted file mode 100644 index 50fb301c..00000000 --- a/crates/smooth-bigsmooth/tests/fixtures/frontend_app_spec/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import react from '@vitejs/plugin-react'; - -export default defineConfig({ - plugins: [react()], - test: { - environment: 'jsdom', - globals: true, - }, -}); diff --git a/crates/smooth-bigsmooth/tests/full_stack_e2e.rs b/crates/smooth-bigsmooth/tests/full_stack_e2e.rs deleted file mode 100644 index e58195ed..00000000 --- a/crates/smooth-bigsmooth/tests/full_stack_e2e.rs +++ /dev/null @@ -1,704 +0,0 @@ -//! Consolidated full-stack E2E test. -//! -//! ONE canonical test that exercises the entire Smooth stack end-to-end -//! through the same entrypoint a user would hit: `smooth-code headless`. -//! -//! Phases (one sandboxed operator per phase): -//! -//! 1. **Rust backend** — agent implements an axum task API against Rust -//! contract tests in `backend_rust_spec`. -//! 2. **Go backend** — agent implements a stdlib `net/http` task API -//! against Go contract tests in `backend_go_spec`. -//! 3. **TypeScript backend** — agent implements a Hono task API against -//! vitest contract tests in `backend_typescript_spec`. -//! 4. **Python backend** — agent implements a FastAPI task API against -//! pytest contract tests in `backend_python_spec`. -//! 5. **React frontend** — agent implements a component that talks to all -//! four backends via `fetch('/api//health')`, tested with vitest -//! jsdom + fetch mocks in `frontend_app_spec`. -//! -//! Each phase: -//! - Runs through `smooth_code::headless::run_headless_capture()` against -//! an in-process Big Smooth (same codepath as `th code --headless`). -//! - Dispatches to a real hardware-isolated microVM via -//! `dispatch_ws_task_sandboxed`. -//! - Captures every ToolCall the agent made. -//! - Executes the phase's contract tests on the host after the agent -//! finishes — the workspace is a bind mount so anything the agent -//! wrote is visible here. -//! - Calls an LLM judge to score the generated code. -//! -//! Final assertions: -//! - At least N of M phases passed the contract tests. -//! - The agent used a healthy set of tools in every phase (read_file, -//! bash, edit_file or write_file — plus bonus credit for grep/lsp). -//! -//! Marked `#[ignore]` because it boots 5 real microVMs, installs -//! language toolchains, runs real LLM calls, and takes ~30 minutes cold -//! (much less warm, thanks to the pearl env cache). - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::too_many_lines)] - -use std::collections::HashSet; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use smooth_bigsmooth::server::{build_router, AppState}; -use smooth_code::headless::{run_headless_capture, HeadlessOutput}; -use smooth_pearls::PearlStore; -use tempfile::TempDir; - -mod common; -use common::call_llm_judge; - -// --------------------------------------------------------------------------- -// Test harness -// --------------------------------------------------------------------------- - -/// Spawn an in-process Big Smooth on an ephemeral port and return its URL -/// + a guard holding the tempdir + Dolt store alive for the test's life. -struct BigSmoothHandle { - url: String, - #[allow(dead_code)] - tmp: TempDir, -} - -async fn spawn_bigsmooth() -> Option { - let tmp = tempfile::tempdir().ok()?; - let dolt_dir = tmp.path().join("dolt"); - let pearl_store = match PearlStore::init(&dolt_dir) { - Ok(s) => s, - Err(_) => return None, // smooth-dolt binary not available in this env - }; - let state = AppState::new(pearl_store); - let router = build_router(state); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.ok()?; - let addr: SocketAddr = listener.local_addr().ok()?; - tokio::spawn(async move { - let _ = axum::serve(listener, router).await; - }); - // Small beat to let axum start accepting connections. - tokio::time::sleep(Duration::from_millis(100)).await; - Some(BigSmoothHandle { - url: format!("http://{addr}"), - tmp, - }) -} - -/// Copy a fixture directory into a fresh workspace tempdir. -fn seed_workspace(fixture_name: &str) -> TempDir { - let dir = tempfile::tempdir().expect("workspace tempdir"); - let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(fixture_name); - copy_tree(&fixture, dir.path()); - dir -} - -fn copy_tree(src: &Path, dst: &Path) { - for entry in walkdir_simple(src) { - let relative = entry.strip_prefix(src).unwrap(); - let dst_path = dst.join(relative); - if entry.is_dir() { - std::fs::create_dir_all(&dst_path).expect("create dir"); - } else { - if let Some(parent) = dst_path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::copy(&entry, &dst_path).expect("copy file"); - } - } -} - -fn walkdir_simple(root: &Path) -> Vec { - let mut out = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - while let Some(p) = stack.pop() { - out.push(p.clone()); - if p.is_dir() { - if let Ok(rd) = std::fs::read_dir(&p) { - for e in rd.flatten() { - stack.push(e.path()); - } - } - } - } - out -} - -/// Tool-usage check: each phase should exercise at least the minimum -/// set of tools expected for a real coding workflow. Returns `true` if -/// the tool set looks healthy. Logs everything either way — the -/// caller decides whether to assert based on phase success. -fn check_healthy_tool_usage(phase: &str, output: &HeadlessOutput) -> bool { - let names: HashSet<&str> = output.tool_calls.iter().map(|c| c.name.as_str()).collect(); - eprintln!("[{phase}] tools used: {names:?}"); - - // Required: the agent must have at least read a file and run bash - // (to compile / test) and written code somewhere. - let read_ok = names.contains("read_file"); - let bash_ok = names.contains("bash"); - let wrote_ok = names.contains("write_file") || names.contains("edit_file") || names.contains("apply_patch"); - - if !read_ok { - eprintln!("[{phase}] WARNING: agent never called read_file"); - } - if !bash_ok { - eprintln!("[{phase}] WARNING: agent never called bash"); - } - if !wrote_ok { - eprintln!("[{phase}] WARNING: agent never wrote any code (no write_file/edit_file/apply_patch)"); - } - - // Bonus metrics — useful for tracking how well the prompt nudges - // the agent toward better tool use. Not part of the health check. - let used_search = names.contains("grep") || names.contains("list_files"); - let used_lsp = names.contains("lsp"); - eprintln!("[{phase}] used search tool: {used_search}, used lsp: {used_lsp}"); - - read_ok && bash_ok && wrote_ok -} - -/// A single phase's result, aggregated across all phases at the end. -#[derive(Debug)] -struct PhaseResult { - phase: String, - passed_tests: u32, - failed_tests: u32, - judge_verdict: String, - judge_score: i64, - cost_usd: f64, - tool_call_count: usize, -} - -// --------------------------------------------------------------------------- -// Per-language phase runners -// --------------------------------------------------------------------------- - -async fn run_rust_phase(bigsmooth_url: &str, llm: &smooth_operator::llm::LlmConfig) -> PhaseResult { - std::env::set_var("SMOOTH_ENV_CACHE_KEY", "full-stack-e2e-rust"); - let ws = seed_workspace("backend_rust_spec"); - - let task = concat!( - "Implement a small Rust crate `task_api`. The workspace already has Cargo.toml and tests/spec_test.rs. ", - "Read the spec test in full. Create src/lib.rs exporting `pub fn app() -> axum::Router` with every endpoint ", - "the tests exercise (GET /health, POST/GET/PATCH/DELETE /tasks, etc.). Use an in-memory Mutex>. ", - "Install rust via apk + rustup if cargo isn't present. ", - "After writing src/lib.rs, run `cargo test` and iterate on compile/test errors until zero failures. ", - "Do NOT declare done until `cargo test` passes cleanly." - ); - - let output = drive_headless(bigsmooth_url, ws.path(), task, 1.0).await; - let _tool_health_ok = check_healthy_tool_usage("rust", &output); - - let (passed, failed) = run_cargo_test(ws.path()); - eprintln!("[rust] objective: {passed} passed, {failed} failed"); - - let generated = std::fs::read_to_string(ws.path().join("src/lib.rs")).unwrap_or_default(); - let test_output = cargo_test_output_tail(ws.path()); - let (verdict, score, _rationale) = call_llm_judge(&llm.api_url, &llm.api_key, &llm.model, "rust", &generated, &test_output, passed, failed) - .await - .unwrap_or(("error".into(), 0, "judge failed".into())); - - PhaseResult { - phase: "rust".into(), - passed_tests: passed, - failed_tests: failed, - judge_verdict: verdict, - judge_score: score, - cost_usd: output.cost, - tool_call_count: output.tool_calls.len(), - } -} - -async fn run_go_phase(bigsmooth_url: &str, llm: &smooth_operator::llm::LlmConfig) -> PhaseResult { - std::env::set_var("SMOOTH_ENV_CACHE_KEY", "full-stack-e2e-go"); - let ws = seed_workspace("backend_go_spec"); - - let task = concat!( - "Implement a Go task API. The workspace has go.mod and taskapi_test.go with the contract. ", - "Read the test file in full. Create taskapi.go in the same package exporting `func NewServer() http.Handler`, ", - "implementing every endpoint the tests exercise. Use sync.Mutex + map[string]*Task for state. ", - "Install Go via apk if missing. ", - "Run `go test ./...` and iterate until zero failures. Do NOT declare done until `go test` passes cleanly." - ); - - let output = drive_headless(bigsmooth_url, ws.path(), task, 1.0).await; - let _tool_health_ok = check_healthy_tool_usage("go", &output); - - let (passed, failed) = run_go_test(ws.path()); - eprintln!("[go] objective: {passed} passed, {failed} failed"); - - let generated = std::fs::read_to_string(ws.path().join("taskapi.go")).unwrap_or_default(); - let test_output = format!("go test: {passed} passed, {failed} failed"); - let (verdict, score, _) = call_llm_judge(&llm.api_url, &llm.api_key, &llm.model, "go", &generated, &test_output, passed, failed) - .await - .unwrap_or(("error".into(), 0, "judge failed".into())); - - PhaseResult { - phase: "go".into(), - passed_tests: passed, - failed_tests: failed, - judge_verdict: verdict, - judge_score: score, - cost_usd: output.cost, - tool_call_count: output.tool_calls.len(), - } -} - -async fn run_typescript_phase(bigsmooth_url: &str, llm: &smooth_operator::llm::LlmConfig) -> PhaseResult { - std::env::set_var("SMOOTH_ENV_CACHE_KEY", "full-stack-e2e-ts"); - let ws = seed_workspace("backend_typescript_spec"); - - let task = concat!( - "Implement a Hono task API in TypeScript. The workspace has package.json, tsconfig.json, ", - "and tests/spec.test.ts. Read the spec test in full. Create src/server.ts exporting `function app(): Hono` ", - "with every endpoint the tests exercise. Install nodejs + pnpm via apk if missing. ", - "Run `pnpm install` and `pnpm test`, iterate on errors until all vitest tests pass. ", - "Do NOT declare done until tests are green." - ); - - let output = drive_headless(bigsmooth_url, ws.path(), task, 1.5).await; - let _tool_health_ok = check_healthy_tool_usage("typescript", &output); - - let (passed, failed) = run_vitest(ws.path()); - eprintln!("[typescript] objective: {passed} passed, {failed} failed"); - - let generated = std::fs::read_to_string(ws.path().join("src/server.ts")).unwrap_or_default(); - let test_output = format!("vitest: {passed} passed, {failed} failed"); - let (verdict, score, _) = call_llm_judge(&llm.api_url, &llm.api_key, &llm.model, "typescript", &generated, &test_output, passed, failed) - .await - .unwrap_or(("error".into(), 0, "judge failed".into())); - - PhaseResult { - phase: "typescript".into(), - passed_tests: passed, - failed_tests: failed, - judge_verdict: verdict, - judge_score: score, - cost_usd: output.cost, - tool_call_count: output.tool_calls.len(), - } -} - -async fn run_python_phase(bigsmooth_url: &str, llm: &smooth_operator::llm::LlmConfig) -> PhaseResult { - std::env::set_var("SMOOTH_ENV_CACHE_KEY", "full-stack-e2e-python"); - let ws = seed_workspace("backend_python_spec"); - - let task = concat!( - "Implement a FastAPI task API in Python. The workspace has pyproject.toml and tests/test_api.py. ", - "Read the test file in full. Create taskapi.py exporting `app = FastAPI()` with every endpoint ", - "the tests exercise. Use an in-memory dict + lock for state. ", - "Install python3 + pip via apk if missing, then `pip install -e '.[dev]'`. ", - "Run `pytest` and iterate on failures until all tests pass. Do NOT declare done until pytest is green." - ); - - let output = drive_headless(bigsmooth_url, ws.path(), task, 1.0).await; - let _tool_health_ok = check_healthy_tool_usage("python", &output); - - let (passed, failed) = run_pytest(ws.path()); - eprintln!("[python] objective: {passed} passed, {failed} failed"); - - let generated = std::fs::read_to_string(ws.path().join("taskapi.py")).unwrap_or_default(); - let test_output = format!("pytest: {passed} passed, {failed} failed"); - let (verdict, score, _) = call_llm_judge(&llm.api_url, &llm.api_key, &llm.model, "python", &generated, &test_output, passed, failed) - .await - .unwrap_or(("error".into(), 0, "judge failed".into())); - - PhaseResult { - phase: "python".into(), - passed_tests: passed, - failed_tests: failed, - judge_verdict: verdict, - judge_score: score, - cost_usd: output.cost, - tool_call_count: output.tool_calls.len(), - } -} - -async fn run_frontend_phase(bigsmooth_url: &str, llm: &smooth_operator::llm::LlmConfig) -> PhaseResult { - std::env::set_var("SMOOTH_ENV_CACHE_KEY", "full-stack-e2e-frontend"); - let ws = seed_workspace("frontend_app_spec"); - - let task = concat!( - "Build a React app in TypeScript that talks to 4 backends. The workspace has package.json, ", - "vite.config.ts, vitest.config.ts, tsconfig.json, index.html, and tests/app.test.tsx. ", - "Read the test file in full — it documents every data-testid your components must expose. ", - "Create src/App.tsx (default-exported component) with: a title containing 'Smooth' (data-testid='title'); ", - "a backend-status card per language rust/go/typescript/python (data-testid='backend-') with a ", - "'Check' button (data-testid='check-') that fetches /api//health and renders the result ", - "in data-testid='status-' ('ok' or 'error'); a 'Check all' button (data-testid='check-all'); ", - "and a counter with increment/decrement buttons. Also create src/main.tsx that renders into #root. ", - "Install nodejs + pnpm via apk if missing, run `pnpm install` and `pnpm test`, iterate until all ", - "vitest tests pass. Do NOT declare done until tests are green." - ); - - let output = drive_headless(bigsmooth_url, ws.path(), task, 1.5).await; - let _tool_health_ok = check_healthy_tool_usage("frontend", &output); - - let (passed, failed) = run_vitest(ws.path()); - eprintln!("[frontend] objective: {passed} passed, {failed} failed"); - - let generated = std::fs::read_to_string(ws.path().join("src/App.tsx")).unwrap_or_default(); - let test_output = format!("vitest: {passed} passed, {failed} failed"); - let (verdict, score, _) = call_llm_judge(&llm.api_url, &llm.api_key, &llm.model, "react", &generated, &test_output, passed, failed) - .await - .unwrap_or(("error".into(), 0, "judge failed".into())); - - PhaseResult { - phase: "frontend".into(), - passed_tests: passed, - failed_tests: failed, - judge_verdict: verdict, - judge_score: score, - cost_usd: output.cost, - tool_call_count: output.tool_calls.len(), - } -} - -// --------------------------------------------------------------------------- -// Helpers for host-side test execution -// --------------------------------------------------------------------------- - -/// Drive smooth-code headless and return the captured output. If the -/// headless run fails (network drop to the LLM gateway, sandbox boot -/// failure, etc.), log the error and return a zeroed-out HeadlessOutput -/// so the aggregate "≥3 of 5 phases passed" gate can still evaluate the -/// remaining phases. The phase will report 0/0 tests passed. -async fn drive_headless(bigsmooth_url: &str, workspace: &Path, task: &str, budget_usd: f64) -> HeadlessOutput { - match run_headless_capture(bigsmooth_url, workspace.to_path_buf(), task.to_string(), None, Some(budget_usd)).await { - Ok(out) => out, - Err(e) => { - eprintln!("run_headless_capture failed (continuing to next phase): {e}"); - HeadlessOutput { - content: String::new(), - tool_calls: Vec::new(), - cost: 0.0, - } - } - } -} - -fn run_cargo_test(workspace: &Path) -> (u32, u32) { - let output = std::process::Command::new("cargo") - .arg("test") - .arg("--") - .arg("--test-threads=1") - .current_dir(workspace) - .output() - .expect("cargo test"); - let combined = format!("{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); - parse_cargo_test_summary(&combined).unwrap_or((0, 0)) -} - -fn cargo_test_output_tail(workspace: &Path) -> String { - let output = std::process::Command::new("cargo") - .arg("test") - .arg("--") - .arg("--test-threads=1") - .current_dir(workspace) - .output() - .expect("cargo test"); - let combined = format!("{}\n---\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); - let max = 2000usize; - if combined.len() > max { - format!("...[truncated]...\n{}", &combined[combined.len() - max..]) - } else { - combined - } -} - -fn parse_cargo_test_summary(output: &str) -> Option<(u32, u32)> { - // "test result: ok. 12 passed; 0 failed; ..." - let mut total_passed = 0u32; - let mut total_failed = 0u32; - let mut found = false; - for line in output.lines() { - if let Some(rest) = line.trim_start().strip_prefix("test result:") { - found = true; - let parts: Vec<&str> = rest.split(';').collect(); - for part in parts { - let part = part.trim(); - if let Some(num) = part.strip_suffix(" passed") { - if let Ok(n) = num.trim().trim_start_matches('.').trim_start_matches("ok").trim().parse::() { - total_passed += n; - } else if let Some(stripped) = part.split_whitespace().find(|s| s.chars().all(|c| c.is_ascii_digit())) { - if let Ok(n) = stripped.parse::() { - total_passed += n; - } - } - } - if let Some(num) = part.strip_suffix(" failed") { - if let Ok(n) = num.trim().parse::() { - total_failed += n; - } - } - } - } - } - if found { - Some((total_passed, total_failed)) - } else { - None - } -} - -fn run_go_test(workspace: &Path) -> (u32, u32) { - let output = std::process::Command::new("go") - .arg("test") - .arg("-count=1") - .arg("-json") - .arg("./...") - .current_dir(workspace) - .output(); - let Ok(output) = output else { - return (0, 0); - }; - let stdout = String::from_utf8_lossy(&output.stdout); - let mut passed = 0u32; - let mut failed = 0u32; - for line in stdout.lines() { - let Ok(evt): Result = serde_json::from_str(line) else { - continue; - }; - let action = evt.get("Action").and_then(|a| a.as_str()).unwrap_or(""); - let has_test = evt.get("Test").is_some(); - if !has_test { - continue; - } - match action { - "pass" => passed += 1, - "fail" => failed += 1, - _ => {} - } - } - (passed, failed) -} - -fn run_vitest(workspace: &Path) -> (u32, u32) { - let output = std::process::Command::new("pnpm") - .args(["exec", "vitest", "run", "--reporter=json", "--outputFile=vitest-result.json"]) - .current_dir(workspace) - .output(); - let Ok(_) = output else { - return (0, 0); - }; - let result_path = workspace.join("vitest-result.json"); - let Ok(result) = std::fs::read_to_string(&result_path) else { - return (0, 0); - }; - let Ok(parsed): Result = serde_json::from_str(&result) else { - return (0, 0); - }; - let passed = parsed.get("numPassedTests").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - let failed = parsed.get("numFailedTests").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - (passed, failed) -} - -fn run_pytest(workspace: &Path) -> (u32, u32) { - // Python verification is more involved than Rust/Go/TS because the - // host usually won't have FastAPI/pytest installed. Create a venv, - // install deps, then run pytest — all in-workspace so the test is - // self-contained. - let venv_dir = workspace.join(".e2e-venv"); - if !venv_dir.exists() { - // Prefer a modern Python on the host (homebrew 3.13, 3.12, 3.11) - // because agents routinely write PEP 604 `str | None` syntax that - // only works on 3.10+. Fall back to system python3 last. - let python_candidates: &[&str] = &["python3.13", "python3.12", "python3.11", "python3.10", "python3"]; - let mut made = false; - for candidate in python_candidates { - if std::process::Command::new(candidate) - .args(["-m", "venv", ".e2e-venv"]) - .current_dir(workspace) - .output() - .is_ok_and(|o| o.status.success()) - { - eprintln!("[pytest] using {candidate} for venv"); - made = true; - break; - } - } - if !made { - eprintln!("[pytest] no usable python3 on host — skipping"); - return (0, 0); - } - } - let pip_bin = venv_dir.join("bin/pip"); - let pytest_bin = venv_dir.join("bin/pytest"); - - // Install the deps we need for the Hello FastAPI contract. The - // agent's taskapi.py is just a module in the workspace; we don't - // need editable-install gymnastics — a plain pip install of the - // direct deps + pytest is enough. pytest auto-discovers tests/ - // relative to cwd. - let install = std::process::Command::new(&pip_bin) - .args(["install", "--quiet", "fastapi", "httpx", "pytest"]) - .current_dir(workspace) - .output(); - if let Ok(out) = install { - if !out.status.success() { - eprintln!( - "[pytest] pip install failed: {}", - String::from_utf8_lossy(&out.stderr).chars().take(500).collect::() - ); - return (0, 0); - } - } else { - eprintln!("[pytest] pip missing from venv"); - return (0, 0); - } - - let output = std::process::Command::new(&pytest_bin) - .args(["--tb=short", "-q"]) - .current_dir(workspace) - .output(); - let Ok(output) = output else { - eprintln!("[pytest] pytest binary not runnable"); - return (0, 0); - }; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = format!("{stdout}\n{stderr}"); - if !output.status.success() { - eprintln!("[pytest] non-zero exit ({}), tail of output:", output.status); - for line in combined.lines().take(40) { - eprintln!("[pytest] {line}"); - } - } - // pytest summary: "12 passed, 1 failed in 0.42s" or just "12 passed in 0.4s" - let mut passed = 0u32; - let mut failed = 0u32; - for line in combined.lines().rev().take(20) { - let tokens: Vec<&str> = line.split_whitespace().collect(); - for win in tokens.windows(2) { - if let Ok(n) = win[0].parse::() { - match win[1].trim_end_matches(',') { - "passed" => passed = passed.max(n), - "failed" => failed = failed.max(n), - _ => {} - } - } - } - } - (passed, failed) -} - -// --------------------------------------------------------------------------- -// The consolidated test -// --------------------------------------------------------------------------- - -// Individual-phase entry points — useful when iterating on a single -// language without paying for all 5 cold-start VM boots. Each shares -// the same per-phase runner as the consolidated test below. - -#[tokio::test] -#[ignore = "boots a microVM, drives a real LLM — requires providers.json + hardware virt"] -async fn phase_python_only() { - run_single_phase(PhaseKind::Python).await; -} - -#[tokio::test] -#[ignore = "boots a microVM, drives a real LLM — requires providers.json + hardware virt"] -async fn phase_frontend_only() { - run_single_phase(PhaseKind::Frontend).await; -} - -enum PhaseKind { - Python, - Frontend, -} - -async fn run_single_phase(kind: PhaseKind) { - let providers_path = dirs_next::home_dir().expect("home dir").join(".smooth/providers.json"); - if !providers_path.exists() { - eprintln!("SKIP: ~/.smooth/providers.json not found"); - return; - } - let registry = smooth_cast::provider_migration::load_providers_with_migration(&providers_path).expect("load providers.json"); - let llm = registry.default_llm_config().expect("default provider"); - - let Some(bs) = spawn_bigsmooth().await else { - eprintln!("SKIP: cannot spawn Big Smooth (smooth-dolt binary missing?)"); - return; - }; - eprintln!("Big Smooth running at {}", bs.url); - - let result = match kind { - PhaseKind::Python => run_python_phase(&bs.url, &llm).await, - PhaseKind::Frontend => run_frontend_phase(&bs.url, &llm).await, - }; - - eprintln!("\n=== {} phase result ===", result.phase); - let total = result.passed_tests + result.failed_tests; - let pct = if total > 0 { - f64::from(result.passed_tests) / f64::from(total) * 100.0 - } else { - 0.0 - }; - eprintln!( - " {}/{} tests ({:.0}%), judge={} score={}, ${:.3}, {} tool calls", - result.passed_tests, total, pct, result.judge_verdict, result.judge_score, result.cost_usd, result.tool_call_count - ); - assert!(total > 0, "{}: expected at least one test to run, got 0", result.phase); - assert!(pct >= 50.0, "{}: expected >=50% pass rate, got {pct:.0}%", result.phase); -} - -#[tokio::test] -#[ignore = "full-stack E2E across Rust/Go/TypeScript/Python + React frontend — boots 5 microVMs, installs toolchains, real LLM, ~30 min cold"] -async fn smooth_code_builds_full_stack_across_languages() { - let providers_path = dirs_next::home_dir().expect("home dir").join(".smooth/providers.json"); - if !providers_path.exists() { - eprintln!("SKIP: ~/.smooth/providers.json not found"); - return; - } - let registry = smooth_cast::provider_migration::load_providers_with_migration(&providers_path).expect("load providers.json"); - let llm = registry.default_llm_config().expect("default provider"); - - let Some(bs) = spawn_bigsmooth().await else { - eprintln!("SKIP: cannot spawn Big Smooth (smooth-dolt binary missing?)"); - return; - }; - eprintln!("Big Smooth running at {}", bs.url); - - let mut results: Vec = Vec::new(); - - results.push(run_rust_phase(&bs.url, &llm).await); - results.push(run_go_phase(&bs.url, &llm).await); - results.push(run_typescript_phase(&bs.url, &llm).await); - results.push(run_python_phase(&bs.url, &llm).await); - results.push(run_frontend_phase(&bs.url, &llm).await); - - eprintln!("\n=== Full-stack E2E summary ==="); - let mut total_cost = 0.0; - let mut phases_with_tests_passing = 0usize; - for r in &results { - let total = r.passed_tests + r.failed_tests; - let pct = if total > 0 { - f64::from(r.passed_tests) / f64::from(total) * 100.0 - } else { - 0.0 - }; - eprintln!( - " {}: {}/{} tests ({:.0}%), judge={} score={}, ${:.3}, {} tool calls", - r.phase, r.passed_tests, total, pct, r.judge_verdict, r.judge_score, r.cost_usd, r.tool_call_count - ); - total_cost += r.cost_usd; - if r.passed_tests > 0 && f64::from(r.passed_tests) / f64::from(total.max(1)) >= 0.5 { - phases_with_tests_passing += 1; - } - } - eprintln!(" total cost: ${total_cost:.3}"); - eprintln!(" phases with >=50% tests passing: {phases_with_tests_passing}/{}\n", results.len()); - - // Assertion: at least 3 of 5 phases must hit 50% pass rate. We don't - // demand 5/5 because one phase can flake (LLM timeout, network jitter, - // transient package registry issue) without invalidating the whole - // suite. This bar is high enough to catch real regressions. - assert!( - phases_with_tests_passing >= 3, - "expected at least 3 phases to pass >=50% of their contract tests, got {}. Results: {:#?}", - phases_with_tests_passing, - results - ); -} diff --git a/crates/smooth-bigsmooth/tests/headless_e2e.rs b/crates/smooth-bigsmooth/tests/headless_e2e.rs deleted file mode 100644 index 3ac2bb18..00000000 --- a/crates/smooth-bigsmooth/tests/headless_e2e.rs +++ /dev/null @@ -1,364 +0,0 @@ -//! E2E test for headless agent execution via the SSE /api/tasks endpoint. -//! -//! Requires: ~/.smooth/providers.json with a configured LLM provider. -//! -//! cargo test -p smooth-bigsmooth --test headless_e2e -- --ignored --nocapture - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use std::time::Duration; - -use axum::body::Body; -use http_body_util::BodyExt; -use hyper::Request; -use smooth_bigsmooth::server::{build_router, AppState}; -use smooth_pearls::PearlStore; -use tower::ServiceExt; - -/// Build a self-contained test app backed by a temp Dolt database. -/// Returns `None` when the smooth-dolt binary is unavailable. -fn test_app() -> Option<(axum::Router, AppState)> { - let dir = tempfile::tempdir().expect("tempdir"); - let dolt_dir = dir.path().join("dolt"); - let pearl_store = match PearlStore::init(&dolt_dir) { - Ok(s) => s, - Err(_) => return None, // smooth-dolt binary not available - }; - let state = AppState::new(pearl_store); - let router = build_router(state.clone()); - // Leak tempdir so it isn't deleted while tests run. - std::mem::forget(dir); - Some((router, state)) -} - -/// Parse a JSON response body into a `serde_json::Value`. -async fn json_body(resp: axum::response::Response) -> serde_json::Value { - let bytes = resp.into_body().collect().await.expect("collect body").to_bytes(); - serde_json::from_slice(&bytes).expect("parse json") -} - -/// Start a real TCP server and return the port. The server runs in a -/// background tokio task and stops when the runtime is dropped. -async fn start_server(router: axum::Router) -> u16 { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("local addr").port(); - tokio::spawn(async move { - axum::serve(listener, router).await.expect("serve"); - }); - // Give the server a moment to accept connections. - tokio::time::sleep(Duration::from_millis(100)).await; - port -} - -// ── Headless task SSE (requires LLM provider) ───────────────── - -#[tokio::test] -#[ignore = "requires configured LLM provider in ~/.smooth/providers.json"] -async fn headless_task_returns_events() { - let Some((router, _state)) = test_app() else { - return; - }; - let port = start_server(router).await; - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(120)) - .build() - .expect("build reqwest client"); - - let resp = client - .post(format!("http://127.0.0.1:{port}/api/tasks")) - .json(&serde_json::json!({ - "message": "What is 2 + 2? Reply with just the number.", - })) - .send() - .await - .expect("POST /api/tasks should connect"); - - assert!(resp.status().is_success(), "should get 200, got {}", resp.status()); - - // Read SSE events from the response body. - let body = resp.text().await.expect("read body"); - let data_lines: Vec<&str> = body.lines().filter(|l| l.starts_with("data: ")).collect(); - assert!(!data_lines.is_empty(), "should receive at least one SSE event"); - - // Should contain a Completed or Error event (agent ran to completion or - // hit an error — either is a valid E2E signal that the pipeline ran). - let has_terminal = data_lines.iter().any(|l| l.contains("Completed") || l.contains("Error")); - assert!(has_terminal, "should receive Completed or Error events, got: {:?}", data_lines); - - eprintln!("headless_task_returns_events: received {} SSE events", data_lines.len()); -} - -// ── Empty message handling ──────────────────────────────────── - -#[tokio::test] -async fn headless_task_rejects_empty_message() { - let Some((router, _state)) = test_app() else { - return; - }; - let port = start_server(router).await; - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .expect("build reqwest client"); - - let resp = client - .post(format!("http://127.0.0.1:{port}/api/tasks")) - .json(&serde_json::json!({ "message": "" })) - .send() - .await - .expect("should connect"); - - // The SSE endpoint streams back events regardless; an empty message will - // either trigger a validation error event or the LLM will refuse. Either - // way the HTTP connection must succeed (the handler always returns 200 - // with an SSE stream). - let status = resp.status(); - let body = resp.text().await.expect("read body"); - eprintln!("empty message response (status {status}): {}", &body[..body.len().min(500)]); - - // We don't assert a specific status because the SSE handler returns 200 - // and streams error events. Verify we got *something* back. - assert!(!body.is_empty(), "response body should not be empty"); -} - -// ── JSON output validity (requires LLM provider) ───────────── - -#[tokio::test] -#[ignore = "requires configured LLM provider in ~/.smooth/providers.json"] -async fn headless_json_output_is_valid() { - let Some((router, _state)) = test_app() else { - return; - }; - let port = start_server(router).await; - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(120)) - .build() - .expect("build reqwest client"); - - let resp = client - .post(format!("http://127.0.0.1:{port}/api/tasks")) - .json(&serde_json::json!({ - "message": "Say hello", - })) - .send() - .await - .expect("POST /api/tasks"); - - assert_eq!(resp.status(), 200); - - let body = resp.text().await.expect("read body"); - let data_lines: Vec<&str> = body.lines().filter(|l| l.starts_with("data: ")).collect(); - assert!(!data_lines.is_empty(), "should receive at least one SSE data line"); - - // Every SSE data line must be valid JSON with a discriminant tag. - for line in &data_lines { - let json_str = line.strip_prefix("data: ").expect("strip prefix"); - let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap_or_else(|e| panic!("SSE data should be valid JSON: {json_str} (err: {e})")); - - // AgentEvent is a tagged enum — serde serializes it as { "VariantName": { ... } } - // or { "type": "..." } depending on the serde representation. Verify it's an object. - assert!(parsed.is_object(), "each SSE event should be a JSON object: {parsed}"); - } - - eprintln!("headless_json_output_is_valid: all {} events are valid JSON", data_lines.len()); -} - -// ── Orchestrator status endpoint ───────────────────────────── - -#[tokio::test] -async fn orchestrator_status_endpoint() { - let Some((app, _state)) = test_app() else { - return; - }; - - let resp = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/orchestrator/status") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true, "response should be ok: {body}"); - - let data = &body["data"]; - assert_eq!(data["state"], "idle", "fresh orchestrator should be idle"); - assert_eq!(data["active_workers"], 0, "no active workers on fresh orchestrator"); - assert_eq!(data["completed"], 0, "no completed beads on fresh orchestrator"); - assert!(data["pool_max_concurrency"].as_u64().is_some(), "should report pool_max_concurrency"); - assert_eq!(data["pool_active"], 0, "no active pool slots on fresh orchestrator"); -} - -// ── Delegate endpoint — creates sub-pearl ──────────────────── - -#[tokio::test] -async fn delegate_creates_sub_pearl() { - let Some((app, state)) = test_app() else { - return; - }; - - // POST /api/delegate with a task - let body = serde_json::json!({ - "parent_operator_id": "op-headless-test", - "task": "Write a function that adds two numbers" - }); - - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/delegate") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_string(&body).expect("serialize"))) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let resp_body = json_body(resp).await; - assert_eq!(resp_body["ok"], true, "delegate should succeed: {resp_body}"); - assert_eq!(resp_body["data"]["status"], "dispatched"); - - let delegation_id = resp_body["data"]["delegation_id"].as_str().expect("delegation_id should be a string"); - assert!(delegation_id.starts_with("th-"), "pearl ID should start with th-: {delegation_id}"); - - // Verify the pearl was created in the store. - let pearl = smooth_bigsmooth::pearls::get_pearl(&state.pearl_store, delegation_id) - .expect("get_pearl should not error") - .expect("pearl should exist"); - assert!(pearl.title.contains("[delegated]"), "title should contain [delegated]: {}", pearl.title); - assert_eq!(pearl.status, smooth_pearls::PearlStatus::Open); - - // GET /api/delegate/{id}/status — should be in_progress (Open maps to in_progress). - let status_resp = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/api/delegate/{delegation_id}/status")) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(status_resp.status(), 200); - - let status_body = json_body(status_resp).await; - assert_eq!(status_body["ok"], true); - assert_eq!(status_body["data"]["status"], "in_progress"); - assert_eq!(status_body["data"]["delegation_id"], delegation_id); - - // Close the pearl and check status transitions to "completed". - let _ = state.pearl_store.close(&[delegation_id]); - - let completed_resp = build_router(state.clone()) - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/api/delegate/{delegation_id}/status")) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - let completed_body = json_body(completed_resp).await; - assert_eq!(completed_body["data"]["status"], "completed"); -} - -// ── Delegate status — not found ────────────────────────────── - -#[tokio::test] -async fn delegate_status_not_found() { - let Some((app, _state)) = test_app() else { - return; - }; - - let resp = app - .oneshot( - Request::builder() - .method("GET") - .uri("/api/delegate/th-nonexistent-headless/status") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - let body = json_body(resp).await; - assert_eq!(body["ok"], false, "should report not found: {body}"); - assert!( - body["data"]["error"].as_str().unwrap_or("").contains("not found"), - "error should mention not found: {body}" - ); -} - -// ── Task endpoint via oneshot (verifies SSE response type) ─── - -#[tokio::test] -async fn task_endpoint_returns_sse_content_type() { - let Some((app, _state)) = test_app() else { - return; - }; - - // Even without a valid LLM provider, the handler should accept the - // request and start streaming (it will stream an error event when it - // fails to load providers.json or connect to the LLM). - let body = serde_json::json!({ - "message": "test", - }); - - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/tasks") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_string(&body).expect("serialize"))) - .expect("request"), - ) - .await - .expect("response"); - - // The SSE handler always returns 200 — errors are streamed as events. - assert_eq!(resp.status(), 200, "task endpoint should return 200"); - - let content_type = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or(""); - assert!( - content_type.contains("text/event-stream"), - "content-type should be text/event-stream, got: {content_type}" - ); - - // Read the body — should contain at least one SSE data line (likely an error - // event since we don't have providers configured in CI). - let bytes = resp.into_body().collect().await.expect("collect body").to_bytes(); - let body_str = String::from_utf8_lossy(&bytes); - let data_lines: Vec<&str> = body_str.lines().filter(|l| l.starts_with("data: ")).collect(); - - eprintln!( - "task_endpoint_returns_sse_content_type: got {} SSE events (provider likely not configured)", - data_lines.len() - ); - - // Every data line should be valid JSON. - for line in &data_lines { - let json_str = line.strip_prefix("data: ").expect("strip"); - assert!( - serde_json::from_str::(json_str).is_ok(), - "SSE data line should be valid JSON: {json_str}" - ); - } -} diff --git a/crates/smooth-bigsmooth/tests/integration.rs b/crates/smooth-bigsmooth/tests/integration.rs deleted file mode 100644 index 06c7942b..00000000 --- a/crates/smooth-bigsmooth/tests/integration.rs +++ /dev/null @@ -1,875 +0,0 @@ -//! Integration tests for Big Smooth server + PearlStore + Orchestrator. - -use axum::body::Body; -use axum::Router; -use http_body_util::BodyExt; -use hyper::Request; -use smooth_bigsmooth::orchestrator::{Orchestrator, OrchestratorState}; -use smooth_bigsmooth::server::{build_router, AppState}; -use smooth_pearls::PearlStore; -use tower::ServiceExt; - -/// Build a self-contained test app backed by a temp Dolt database. -fn test_app() -> Option<(Router, PearlStore)> { - let dir = tempfile::tempdir().expect("tempdir"); - let dolt_dir = dir.path().join("dolt"); - let pearl_store = match PearlStore::init(&dolt_dir) { - Ok(s) => s, - Err(_) => return None, // smooth-dolt binary not available - }; - let state = AppState::new(pearl_store.clone()); - let router = build_router(state); - // Leak tempdir so it isn't deleted while tests run. - std::mem::forget(dir); - Some((router, pearl_store)) -} - -/// Parse a JSON response body into a `serde_json::Value`. -async fn json_body(resp: axum::response::Response) -> serde_json::Value { - let bytes = resp.into_body().collect().await.expect("collect body").to_bytes(); - serde_json::from_slice(&bytes).expect("parse json") -} - -// ── 1. Health endpoint ──────────────────────────────────────── - -#[tokio::test] -async fn health_endpoint() { - let Some((app, _store)) = test_app() else { return }; - - let resp = app - .oneshot(Request::builder().uri("/health").body(Body::empty()).expect("request")) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - assert_eq!(body["service"], "big-smooth"); - assert!(body["uptime"].as_f64().is_some()); - assert!(body["timestamp"].as_str().is_some()); -} - -// ── 2. System health ────────────────────────────────────────── - -#[tokio::test] -async fn system_health() { - let Some((app, _store)) = test_app() else { return }; - - let resp = app - .oneshot(Request::builder().uri("/api/system/health").body(Body::empty()).expect("request")) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - - let data = &body["data"]; - assert_eq!(data["leader"]["status"], "healthy"); - assert_eq!(data["database"]["status"], "healthy"); - assert_eq!(data["sandbox"]["status"], "healthy"); - assert!(data["pearls"]["open_pearls"].as_u64().is_some()); -} - -// ── 3. Create issue via API ─────────────────────────────────── - -#[tokio::test] -async fn create_pearl_via_api() { - let Some((app, _store)) = test_app() else { return }; - - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/pearls") - .header("content-type", "application/json") - .body(Body::from( - r#"{"title":"Test issue","description":"Integration test","type":"task","priority":2}"#, - )) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - assert_eq!(body["data"]["title"], "Test issue"); - assert_eq!(body["data"]["description"], "Integration test"); - assert!(body["data"]["id"].as_str().is_some()); -} - -// ── 4. List issues via API ──────────────────────────────────── - -#[tokio::test] -async fn list_pearls_via_api() { - let Some((app, store)) = test_app() else { return }; - - // Seed two issues directly via the store. - let new = smooth_pearls::NewPearl { - title: "Alpha".into(), - description: String::new(), - pearl_type: smooth_pearls::PearlType::Task, - priority: smooth_pearls::Priority::Medium, - assigned_to: None, - parent_id: None, - labels: Vec::new(), - }; - store.create(&new).expect("create 1"); - store.create(&new).expect("create 2"); - - let resp = app - .oneshot(Request::builder().uri("/api/pearls").body(Body::empty()).expect("request")) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - - let data = body["data"].as_array().expect("data is array"); - assert_eq!(data.len(), 2); -} - -// ── 5. Get issue via API ────────────────────────────────────── - -#[tokio::test] -async fn get_pearl_via_api() { - let Some((app, store)) = test_app() else { return }; - - let new = smooth_pearls::NewPearl { - title: "Find me".into(), - description: "details".into(), - pearl_type: smooth_pearls::PearlType::Task, - priority: smooth_pearls::Priority::High, - assigned_to: None, - parent_id: None, - labels: Vec::new(), - }; - let created = store.create(&new).expect("create"); - - let resp = app - .oneshot( - Request::builder() - .uri(format!("/api/pearls/{}", created.id)) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - assert_eq!(body["data"]["title"], "Find me"); - assert_eq!(body["data"]["description"], "details"); -} - -// ── 6. Close issue via API ──────────────────────────────────── - -#[tokio::test] -async fn close_pearl_via_api() { - let Some((app, store)) = test_app() else { return }; - - let new = smooth_pearls::NewPearl { - title: "Close me".into(), - description: String::new(), - pearl_type: smooth_pearls::PearlType::Task, - priority: smooth_pearls::Priority::Medium, - assigned_to: None, - parent_id: None, - labels: Vec::new(), - }; - let created = store.create(&new).expect("create"); - - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/api/pearls/{}/close", created.id)) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - assert_eq!(body["data"]["closed"], 1); - - // Verify via store. - let issue = store.get(&created.id).expect("get").expect("exists"); - assert_eq!(issue.status, smooth_pearls::PearlStatus::Closed); -} - -// ── 7. Ready issues via API ────────────────────────────────── - -#[tokio::test] -async fn ready_issues_via_api() { - let Some((app, store)) = test_app() else { return }; - - // Create two issues, close one — only the open one should be "ready". - let new = smooth_pearls::NewPearl { - title: "Open issue".into(), - description: String::new(), - pearl_type: smooth_pearls::PearlType::Task, - priority: smooth_pearls::Priority::Medium, - assigned_to: None, - parent_id: None, - labels: Vec::new(), - }; - store.create(&new).expect("create open"); - - let closed_new = smooth_pearls::NewPearl { - title: "Closed issue".into(), - ..new.clone() - }; - let closed = store.create(&closed_new).expect("create to-close"); - store.close(&[&closed.id]).expect("close"); - - let resp = app - .oneshot(Request::builder().uri("/api/pearls/ready").body(Body::empty()).expect("request")) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - - let data = body["data"].as_array().expect("data is array"); - assert_eq!(data.len(), 1); - assert_eq!(data[0]["title"], "Open issue"); -} - -// ── 8. Stats via API ────────────────────────────────────────── - -#[tokio::test] -async fn stats_via_api() { - let Some((app, store)) = test_app() else { return }; - - let new = smooth_pearls::NewPearl { - title: "A".into(), - description: String::new(), - pearl_type: smooth_pearls::PearlType::Task, - priority: smooth_pearls::Priority::Medium, - assigned_to: None, - parent_id: None, - labels: Vec::new(), - }; - store.create(&new).expect("create 1"); - let b = store - .create(&smooth_pearls::NewPearl { - title: "B".into(), - ..new.clone() - }) - .expect("create 2"); - store.close(&[&b.id]).expect("close B"); - - let resp = app - .oneshot(Request::builder().uri("/api/pearls/stats").body(Body::empty()).expect("request")) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - assert_eq!(body["data"]["open"], 1); - assert_eq!(body["data"]["closed"], 1); - assert_eq!(body["data"]["total"], 2); -} - -// ── 9. Beads backward compatibility ────────────────────────── - -#[tokio::test] -async fn beads_backward_compat() { - let Some((app, store)) = test_app() else { return }; - - let new = smooth_pearls::NewPearl { - title: "Bead compat".into(), - description: String::new(), - pearl_type: smooth_pearls::PearlType::Task, - priority: smooth_pearls::Priority::Medium, - assigned_to: None, - parent_id: None, - labels: Vec::new(), - }; - let created = store.create(&new).expect("create"); - - // /api/pearls should list issues (alias for /api/pearls). - let resp = app - .clone() - .oneshot(Request::builder().uri("/api/pearls").body(Body::empty()).expect("request")) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - let data = body["data"].as_array().expect("array"); - assert_eq!(data.len(), 1); - - // /api/pearls/:id should return the specific issue. - let resp2 = app - .oneshot( - Request::builder() - .uri(format!("/api/pearls/{}", created.id)) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(resp2.status(), 200); - let body2 = json_body(resp2).await; - assert_eq!(body2["data"]["title"], "Bead compat"); -} - -// ── 10. Orchestrator schedules ready issues ────────────────── - -#[tokio::test] -async fn orchestrator_schedules_ready_issues() { - let tmp = tempfile::tempdir().expect("tempdir"); - let Ok(store) = PearlStore::init(&tmp.path().join("dolt")) else { return }; - std::mem::forget(tmp); - - // Seed ready issues. - let new = smooth_pearls::NewPearl { - title: "Ready task 1".into(), - description: String::new(), - pearl_type: smooth_pearls::PearlType::Task, - priority: smooth_pearls::Priority::High, - assigned_to: None, - parent_id: None, - labels: Vec::new(), - }; - store.create(&new).expect("create 1"); - store - .create(&smooth_pearls::NewPearl { - title: "Ready task 2".into(), - ..new - }) - .expect("create 2"); - - let mut orch = Orchestrator::new(3, store); - assert_eq!(orch.state_name(), "idle"); - - // Step once — should transition from Idle to Scheduling with the 2 ready issues. - orch.step().await.expect("step"); - assert_eq!(orch.state_name(), "scheduling"); - - if let OrchestratorState::Scheduling { ready_beads } = &orch.state { - assert_eq!(ready_beads.len(), 2); - } else { - panic!("expected Scheduling state"); - } -} - -// ── 11. Pearl CRUD lifecycle through Dolt ────────────────────── - -#[tokio::test] -async fn pearl_full_lifecycle_through_dolt() { - let Some((app, store)) = test_app() else { return }; - - // Create via store (simulating what dispatch does) - let pearl = store - .create(&smooth_pearls::NewPearl { - title: "E2E lifecycle pearl".into(), - description: "Tests full Dolt lifecycle".into(), - pearl_type: smooth_pearls::PearlType::Task, - priority: smooth_pearls::Priority::High, - assigned_to: None, - parent_id: None, - labels: vec!["e2e".into()], - }) - .expect("create pearl"); - - assert!(pearl.id.starts_with("th-")); - assert_eq!(pearl.status, smooth_pearls::PearlStatus::Open); - assert_eq!(pearl.labels, vec!["e2e"]); - - // Update via API - let resp = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/api/pearls/{}", pearl.id)) - .header("content-type", "application/json") - .body(Body::from(r#"{"status":"in_progress"}"#)) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(resp.status(), 200); - - // Verify via store - let updated = store.get(&pearl.id).expect("get").expect("exists"); - assert_eq!(updated.status, smooth_pearls::PearlStatus::InProgress); - - // Add comment - let comment = store.add_comment(&pearl.id, "Working on this now").expect("comment"); - assert!(comment.id.starts_with("th-")); - - // Add dependency - let blocker = store - .create(&smooth_pearls::NewPearl { - title: "Blocker pearl".into(), - description: String::new(), - pearl_type: smooth_pearls::PearlType::Task, - priority: smooth_pearls::Priority::Medium, - assigned_to: None, - parent_id: None, - labels: Vec::new(), - }) - .expect("create blocker"); - store.add_dep(&pearl.id, &blocker.id).expect("add dep"); - - // Pearl should be blocked - let blocked = store.blocked().expect("blocked"); - assert!(blocked.iter().any(|p| p.id == pearl.id)); - - // Close blocker, pearl should be unblocked - store.close(&[&blocker.id]).expect("close blocker"); - let blocked_after = store.blocked().expect("blocked after"); - assert!(!blocked_after.iter().any(|p| p.id == pearl.id)); - - // Close pearl via API - let resp2 = app - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/api/pearls/{}/close", pearl.id)) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(resp2.status(), 200); - - // Verify closed - let closed = store.get(&pearl.id).expect("get").expect("exists"); - assert_eq!(closed.status, smooth_pearls::PearlStatus::Closed); - assert!(closed.closed_at.is_some()); - - // Verify history via Dolt - let history = store.get_history(&pearl.id).expect("history"); - assert!(!history.is_empty(), "Dolt should have recorded field change history"); - let status_changes: Vec<_> = history.iter().filter(|h| h.field == "status").collect(); - assert!(!status_changes.is_empty(), "Should have status change history"); - - // Verify Dolt commit log has entries - let log = store.dolt_log(10).expect("dolt log"); - assert!(!log.is_empty(), "Dolt should have commit history from pearl mutations"); - - // Verify stats - let stats = store.stats().expect("stats"); - assert_eq!(stats.closed, 2); // pearl + blocker - assert_eq!(stats.total, 2); -} - -// ── 12. Session messages saved in Dolt ───────────────────────── - -#[tokio::test] -async fn session_messages_saved_in_dolt() { - let Some((_app, store)) = test_app() else { return }; - - // Use the DoltSessionStore directly - use smooth_bigsmooth::session::{MessageType, SessionMessage, SessionStore}; - let session_store = smooth_bigsmooth::session::DoltSessionStore::new(&store); - - let session_id = "test-session-001"; - - // Save messages - session_store - .save_message(SessionMessage { - id: "msg-1".into(), - session_id: session_id.into(), - from: "user".into(), - to: "bigsmooth".into(), - content: "Write a Rust function to add two numbers".into(), - timestamp: chrono::Utc::now(), - message_type: MessageType::Command, - tool_calls: Vec::new(), - }) - .expect("save msg 1"); - - session_store - .save_message(SessionMessage { - id: "msg-2".into(), - session_id: session_id.into(), - from: "bigsmooth".into(), - to: "operator-1".into(), - content: "Dispatching Rust task to operator-1".into(), - timestamp: chrono::Utc::now(), - message_type: MessageType::Command, - tool_calls: Vec::new(), - }) - .expect("save msg 2"); - - session_store - .save_message(SessionMessage { - id: "msg-3".into(), - session_id: session_id.into(), - from: "operator-1".into(), - to: "bigsmooth".into(), - content: "Task completed. 12/12 tests pass.".into(), - timestamp: chrono::Utc::now(), - message_type: MessageType::Response, - tool_calls: Vec::new(), - }) - .expect("save msg 3"); - - // Retrieve messages - let msgs = session_store.get_messages(session_id, 10).expect("get messages"); - assert_eq!(msgs.len(), 3, "should have 3 messages in session"); - // Messages may come back in any order when timestamps are identical (Dolt NOW() resolution) - let ids: Vec<&str> = msgs.iter().map(|m| m.id.as_str()).collect(); - assert!(ids.contains(&"msg-1"), "should contain msg-1"); - assert!(ids.contains(&"msg-2"), "should contain msg-2"); - assert!(ids.contains(&"msg-3"), "should contain msg-3"); - let msg1 = msgs.iter().find(|m| m.id == "msg-1").unwrap(); - assert_eq!(msg1.from, "user"); - let msg3 = msgs.iter().find(|m| m.id == "msg-3").unwrap(); - assert_eq!(msg3.message_type, MessageType::Response); - - // Limit works - let limited = session_store.get_messages(session_id, 2).expect("limited"); - assert_eq!(limited.len(), 2, "limit should cap at 2"); - - // Different session is empty - let other = session_store.get_messages("other-session", 10).expect("other session"); - assert!(other.is_empty()); -} - -// ── 12b. Chat SSE streaming endpoint (pearl th-26d708) ───── - -#[tokio::test] -async fn chat_stream_endpoint_returns_event_stream() { - let Some((app, store)) = test_app() else { return }; - - // Create a chat session so the messages route has something to bind to. - use smooth_bigsmooth::session::DoltSessionStore; - let session_store = DoltSessionStore::new(&store); - let session = session_store.create_chat_session("Stream test", "deepseek-v4-flash").expect("create session"); - - let body_json = serde_json::json!({"content": "ping"}); - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/api/chat/sessions/{}/messages/stream", session.id)) - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&body_json).expect("json"))) - .expect("request"), - ) - .await - .expect("response"); - - // Even without a configured LLM provider the route must answer 200 - // with SSE content-type — the error surfaces inside the stream as - // an `AgentEvent::Error` data line, not as a 4xx/5xx. - assert_eq!(resp.status(), 200, "stream endpoint should return 200"); - let ct = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or_default().to_string(); - assert!(ct.starts_with("text/event-stream"), "expected SSE content-type, got {ct:?}"); -} - -// ── 13. Projects API ───────────────────────────────────────── - -#[tokio::test] -async fn list_projects_returns_registered_projects() { - let Some((app, _store)) = test_app() else { return }; - - let resp = app - .oneshot(Request::builder().uri("/api/projects").body(Body::empty()).expect("request")) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - // The data should be an array (possibly empty if temp dir isn't in registry, - // but the endpoint should always return ok + array). - assert!(body["data"].is_array(), "data should be an array"); -} - -// ── 14. Project pearls API ─────────────────────────────────── - -#[tokio::test] -async fn project_pearls_returns_pearls_for_path() { - let dir = tempfile::tempdir().expect("tempdir"); - let dolt_dir = dir.path().join(".smooth").join("dolt"); - let pearl_store = match PearlStore::init(&dolt_dir) { - Ok(s) => s, - Err(_) => return, // smooth-dolt binary not available - }; - - // Seed a pearl so the project has data. - pearl_store - .create(&smooth_pearls::NewPearl { - title: "Project pearl".into(), - description: "Test pearl in project".into(), - pearl_type: smooth_pearls::PearlType::Task, - priority: smooth_pearls::Priority::Medium, - assigned_to: None, - parent_id: None, - labels: Vec::new(), - }) - .expect("create pearl"); - - let state = AppState::new(pearl_store); - let app = build_router(state); - - let path_encoded = urlencoding::encode(dir.path().to_str().unwrap()); - let uri = format!("/api/projects/pearls?path={path_encoded}"); - - let resp = app - .oneshot(Request::builder().uri(&uri).body(Body::empty()).expect("request")) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - - let data = body["data"].as_array().expect("data is array"); - assert_eq!(data.len(), 1); - assert_eq!(data[0]["title"], "Project pearl"); - - // Leak tempdir so Dolt dir stays alive (already done by test_app pattern). - std::mem::forget(dir); -} - -// ── 15. Generated policy includes port config ──────────────── - -#[tokio::test] -async fn generated_policy_includes_port_config() { - use smooth_bigsmooth::policy::{generate_policy_for_task, TaskType}; - - // Coding execute — ports should be enabled. - let toml = generate_policy_for_task("op-port", "bead-port", "execute", "tok", &[], TaskType::Coding, vec![]).expect("generate coding"); - let policy = smooth_policy::Policy::from_toml(&toml).expect("parse coding"); - assert!(policy.ports.enabled, "coding execute should have ports enabled"); - assert!(policy.ports.allow_range.0 > 0, "should have valid port range"); - - // Review — ports should be disabled. - let toml2 = generate_policy_for_task("op-port2", "bead-port2", "execute", "tok", &[], TaskType::Review, vec![]).expect("generate review"); - let policy2 = smooth_policy::Policy::from_toml(&toml2).expect("parse review"); - assert!(!policy2.ports.enabled, "review should have ports disabled"); -} - -// ── 16. Orchestrator status shows idle ─────────────────────── - -#[tokio::test] -async fn orchestrator_status_shows_idle() { - let Some((app, _store)) = test_app() else { return }; - - let resp = app - .oneshot(Request::builder().uri("/api/orchestrator/status").body(Body::empty()).expect("request")) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - // Fresh server — orchestrator may have already stepped (moving to scheduling - // if there are ready pearls), but on a clean store it should be idle. - assert!(body["data"]["state"].is_string(), "state should be a string"); - assert_eq!(body["data"]["active_workers"], 0); -} - -// ── 17. Policy denies .env files through mount translation ─── - -#[tokio::test] -async fn policy_denies_env_files_through_mount_translation() { - use smooth_bigsmooth::policy::{generate_policy_for_task, TaskType}; - - let mounts = vec![smooth_policy::MountMapping { - guest_path: "/workspace".into(), - host_path: "/home/user/project".into(), - }]; - - let toml = generate_policy_for_task("op-deny", "bead-deny", "execute", "tok", &[], TaskType::Coding, mounts).expect("generate"); - let policy = smooth_policy::Policy::from_toml(&toml).expect("parse"); - - // .env should be denied via mount translation - assert!(policy.is_guest_path_denied("/workspace/.env").expect("glob .env"), ".env should be denied"); - // Normal source file should be allowed - assert!( - !policy.is_guest_path_denied("/workspace/src/main.rs").expect("glob main.rs"), - "main.rs should be allowed" - ); - // Other sensitive patterns - assert!( - policy.is_guest_path_denied("/workspace/secret.pem").expect("glob .pem"), - ".pem should be denied" - ); - assert!( - policy.is_guest_path_denied("/workspace/.ssh/id_rsa").expect("glob .ssh"), - ".ssh/* should be denied" - ); -} - -// ── 18. System health includes orchestrator ────────────────── - -#[tokio::test] -async fn system_health_includes_orchestrator() { - let Some((app, _store)) = test_app() else { return }; - - let resp = app - .oneshot(Request::builder().uri("/api/system/health").body(Body::empty()).expect("request")) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - - let data = &body["data"]; - // The orchestrator field should exist with state and worker counts. - assert!(data["orchestrator"].is_object(), "orchestrator field should be an object"); - assert!(data["orchestrator"]["state"].is_string(), "orchestrator state should be a string"); - assert!(data["orchestrator"]["active_workers"].is_number(), "active_workers should be a number"); - assert!(data["orchestrator"]["completed"].is_number(), "completed should be a number"); -} - -// ── 19. Delegation full lifecycle ──────────────────────────── - -#[tokio::test] -async fn delegation_full_lifecycle() { - let Some((app, store)) = test_app() else { return }; - - // 1. POST /api/delegate — creates a pearl - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/delegate") - .header("content-type", "application/json") - .body(Body::from( - r#"{"parent_operator_id":"op-parent-1","task":"Write unit tests for the auth module"}"#, - )) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(resp.status(), 200); - - let body = json_body(resp).await; - assert_eq!(body["ok"], true); - let delegation_id = body["data"]["delegation_id"].as_str().expect("delegation_id should be a string").to_string(); - assert!(!delegation_id.is_empty(), "delegation_id should not be empty"); - - // 2. GET /api/delegate/{id}/status — should be in_progress (Open pearl maps to in_progress) - let resp2 = app - .clone() - .oneshot( - Request::builder() - .uri(format!("/api/delegate/{delegation_id}/status")) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(resp2.status(), 200); - - let body2 = json_body(resp2).await; - assert_eq!(body2["ok"], true); - assert_eq!(body2["data"]["status"], "in_progress"); - - // 3. Close the pearl via /api/pearls/{id}/close - let resp3 = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/api/pearls/{delegation_id}/close")) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(resp3.status(), 200); - let body3 = json_body(resp3).await; - assert_eq!(body3["ok"], true); - - // 4. GET /api/delegate/{id}/status — should now be completed - let resp4 = app - .oneshot( - Request::builder() - .uri(format!("/api/delegate/{delegation_id}/status")) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(resp4.status(), 200); - - let body4 = json_body(resp4).await; - assert_eq!(body4["ok"], true); - assert_eq!(body4["data"]["status"], "completed"); - - // Verify the pearl was actually closed in the store too. - let pearl = store.get(&delegation_id).expect("get").expect("exists"); - assert_eq!(pearl.status, smooth_pearls::PearlStatus::Closed); -} - -// ── Orchestrator snapshots saved in Dolt ─────────────────── - -#[tokio::test] -async fn orchestrator_snapshots_saved_in_dolt() { - let Some((_app, store)) = test_app() else { return }; - - use smooth_bigsmooth::session::{OrchestratorSnapshot, SessionStatus, SessionStore}; - let session_store = smooth_bigsmooth::session::DoltSessionStore::new(&store); - - // Save a snapshot - session_store - .save_snapshot(OrchestratorSnapshot { - session_id: "sess-001".into(), - bead_id: "th-abc123".into(), - phase: "Monitoring".into(), - operator_id: "operator-1".into(), - dispatched_at: chrono::Utc::now(), - last_checkpoint_id: None, - status: SessionStatus::Active, - }) - .expect("save snapshot"); - - // Retrieve it - let snap = session_store.get_snapshot("sess-001").expect("get").expect("exists"); - assert_eq!(snap.bead_id, "th-abc123"); - assert_eq!(snap.phase, "Monitoring"); - assert_eq!(snap.status, SessionStatus::Active); - - // List active sessions - let active = session_store.list_active_sessions().expect("list active"); - assert_eq!(active.len(), 1); - assert_eq!(active[0].session_id, "sess-001"); - - // Mark completed - session_store.mark_completed("sess-001").expect("mark completed"); - let snap2 = session_store.get_snapshot("sess-001").expect("get").expect("exists"); - assert_eq!(snap2.status, SessionStatus::Completed); - - // No longer in active list - let active2 = session_store.list_active_sessions().expect("list active after"); - assert!(active2.is_empty()); -} diff --git a/crates/smooth-bigsmooth/tests/narc_escalation.rs b/crates/smooth-bigsmooth/tests/narc_escalation.rs deleted file mode 100644 index cc4d6da1..00000000 --- a/crates/smooth-bigsmooth/tests/narc_escalation.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Integration test for the Wonk → Safehouse Narc escalation loop. -//! -//! This test spins two independent in-process services: -//! -//! 1. A **Safehouse Narc** service (backed by Big Smooth's `AppState` -//! exposing `/api/narc/judge`) bound to an ephemeral port. -//! 2. A per-VM **Wonk** server (`smooth_wonk::build_router`) bound to a -//! different ephemeral port, configured with a restrictive base policy -//! plus a [`smooth_wonk::NarcClient`] pointed at service #1. -//! -//! Then it drives `POST /check/network` against Wonk with three categories -//! of request: -//! -//! - **In the static allowlist** → Wonk answers locally (no Narc round-trip). -//! - **Obviously safe (e.g. `registry.npmjs.org`)** → Wonk escalates to -//! Narc, Narc's rule engine short-circuits, Wonk caches the approval. -//! - **Obviously dangerous (e.g. `pastebin.com`)** → Wonk escalates to -//! Narc, Narc denies via the rule engine. -//! - **Unknown domain with no LLM** → Wonk escalates to Narc, Narc -//! escalates to human (fail closed). -//! -//! The test uses `SafehouseNarc::without_llm()` so there is no LLM call — -//! it exercises the rule engine and the escalation plumbing end to end, and -//! runs in ~1 second with no external dependencies. - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -use axum::routing::post; -use axum::{Json, Router}; -use smooth_bigsmooth::safehouse_narc::SafehouseNarc; -use smooth_narc::judge::{JudgeDecision, JudgeRequest}; -use smooth_policy::Policy; -use smooth_wonk::{build_router as wonk_router, AppState as WonkAppState, NarcClient}; - -const EXAMPLE_POLICY: &str = r#" -[metadata] -operator_id = "op-narc-test" -bead_id = "pearl-narc-test" -phase = "execute" - -[auth] -token = "test-token" - -[network] -[[network.allow]] -domain = "api.llmgateway.io" - -[filesystem] -writable = true -deny_patterns = [] - -[tools] -allow = ["read_file", "write_file", "bash"] -deny = [] - -[beads] - -[mcp] - -[access_requests] -enabled = true -auto_approve_domains = [] -auto_approve_tools = [] -"#; - -/// Spawn a minimal Big Smooth axum router exposing just the Narc route, -/// backed by a `SafehouseNarc::without_llm()` so we never make real LLM -/// calls in this test. -async fn spawn_narc_service() -> String { - let narc = SafehouseNarc::without_llm(); - let router = Router::new() - .route( - "/api/narc/judge", - post(move |Json(req): Json| { - let narc = narc.clone(); - async move { Json::(narc.judge(req).await) } - }), - ) - .layer(tower_http::cors::CorsLayer::permissive()); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr: SocketAddr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let _ = axum::serve(listener, router).await; - }); - tokio::time::sleep(Duration::from_millis(50)).await; - format!("http://{addr}") -} - -/// Spawn a Wonk server with the narc escalation client wired in. -async fn spawn_wonk_with_narc(narc_base_url: &str) -> String { - let policy = Policy::from_toml(EXAMPLE_POLICY).expect("parse policy"); - let holder = smooth_wonk::PolicyHolder::from_policy(policy); - let negotiator = smooth_wonk::Negotiator::new("http://127.0.0.1:1/no-leader", holder.clone()); - let state = Arc::new(WonkAppState::new(holder, negotiator).with_narc(NarcClient::new(narc_base_url))); - let app = wonk_router(state); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr: SocketAddr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - tokio::time::sleep(Duration::from_millis(50)).await; - format!("http://{addr}") -} - -/// Helper: ask Wonk `/check/network` about a domain and return -/// `(allowed, reason)`. -async fn check_network(wonk_url: &str, domain: &str) -> (bool, String) { - #[derive(serde::Deserialize)] - struct Resp { - allowed: bool, - reason: String, - } - // EXAMPLE_POLICY at the top of this file has `token = "test-token"`; - // Wonk's middleware now requires Authorization: Bearer on every - // /check/* call. - let resp = reqwest::Client::new() - .post(format!("{wonk_url}/check/network")) - .bearer_auth("test-token") - .json(&serde_json::json!({"domain": domain, "path": "/", "method": "GET"})) - .send() - .await - .expect("post /check/network"); - let parsed: Resp = resp.json().await.expect("parse /check/network response"); - (parsed.allowed, parsed.reason) -} - -#[tokio::test] -async fn static_policy_allowlist_answers_without_narc_round_trip() { - let narc_url = spawn_narc_service().await; - let wonk_url = spawn_wonk_with_narc(&narc_url).await; - - let (allowed, reason) = check_network(&wonk_url, "api.llmgateway.io").await; - assert!(allowed, "static allowlist domain should be allowed: {reason}"); - assert!(reason.contains("static policy allowlist"), "reason should name the static path: {reason}"); -} - -#[tokio::test] -async fn obviously_safe_domain_is_approved_by_narc_rule_engine() { - let narc_url = spawn_narc_service().await; - let wonk_url = spawn_wonk_with_narc(&narc_url).await; - - let (allowed, reason) = check_network(&wonk_url, "registry.npmjs.org").await; - assert!(allowed, "registry.npmjs.org should be approved by Narc: {reason}"); - assert!(reason.contains("Narc approved"), "reason should name Narc: {reason}"); -} - -#[tokio::test] -async fn narc_approval_is_cached_in_wonk_runtime_allowlist() { - let narc_url = spawn_narc_service().await; - let wonk_url = spawn_wonk_with_narc(&narc_url).await; - - // First call goes through Narc. - let (a1, r1) = check_network(&wonk_url, "static.rust-lang.org").await; - assert!(a1, "first call should be Narc-approved: {r1}"); - assert!(r1.contains("Narc approved")); - - // Second call should hit Wonk's runtime cache and say so in the reason. - let (a2, r2) = check_network(&wonk_url, "static.rust-lang.org").await; - assert!(a2, "second call should be cached: {r2}"); - assert!( - r2.contains("runtime allowlist") || r2.contains("runtime_allowlist") || r2.contains("Narc-approved"), - "second call should indicate runtime cache hit: {r2}" - ); -} - -#[tokio::test] -async fn dangerous_domain_is_denied_by_narc_rule_engine() { - let narc_url = spawn_narc_service().await; - let wonk_url = spawn_wonk_with_narc(&narc_url).await; - - let (allowed, reason) = check_network(&wonk_url, "pastebin.com").await; - assert!(!allowed, "pastebin.com should be denied: {reason}"); - assert!( - reason.contains("Narc denied") || reason.contains("dangerous"), - "reason should name Narc deny: {reason}" - ); -} - -#[tokio::test] -async fn unknown_domain_escalates_to_human_without_llm() { - let narc_url = spawn_narc_service().await; - let wonk_url = spawn_wonk_with_narc(&narc_url).await; - - let (allowed, reason) = check_network(&wonk_url, "weird-unknown-thing.example").await; - assert!(!allowed, "unknown domain should fail closed when Narc has no LLM: {reason}"); - assert!( - reason.contains("escalated to human") || reason.contains("fail closed"), - "reason should indicate escalation: {reason}" - ); -} diff --git a/crates/smooth-bigsmooth/tests/safehouse_e2e.rs b/crates/smooth-bigsmooth/tests/safehouse_e2e.rs deleted file mode 100644 index 4c3ceb75..00000000 --- a/crates/smooth-bigsmooth/tests/safehouse_e2e.rs +++ /dev/null @@ -1,762 +0,0 @@ -//! Safehouse + Bootstrap Bill + cross-language E2E. -//! -//! This is the payoff test: it runs Smooth as its intended architecture -//! end-to-end, without any shortcuts, and validates that real LLM-driven -//! agents can produce passing code in **both Rust and TypeScript** through -//! the same orchestration pipeline. -//! -//! # Topology exercised -//! -//! ```text -//! test process -//! ├── spawns Bootstrap Bill (host subprocess, binds 0.0.0.0:) -//! └── Bill spawns: -//! ├── Safehouse VM (alpine + `safehouse` binary) -//! │ ├── Big Smooth (axum :4400) -//! │ ├── Archivist (:4401, reachable on host via port map) -//! │ └── Safehouse cast: Wonk, Goalie, Narc, Scribe (all tokio tasks) -//! │ -//! ├── Operator VM #1 (Rust) -//! │ └── smooth-operative + per-VM cast -//! │ → LLM reads task_api_spec/tests/spec_test.rs -//! │ → writes src/lib.rs into bind-mounted host workspace -//! │ → Scribe forwards logs to Safehouse Archivist -//! │ -//! └── Operator VM #2 (TypeScript) -//! └── smooth-operative + per-VM cast -//! → LLM reads hono_api_spec/tests/spec.test.ts -//! → writes src/server.ts into bind-mounted host workspace -//! → Scribe forwards logs to Safehouse Archivist -//! ``` -//! -//! # What gets asserted -//! -//! 1. Safehouse VM boots and Big Smooth's `/health` responds. -//! 2. Rust agent writes `src/lib.rs`. Host runs `cargo test`. ≥50% pass. -//! 3. LLM judge evaluates the Rust code. `pass` verdict or score ≥ 5. -//! 4. TypeScript agent writes `src/server.ts`. Host runs -//! `pnpm install --frozen-lockfile && pnpm exec vitest`. ≥50% pass. -//! 5. LLM judge evaluates the TS code. `pass` verdict or score ≥ 5. -//! 6. **Archivist query shows entries from BOTH operator VMs** (distinct -//! `source_vm` values). This is the proof that cross-VM log forwarding -//! works end-to-end. - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -mod common; - -use std::collections::HashMap; -use std::path::PathBuf; -use std::process::Child; -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use smooth_bootstrap_bill::protocol::{BindMountSpec, PortMapping, SandboxSpec}; -use smooth_bootstrap_bill::BillClient; -use tokio_tungstenite::tungstenite::Message; - -use common::{call_llm_judge, copy_tree, find_workspace_target, parse_cargo_test_summary, parse_vitest_summary, spawn_bill_subprocess, wait_for_http_ok}; - -/// Explicit teardown handle. The test calls [`cleanup`] at the end of -/// the happy path. Panics unwind to test harness which kills the child -/// process group — Bill's own panic hook cleans up its sandboxes in -/// that case. -struct TeardownGuard { - bill_child: Option, - bill_client: Option, - safehouse_name: Option, -} - -impl TeardownGuard { - async fn cleanup(mut self) { - if let (Some(client), Some(name)) = (self.bill_client.take(), self.safehouse_name.take()) { - let _ = client.destroy(&name).await; - } - if let Some(mut child) = self.bill_child.take() { - let _ = child.kill(); - let _ = child.wait(); - } - } -} - -#[tokio::test] -#[ignore = "full Safehouse + Bill + two operator VMs + real LLM + cargo test + pnpm + LLM judge — requires hardware virt, providers.json, pnpm, node>=20"] -async fn safehouse_full_stack_rust_and_typescript_with_judge() { - // --- Prereq gate ------------------------------------------------------- - let providers_path = dirs_next::home_dir().expect("home dir").join(".smooth/providers.json"); - if !providers_path.exists() { - eprintln!("SKIP: ~/.smooth/providers.json missing — need real LLM credentials"); - return; - } - let registry = smooth_cast::provider_migration::load_providers_with_migration(&providers_path).expect("load providers.json"); - let llm = registry.default_llm_config().expect("default llm"); - - let operative_host_path = find_workspace_target("aarch64-unknown-linux-musl/release/smooth-operative") - .expect("operative binary missing — run scripts/build-operative.sh") - .canonicalize() - .expect("canon"); - let _operative_host_dir = operative_host_path.parent().expect("runner has parent").to_path_buf(); - let safehouse_bin_path = find_workspace_target("aarch64-unknown-linux-musl/release/safehouse") - .expect("safehouse binary missing — run scripts/build-safehouse.sh") - .canonicalize() - .expect("canon"); - let safehouse_bin_dir = safehouse_bin_path.parent().expect("has parent").to_path_buf(); - - assert!(which_exists("pnpm"), "pnpm is required on PATH for the TypeScript leg"); - assert!(which_exists("node"), "node is required on PATH for the TypeScript leg"); - - // --- Start Bill -------------------------------------------------------- - let (bill_child, bill_addr) = spawn_bill_subprocess().await.expect("spawn bill"); - let bill_host_port = bill_addr.port(); - eprintln!("bill: {bill_addr}"); - let bill_url = format!("http://127.0.0.1:{bill_host_port}"); - let bill_client = BillClient::new(bill_url); - let version = bill_client.ping().await.expect("bill ping"); - eprintln!("bill version: {version}"); - - // --- Spawn Safehouse VM ------------------------------------------------ - // Probe a free host port for Archivist BEFORE calling Bill so we can - // put it in the Safehouse's env (which the Safehouse then uses to - // construct the operator-facing archivist URL). This is the chicken- - // and-egg solution: we know the port before the VM boots, Bill - // honors it via the fixed host_port in the PortMapping. - let archivist_host_port: u16 = { - let l = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("probe archivist port"); - let p = l.local_addr().expect("addr").port(); - drop(l); - p - }; - eprintln!("pre-reserved archivist host port: {archivist_host_port}"); - - // Also pre-reserve the Big Smooth API host port so operators can reach it. - let bigsmooth_host_port_fixed: u16 = { - let l = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("probe bigsmooth port"); - let p = l.local_addr().expect("addr").port(); - drop(l); - p - }; - eprintln!("pre-reserved bigsmooth host port: {bigsmooth_host_port_fixed}"); - - let home_dot_smooth = dirs_next::home_dir().expect("home").join(".smooth"); - let safehouse_name = format!("smooth-safehouse-{}", &uuid::Uuid::new_v4().to_string()[..8]); - let mut safehouse_env: HashMap = HashMap::new(); - safehouse_env.insert("SMOOTH_SAFEHOUSE_MODE".into(), "1".into()); - // Critical: tells Big Smooth to take dispatch_ws_task_sandboxed, which - // routes operator spawns through our sandbox client (which, with - // SMOOTH_BOOTSTRAP_BILL_URL set, is BillSandboxClient). Without this, - // Reaching the host from inside a microVM: - // - // microsandbox's TCP proxy intercepts all non-loopback outbound TCP - // and does `TcpStream::connect(dst)` on the HOST with the guest's - // original destination address. 127.0.0.1 NEVER works because the - // guest kernel handles loopback internally — the packet never - // reaches microsandbox's virtual NIC. - // - // The solution: use the HOST's real network interface IP. Bill is on - // 0.0.0.0:, so any routable IP that reaches the host works. - // We detect the primary interface IP at test time and pass it in. - // The `allow_host_loopback: true` flag on the SandboxSpec tells - // Bill to apply `NetworkPolicy::allow_all()`, which permits - // private-network (192.168.x, etc.) outbound through the proxy. - let host_ip = detect_host_ip(); - eprintln!("host IP for VM→host connectivity: {host_ip}"); - safehouse_env.insert("SMOOTH_BOOTSTRAP_BILL_URL".into(), format!("http://{host_ip}:{bill_host_port}")); - safehouse_env.insert("SMOOTH_OPERATIVE_HOST_PATH".into(), operative_host_path.to_string_lossy().to_string()); - safehouse_env.insert("SMOOTH_ARCHIVIST_HOST_PORT".into(), archivist_host_port.to_string()); - safehouse_env.insert("SMOOTH_SAFEHOUSE_DB".into(), "/root/.smooth/smooth.db".into()); - safehouse_env.insert("SMOOTH_SAFEHOUSE_PORT".into(), "4400".into()); - // Alpine's base image has no HOME set by default, which breaks - // dirs_next::home_dir() inside the VM and therefore breaks - // load_llm_config_for_runner. Set HOME explicitly to /root so Big - // Smooth finds the bind-mounted ~/.smooth/providers.json. - safehouse_env.insert("HOME".into(), "/root".into()); - safehouse_env.insert("RUST_LOG".into(), "info,smooth_bigsmooth=debug".into()); - // Tell the Safehouse VM where to find smooth-dolt for the pearl store. - safehouse_env.insert("SMOOTH_DOLT".into(), "/opt/smooth/bin/smooth-dolt".into()); - // Stable cache key so Rust deps compiled on the first test run - // persist to ~/.smooth/pearl-env/safehouse-e2e-test/ and are reused - // on subsequent runs. First run is cold (~5 min for cargo build); - // every run after is warm (~5s). - safehouse_env.insert("SMOOTH_ENV_CACHE_KEY".into(), "safehouse-e2e-test".into()); - // Tell the Safehouse the host port of its own Big Smooth API, so it - // can pass SMOOTH_BIGSMOOTH_URL to operators for pearl tool access. - safehouse_env.insert("SMOOTH_BIGSMOOTH_HOST_PORT".into(), bigsmooth_host_port_fixed.to_string()); - // Pre-compute the full operator-facing Big Smooth URL so operators can - // call pearl tools. This is simpler than deriving it inside the VM. - let bigsmooth_operator_url = format!("http://{host_ip}:{bigsmooth_host_port_fixed}"); - safehouse_env.insert("SMOOTH_BIGSMOOTH_OPERATOR_URL".into(), bigsmooth_operator_url); - // Tell the Safehouse the host path to ~/.smooth so it can tell Bill to - // bind-mount it into operator VMs. Inside the VM, dirs_next gives /root - // which is the guest home, not the host home. - if let Some(home) = dirs_next::home_dir() { - let smooth_home = home.join(".smooth"); - if smooth_home.exists() { - safehouse_env.insert("SMOOTH_HOME_HOST_PATH".into(), smooth_home.to_string_lossy().to_string()); - } - } - - let spec = SandboxSpec { - name: safehouse_name.clone(), - image: "alpine".into(), - cpus: 2, - memory_mb: 2048, - env: safehouse_env, - mounts: vec![ - BindMountSpec { - host_path: safehouse_bin_dir.to_string_lossy().to_string(), - guest_path: "/opt/smooth/bin".into(), - readonly: true, - }, - BindMountSpec { - host_path: home_dot_smooth.to_string_lossy().to_string(), - guest_path: "/root/.smooth".into(), - readonly: false, - }, - ], - ports: vec![ - // Big Smooth on 127.0.0.1 only (bind_all would conflict with microsandbox's own bind). - // Operators reach Big Smooth via the host IP + this port because microsandbox's - // TCP proxy handles outbound from VMs (the proxy does TcpStream::connect on the host - // with the guest's original dest — so connecting to host_ip:port works even without - // bind_all, because the VM's outbound goes through the proxy which connects on the host). - PortMapping { - host_port: bigsmooth_host_port_fixed, - guest_port: 4400, - bind_all: false, - }, - // Archivist must be reachable from OTHER VMs via the host IP. - // microsandbox publishes on 127.0.0.1 only; `bind_all: true` - // tells Bill to run a TCP proxy on 0.0.0.0 as well. - PortMapping { - host_port: archivist_host_port, - guest_port: 4401, - bind_all: true, - }, - ], - timeout_seconds: 1800, - // The Safehouse VM must reach Bill on host loopback - // (127.0.0.1:) so Big Smooth can request operator pods. - // Default microsandbox policy denies loopback/private outbound. - allow_host_loopback: true, - // Safehouse itself doesn't need env caching (it's Big Smooth, not - // an operator). Operator VMs get caching via the dispatch path. - env_cache_key: None, - use_named_volume_for_cache: false, - secrets: vec![], - }; - - let (resolved_name, host_ports, _created_at) = bill_client.spawn(spec).await.expect("spawn safehouse"); - eprintln!("safehouse: {resolved_name} host_ports={host_ports:?}"); - let teardown = TeardownGuard { - bill_child: Some(bill_child), - bill_client: Some(bill_client.clone()), - safehouse_name: Some(resolved_name.clone()), - }; - - let bigsmooth_host_port = host_ports.iter().find(|p| p.guest_port == 4400).map(|p| p.host_port).expect("4400 mapping"); - assert_eq!( - bigsmooth_host_port, bigsmooth_host_port_fixed, - "bill should have honored our fixed bigsmooth port" - ); - // archivist_host_port was pre-reserved above; confirm Bill honored it. - let archivist_resolved = host_ports.iter().find(|p| p.guest_port == 4401).map(|p| p.host_port).expect("4401 mapping"); - assert_eq!(archivist_resolved, archivist_host_port, "bill should have honored our fixed archivist port"); - eprintln!("safehouse API : http://127.0.0.1:{bigsmooth_host_port}"); - eprintln!("safehouse ARCH: http://127.0.0.1:{archivist_host_port}"); - - // Exec the safehouse binary. This is a long-running server; we spawn - // the exec in a background task and let it run until we destroy the - // sandbox at teardown. The exec future will return with an error - // when the sandbox is destroyed, which is fine. - { - let exec_client = bill_client.clone(); - let exec_name = resolved_name.clone(); - tokio::spawn(async move { - match exec_client.exec(&exec_name, &["/opt/smooth/bin/safehouse".to_string()]).await { - Ok((stdout, stderr, code)) => { - eprintln!( - "safehouse exec finished: code={code}\nstdout: {stdout}\nstderr tail: {}", - &stderr[stderr.len().saturating_sub(2000)..] - ); - } - Err(e) => { - eprintln!("safehouse exec error: {e}"); - } - } - }); - } - - // Wait for Big Smooth to come up. - wait_for_http_ok(&format!("http://127.0.0.1:{bigsmooth_host_port}/health"), Duration::from_secs(120)) - .await - .expect("safehouse /health never came up"); - eprintln!("safehouse Big Smooth is healthy"); - - // Wait for Archivist too. - wait_for_http_ok(&format!("http://127.0.0.1:{archivist_host_port}/health"), Duration::from_secs(30)) - .await - .expect("safehouse Archivist /health never came up"); - eprintln!("safehouse Archivist is healthy"); - - // --- Open WS to Big Smooth -------------------------------------------- - let ws_url = format!("ws://127.0.0.1:{bigsmooth_host_port}/ws"); - let (mut ws, _) = tokio_tungstenite::connect_async(&ws_url).await.expect("connect ws"); - // Drain Connected event. - let _ = tokio::time::timeout(Duration::from_secs(5), ws.next()).await; - - // --- Rust leg ---------------------------------------------------------- - eprintln!("\n===== RUST LEG ====="); - let rust_workspace = tempfile::tempdir().expect("rust workspace tempdir"); - let rust_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/task_api_spec"); - copy_tree(&rust_fixture, rust_workspace.path()); - // Init a pearl store in the workspace so the operator can use pearl tools directly - if let Ok(_store) = smooth_pearls::PearlStore::init(&rust_workspace.path().join(".smooth").join("dolt")) { - eprintln!(" initialized .smooth/dolt/ in rust workspace for pearl tools"); - } - let (rust_passed, rust_failed, rust_code) = run_rust_leg(&mut ws, rust_workspace.path(), &llm).await; - - // --- TypeScript leg ---------------------------------------------------- - eprintln!("\n===== TYPESCRIPT LEG ====="); - let ts_workspace = tempfile::tempdir().expect("ts workspace tempdir"); - let ts_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hono_api_spec"); - copy_tree(&ts_fixture, ts_workspace.path()); - // Init a pearl store in the workspace for pearl tools - if let Ok(_store) = smooth_pearls::PearlStore::init(&ts_workspace.path().join(".smooth").join("dolt")) { - eprintln!(" initialized .smooth/dolt/ in ts workspace for pearl tools"); - } - let (ts_passed, ts_failed, ts_code) = run_ts_leg(&mut ws, ts_workspace.path(), &llm).await; - - // --- Archivist diagnostic: check if operators got the URL --------------- - for (name, ws) in [("rust", rust_workspace.path()), ("ts", ts_workspace.path())] { - let diag = ws.join(".archivist-diag.txt"); - if diag.exists() { - let content = std::fs::read_to_string(&diag).unwrap_or_default(); - eprintln!("archivist diag ({name}): {content}"); - } else { - eprintln!("archivist diag ({name}): .archivist-diag.txt NOT FOUND (runner didn't write it?)"); - } - } - - // --- Archivist cross-VM log assertion ---------------------------------- - eprintln!("\n===== ARCHIVIST CROSS-VM LOG ASSERTION ====="); - // Give the forwarder a beat to flush any trailing batches. - tokio::time::sleep(Duration::from_secs(2)).await; - let archivist_query_url = format!("http://127.0.0.1:{archivist_host_port}/query?limit=500"); - let resp = reqwest::get(&archivist_query_url).await.expect("archivist query"); - let entries: serde_json::Value = resp.json().await.expect("archivist json"); - let entries_arr = entries.as_array().cloned().unwrap_or_default(); - eprintln!("archivist: {} total entries", entries_arr.len()); - // The /query endpoint strips source_vm (returns bare LogEntry); use - // /stats to get per-VM aggregation. - let stats_url = format!("http://127.0.0.1:{archivist_host_port}/stats"); - let stats_resp = reqwest::get(&stats_url).await.expect("archivist stats"); - let stats: serde_json::Value = stats_resp.json().await.expect("archivist stats json"); - eprintln!("archivist stats: {}", serde_json::to_string_pretty(&stats).unwrap_or_default()); - let by_vm = stats.get("by_vm").and_then(|v| v.as_object()).cloned().unwrap_or_default(); - - // --- Pearl + Session message assertions -------------------------------- - eprintln!("\n===== PEARL + SESSION ASSERTIONS ====="); - - // Query Big Smooth's pearl stats — dispatch should have created pearls - // for each task leg (Rust + TS). - let pearl_stats_url = format!("http://127.0.0.1:{bigsmooth_host_port}/api/pearls/stats"); - let pearl_stats: Option = match reqwest::get(&pearl_stats_url).await { - Ok(resp) => resp.json::().await.ok(), - Err(_) => None, - }; - if let Some(ref stats) = pearl_stats { - let ok = stats["ok"].as_bool().unwrap_or(false); - if ok { - let data = &stats["data"]; - let total = data["total"].as_u64().unwrap_or(0); - let closed = data["closed"].as_u64().unwrap_or(0); - eprintln!("pearl stats: total={total}, closed={closed}, open={}", data["open"].as_u64().unwrap_or(0)); - // Dispatch creates a pearl per task. We dispatched 2 tasks (Rust + TS). - assert!(total >= 2, "expected at least 2 pearls from 2 dispatched tasks, got {total}"); - // Both tasks completed, so pearls should be closed. - assert!(closed >= 2, "expected at least 2 closed pearls, got {closed}"); - } else { - eprintln!("pearl stats: API returned ok=false (smooth-dolt may not be available in VM — build with scripts/build-safehouse.sh)"); - } - } else { - eprintln!("pearl stats: could not query (pearl store may not be available in VM)"); - } - - // Query the pearl list for details - let pearls_url = format!("http://127.0.0.1:{bigsmooth_host_port}/api/pearls"); - if let Ok(resp) = reqwest::get(&pearls_url).await { - if let Ok(body) = resp.json::().await { - let pearls = body["data"].as_array(); - if let Some(pearls) = pearls { - eprintln!("pearls ({}):", pearls.len()); - for p in pearls { - eprintln!( - " {} [{}] {}", - p["id"].as_str().unwrap_or("?"), - p["status"].as_str().unwrap_or("?"), - p["title"].as_str().unwrap_or("?"), - ); - } - } - } - } - - // --- Workspace pearl verification (agent-created pearls) ---------------- - eprintln!("\n===== WORKSPACE PEARL VERIFICATION ====="); - // The agents create/close pearls in the WORKSPACE's .smooth/dolt/, not - // the Safehouse's. Check the host-side Dolt DB for each workspace. - for (name, ws_path) in [("rust", rust_workspace.path()), ("ts", ts_workspace.path())] { - let ws_dolt = ws_path.join(".smooth").join("dolt"); - if ws_dolt.exists() { - match smooth_pearls::PearlStore::open(&ws_dolt) { - Ok(store) => { - let all = store.list(&smooth_pearls::PearlQuery::new()).unwrap_or_default(); - let closed: Vec<_> = all.iter().filter(|p| p.status == smooth_pearls::PearlStatus::Closed).collect(); - eprintln!(" {name} workspace: {} pearls ({} closed)", all.len(), closed.len()); - for p in &all { - eprintln!(" {} [{}] {}", p.id, p.status.as_str(), p.title); - } - // The agent *should* create pearls via create_pearl tool, but - // this is LLM-dependent behavior — some models focus on the - // coding task and skip pearl tracking. Log a warning instead - // of failing the test; the server-side pearl lifecycle - // (dispatch → complete) is validated separately. - if all.is_empty() { - eprintln!(" ⚠ {name} workspace: agent did not create any pearls (LLM did not use create_pearl tool)"); - } - if closed.is_empty() && !all.is_empty() { - eprintln!(" ⚠ {name} workspace: agent created pearls but did not close them"); - } - } - Err(e) => eprintln!(" {name} workspace: failed to open pearl store: {e}"), - } - } else { - eprintln!(" {name} workspace: no .smooth/dolt/ (pearl tools were not available)"); - } - } - - // --- Session message verification -------------------------------------- - eprintln!("\n===== SESSION MESSAGE VERIFICATION ====="); - - // Query session messages for the pearl IDs we know about. The dispatch - // path uses the task UUID as both the pearl's session_id and the WS - // task_id. We can list all pearls and check their IDs. - if let Ok(resp) = reqwest::get(&format!("http://127.0.0.1:{bigsmooth_host_port}/api/pearls")).await { - if let Ok(body) = resp.json::().await { - if let Some(pearls) = body["data"].as_array() { - for p in pearls { - // The pearl's "session" is its task ID. Query messages for it. - // The dispatch path saves the task_id as session_id in messages. - // We can't know the exact task_id, but we can check that SOME - // messages exist across all sessions. - if let Some(id) = p["id"].as_str() { - eprintln!(" checking messages for pearl {id}..."); - } - } - // Instead, query a broad set — all pearls' messages won't match - // since session_id = task UUID, not pearl ID. But we can verify - // the endpoint works and there are messages saved. - // The real proof: stats endpoint already showed pearls were created - // and closed. Session messages are the bonus. - eprintln!(" found {} pearls with potential sessions", pearls.len()); - } - } - } - - // --- Final assertions -------------------------------------------------- - eprintln!("\n===== SUMMARY ====="); - let rust_total = rust_passed + rust_failed; - let ts_total = ts_passed + ts_failed; - eprintln!("rust leg: {rust_passed}/{rust_total} pass, judge={:?}", rust_code); - eprintln!("ts leg: {ts_passed}/{ts_total} pass, judge={:?}", ts_code); - eprintln!("archivist by_vm: {:?}", by_vm.keys().collect::>()); - - assert!(rust_total > 0, "rust leg produced zero tests — fixture broken or agent never wrote src/lib.rs"); - assert!(ts_total > 0, "ts leg produced zero tests — fixture broken or agent never wrote src/server.ts"); - let rust_rate = f64::from(rust_passed) / f64::from(rust_total); - let ts_rate = f64::from(ts_passed) / f64::from(ts_total); - assert!( - rust_rate >= 0.5, - "rust leg {rust_passed}/{rust_total} ({:.0}%) below 50% floor", - rust_rate * 100.0 - ); - assert!(ts_rate >= 0.5, "ts leg {ts_passed}/{ts_total} ({:.0}%) below 50% floor", ts_rate * 100.0); - - let (rust_verdict, rust_score, _) = rust_code; - let (ts_verdict, ts_score, _) = ts_code; - assert!( - rust_verdict == "pass" || rust_score >= 5, - "rust judge rejected: verdict={rust_verdict} score={rust_score}" - ); - assert!( - ts_verdict == "pass" || ts_score >= 5, - "ts judge rejected: verdict={ts_verdict} score={ts_score}" - ); - - assert!( - by_vm.len() >= 2, - "archivist should have seen logs from ≥2 distinct source_vms (proving cross-VM forwarding), got {}: {by_vm:?}", - by_vm.len() - ); - - eprintln!("\n✓ safehouse_full_stack_rust_and_typescript_with_judge: GREEN"); - - // Explicit async teardown — Drop cannot create a new runtime from - // inside the test's runtime. - teardown.cleanup().await; -} - -/// Run the Rust leg. Returns `(passed, failed, judge verdict)`. -async fn run_rust_leg( - ws: &mut tokio_tungstenite::WebSocketStream>, - workspace: &std::path::Path, - llm: &smooth_operator::llm::LlmConfig, -) -> (u32, u32, (String, i64, String)) { - // Write full instructions to a file in the workspace (bind-mounted - // from host). The env var SMOOTH_TASK gets a short pointer; the - // runner will also check /workspace/.smooth-task for details. - // This avoids the kernel cmdline TooLarge limit. - let task_detail = concat!( - "STEP 1: Run create_pearl with title 'Rust API implementation' and description 'Build axum REST API per spec'.\n\n", - "STEP 2: Read tests/spec_test.rs carefully. Create src/lib.rs: pub fn app() -> axum::Router. ", - "Endpoints: GET /health (json {status: ok, version: string}), POST /tasks (201, title required else 422, ", - "auto id via uuid, created_at via chrono::Utc::now, default priority medium, status open, tags default empty vec), ", - "GET /tasks (list as JSON array, optional ?status= and ?priority= query filters), GET /tasks/:id (404 if missing), ", - "PATCH /tasks/:id (partial update, 200 with updated task), DELETE /tasks/:id (204 no content). ", - "CRITICAL: use Arc>> as axum State. All handler return types must be concrete: ", - "use (StatusCode, Json) or Json consistently. Do NOT mix impl IntoResponse with different concrete types ", - "in the same function -- that causes type inference failures. For error returns use StatusCode alone. ", - "Only create src/lib.rs.\n\n", - "STEP 3: Run 'apk add cargo rust' and 'cargo check' in the workspace. Fix ALL compile errors.\n\n", - "STEP 4: Run 'cargo test -- --test-threads=1'. Fix any test failures. ", - "Repeat until all tests pass. Do not finish until cargo test succeeds.\n\n", - "STEP 5: Run close_pearl with the pearl ID from step 1.\n\n", - "You MUST complete all 5 steps in order. Do not skip any step." - ); - std::fs::write(workspace.join(".smooth-task"), task_detail).expect("write task detail"); - let task_message = - "Read /workspace/.smooth-task for full instructions. Use create_pearl tool to track your work before starting, and close_pearl when done."; - // Kimi K2 is significantly stronger than gpt-5.4-mini at Rust code - // generation — fewer impl IntoResponse mistakes, better axum idioms. - send_task_and_wait(ws, task_message, workspace, Some("kimi-k2.5")).await; - - let lib_rs = workspace.join("src/lib.rs"); - assert!(lib_rs.exists(), "rust leg: src/lib.rs was not written"); - let generated = std::fs::read_to_string(&lib_rs).expect("read lib.rs"); - eprintln!("rust leg: src/lib.rs ({} bytes)", generated.len()); - - // cargo test against the persisted workspace. - let output = tokio::task::spawn_blocking({ - let ws = workspace.to_path_buf(); - move || { - std::process::Command::new("cargo") - .arg("test") - .arg("--") - .arg("--test-threads=1") - .current_dir(&ws) - .output() - .expect("cargo test") - } - }) - .await - .expect("join cargo test"); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}\n{stderr}"); - let (passed, failed) = parse_cargo_test_summary(&combined).unwrap_or((0, 0)); - eprintln!("rust cargo test: {passed} passed, {failed} failed"); - - let trimmed = tail_lines(&combined, 4000); - let judge = call_llm_judge(&llm.api_url, &llm.api_key, &llm.model, "rust", &generated, &trimmed, passed, failed) - .await - .expect("judge rust"); - (passed, failed, judge) -} - -/// Run the TypeScript leg. Returns `(passed, failed, judge verdict)`. -async fn run_ts_leg( - ws: &mut tokio_tungstenite::WebSocketStream>, - workspace: &std::path::Path, - llm: &smooth_operator::llm::LlmConfig, -) -> (u32, u32, (String, i64, String)) { - let task_detail = concat!( - "STEP 1: Run create_pearl with title 'TypeScript API implementation' and description 'Build Hono REST API per spec'.\n\n", - "STEP 2: Read tests/spec.test.ts. Create src/server.ts: export default Hono app. ", - "Endpoints: GET /health (json status+version), POST /tasks (201, title required else 422, ", - "auto id via crypto.randomUUID, created_at ISO, default priority medium, status open, tags empty), ", - "GET /tasks (optional status/priority filters), GET /tasks/:id (404 if missing), ", - "PATCH /tasks/:id, DELETE /tasks/:id (204). Use Map state. ", - "Only create src/server.ts.\n\n", - "STEP 3: Run 'apk add nodejs npm', then 'npm install -g pnpm', then 'pnpm install', then 'pnpm test'. ", - "Fix errors and retest until all tests pass.\n\n", - "STEP 4: Run close_pearl with the pearl ID from step 1.\n\n", - "You MUST complete all 4 steps in order. Do not skip any step." - ); - std::fs::write(workspace.join(".smooth-task"), task_detail).expect("write task detail"); - let task_message = - "Read /workspace/.smooth-task for full instructions. Use create_pearl tool to track your work before starting, and close_pearl when done."; - send_task_and_wait(ws, task_message, workspace, Some("kimi-k2.5")).await; - - let server_ts = workspace.join("src/server.ts"); - assert!(server_ts.exists(), "ts leg: src/server.ts was not written"); - let generated = std::fs::read_to_string(&server_ts).expect("read server.ts"); - eprintln!("ts leg: src/server.ts ({} bytes)", generated.len()); - - // pnpm install + vitest on host. - let install = tokio::task::spawn_blocking({ - let ws = workspace.to_path_buf(); - move || { - std::process::Command::new("pnpm") - .args(["install", "--frozen-lockfile", "--silent"]) - .current_dir(&ws) - .output() - .expect("pnpm install") - } - }) - .await - .expect("join pnpm install"); - if !install.status.success() { - eprintln!("pnpm install failed stdout:\n{}", String::from_utf8_lossy(&install.stdout)); - eprintln!("pnpm install failed stderr:\n{}", String::from_utf8_lossy(&install.stderr)); - panic!("pnpm install failed"); - } - - let vitest = tokio::task::spawn_blocking({ - let ws = workspace.to_path_buf(); - move || { - std::process::Command::new("pnpm") - .args(["exec", "vitest", "run", "--reporter=json", "--outputFile=vitest-result.json"]) - .current_dir(&ws) - .output() - .expect("pnpm exec vitest") - } - }) - .await - .expect("join vitest"); - let vitest_stderr = String::from_utf8_lossy(&vitest.stderr).to_string(); - let result_path = workspace.join("vitest-result.json"); - let result_json = std::fs::read_to_string(&result_path).unwrap_or_else(|_| "{}".into()); - let (passed, failed) = parse_vitest_summary(&result_json).unwrap_or((0, 0)); - eprintln!("vitest: {passed} passed, {failed} failed"); - - let test_output = format!("{}\n---\n{}", tail_lines(&result_json, 2000), tail_lines(&vitest_stderr, 1500)); - let judge = call_llm_judge(&llm.api_url, &llm.api_key, &llm.model, "typescript", &generated, &test_output, passed, failed) - .await - .expect("judge ts"); - (passed, failed, judge) -} - -/// Send a TaskStart and block until TaskComplete (or TaskError) arrives. -async fn send_task_and_wait( - ws: &mut tokio_tungstenite::WebSocketStream>, - message: &str, - workspace: &std::path::Path, - model: Option<&str>, -) { - let task_start = serde_json::json!({ - "type": "TaskStart", - "message": message, - "model": model, - "budget": 5.0, - "working_dir": workspace.to_string_lossy(), - }); - ws.send(Message::Text(task_start.to_string().into())).await.expect("send TaskStart"); - - let deadline = tokio::time::Instant::now() + Duration::from_secs(1800); - let mut task_error: Option = None; - while tokio::time::Instant::now() < deadline { - let next = tokio::time::timeout(Duration::from_secs(60), ws.next()).await; - let Ok(Some(Ok(msg))) = next else { continue }; - let text = match msg { - Message::Text(t) => t.to_string(), - Message::Close(_) => break, - _ => continue, - }; - let Ok(event) = serde_json::from_str::(&text) else { - continue; - }; - let ty = event.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match ty { - "TaskComplete" => { - eprintln!(" ws: TaskComplete"); - return; - } - "TaskError" => { - task_error = event.get("message").and_then(|v| v.as_str()).map(String::from); - eprintln!(" ws: TaskError: {task_error:?}"); - break; - } - "ToolCallStart" | "ToolCallComplete" => { - let name = event.get("tool_name").and_then(|v| v.as_str()).unwrap_or(""); - let args = event.get("arguments").and_then(|v| v.as_str()).unwrap_or(""); - eprintln!(" ws: {ty} {name} args={}", truncate(args, 200)); - } - "TokenDelta" => { - if let Some(c) = event.get("content").and_then(|v| v.as_str()) { - // Only print stderr passthrough (prefixed) and short deltas. - if c.contains("[runner stderr]") - || c.contains("[cast-summary]") - || c.contains("error") - || c.contains("Error") - || c.contains("archivist") - || c.contains("ARCHIVIST") - { - eprintln!(" ws: TokenDelta {}", truncate(c, 300)); - } - } - } - _ => { - eprintln!(" ws: {ty}"); - } - } - } - panic!("task did not complete: {task_error:?}"); -} - -fn truncate(s: &str, max: usize) -> String { - if s.len() <= max { - s.to_string() - } else { - format!("{}…", &s[..max]) - } -} - -fn which_exists(cmd: &str) -> bool { - std::process::Command::new("which") - .arg(cmd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} - -fn tail_lines(s: &str, max_bytes: usize) -> String { - if s.len() <= max_bytes { - s.to_string() - } else { - format!("...[truncated]...\n{}", &s[s.len() - max_bytes..]) - } -} - -/// Detect the host's primary non-loopback IPv4 address. -/// -/// This is needed because microsandbox VMs cannot reach the host via -/// 127.0.0.1 (that stays inside the guest kernel's own loopback). The -/// TCP proxy on the host intercepts outbound packets from the guest and -/// calls `TcpStream::connect(dst)` with the guest's original destination. -/// Using the host's real interface IP (e.g., 192.168.1.50 on WiFi) means -/// the proxy's connect call reaches Bill on 0.0.0.0. -/// -/// Falls back to the `SMOOTH_VM_HOST_GATEWAY` env var if detection fails. -fn detect_host_ip() -> String { - if let Ok(ip) = std::env::var("SMOOTH_VM_HOST_GATEWAY") { - return ip; - } - // Use a UDP connect trick: open a UDP socket "connected" to a public - // IP (no actual traffic), then read back the local address the kernel - // selected. This gives us the interface IP the OS would use to route - // to the internet, which is exactly what we want. - let socket = std::net::UdpSocket::bind("0.0.0.0:0").expect("bind UDP probe"); - socket.connect("8.8.8.8:53").expect("connect UDP probe"); - let local = socket.local_addr().expect("local addr"); - local.ip().to_string() -} diff --git a/crates/smooth-bigsmooth/tests/sandbox_security.rs b/crates/smooth-bigsmooth/tests/sandbox_security.rs deleted file mode 100644 index 3d938b65..00000000 --- a/crates/smooth-bigsmooth/tests/sandbox_security.rs +++ /dev/null @@ -1,353 +0,0 @@ -//! Security integration tests for the auto-mode permission system. -//! -//! Exercises the full Decision::Ask → AccessStore → human resolution -//! → SafehouseNarc replay chain end-to-end, in-process. The real -//! microsandbox-spawn-a-VM-and-curl-an-attacker tests from -//! th-9dcc40's description are still the gold standard, but they -//! require platform-specific fixtures (macOS HVF, the built -//! operative binary, network setup). This file proves the -//! decision pipeline + AccessStore semantics + persistent-grants -//! interaction at a layer where no VM is needed. -//! -//! The Safehouse Narc lives in Big Smooth's process. By driving it -//! directly here we exercise the same code paths that the in-VM Wonk -//! would hit when it escalates a `/check/*` call to -//! `POST /api/narc/judge`. -//! -//! Covered: -//! 1. Unknown domain → judge holds → human approves at scope=once → -//! replay returns Approve (no persistence) -//! 2. Unknown domain → judge holds → human denies → replay returns -//! Deny (and stays denied for the same request) -//! 3. Session approve caches: a second judge() call against the -//! same domain after a session-scope approve uses the cache and -//! returns Approve without re-asking -//! 4. Dangerous CLI pattern (`rm -rf /`) is denied by the rule -//! engine BEFORE the Ask path runs — no human prompt fires -//! 5. Persistent (user-scope) grant in wonk-allow.toml -//! short-circuits the judge to Approve without filing a pending -//! request -//! 6. Hold-for-human times out → judge returns EscalateToHuman -//! (fail closed) and the pending entry is expired -//! -//! Each test seeds its own AccessStore + SafehouseNarc so they can -//! run in parallel without state leaking. Resolution helpers -//! (`approve_after`, `deny_after`) spawn a tokio task that polls the -//! store for the pending request and resolves it once it appears. -//! -//! Pearl th-9dcc40. - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use std::sync::Arc; -use std::time::Duration; - -use smooth_bigsmooth::access::AccessStore; -use smooth_bigsmooth::safehouse_narc::SafehouseNarc; -use smooth_bigsmooth::wonk_grants::{SharedWonkGrants, WonkGrants}; -use smooth_narc::judge::{Decision, JudgeKind, JudgeRequest, Scope}; -use smooth_narc::ResolutionVerdict; - -fn req_network(domain: &str, bead_id: &str) -> JudgeRequest { - JudgeRequest { - kind: JudgeKind::Network, - operator_id: "op".into(), - bead_id: bead_id.into(), - phase: "execute".into(), - resource: domain.into(), - detail: Some("GET /".into()), - task_summary: Some("agent is testing security".into()), - agent_reason: None, - } -} - -fn req_cli(command: &str, bead_id: &str) -> JudgeRequest { - JudgeRequest { - kind: JudgeKind::Cli, - operator_id: "op".into(), - bead_id: bead_id.into(), - phase: "execute".into(), - resource: command.into(), - detail: None, - task_summary: None, - agent_reason: None, - } -} - -/// Spawn a tokio task that polls `access` for any pending request and -/// resolves it once it appears. Returns the task's JoinHandle so the -/// test can wait on it after `judge()` returns. Times out after 2s -/// to avoid hanging the suite on a regression. -fn approve_after(access: &AccessStore, verdict: ResolutionVerdict, scope: Scope, glob: Option) -> tokio::task::JoinHandle<()> { - let access = access.clone(); - tokio::spawn(async move { - for _ in 0..200 { - if let Some(pending) = access.list_pending().first().cloned() { - let _ = access.resolve(&pending.id, verdict, scope, glob); - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - // Don't panic — the assertion in the caller will surface the - // missing resolution as the real failure mode. - }) -} - -#[tokio::test] -async fn unknown_domain_judge_holds_then_human_approves() { - // Build a Narc with no LLM (so an unknown domain falls through - // coerce_by_confidence → Ask path). 2-second hold so we don't - // wait the real 60s if a resolver never shows. - let access = AccessStore::new(); - let narc = SafehouseNarc::with_timeout(None, access.clone(), Duration::from_secs(2)); - - // No LLM means run_llm_judge errors → JudgeDecision::escalate → - // Decision::EscalateToHuman, NOT Decision::Ask. To exercise the - // hold-for-human path we instead drive hold_for_human directly - // OR seed the judge with a grant that doesn't match (so the - // pipeline drops to LLM failure path). The cleanest way: drive - // hold_for_human directly using an Ask verdict synthesized as - // the LLM judge would produce one. - let ask = smooth_narc::JudgeDecision::ask("test: unknown domain", Scope::default_options()); - - // Spawn the resolver that will approve once the pending appears. - let resolver = approve_after(&access, ResolutionVerdict::Approve, Scope::Once, None); - - // hold_for_human is private to the crate; we hit it via the public - // judge() flow that wraps it. To get the Ask through to the flow - // without an LLM, we use a custom Narc method by constructing - // it ourselves. Easier: drive the AccessStore directly + verify - // resolution shape, which is what's actually under test here. - let req = req_network("custom.attacker.example", "pearl-1"); - let new_req = smooth_narc::NewAccessRequest { - bead_id: req.bead_id.clone(), - operator_id: req.operator_id.clone(), - kind: req.kind.as_str().to_string(), - resource: req.resource.clone(), - detail: req.detail.clone(), - reason: ask.reason.clone(), - scope_options: ask.scope_options.clone(), - }; - let (id, fut) = access.file_pending(new_req); - let resolution = fut.await_resolution_with_timeout(Duration::from_secs(2)).await; - resolver.await.unwrap(); - - let r = resolution.expect("human approved"); - assert_eq!(r.id, id); - assert_eq!(r.verdict, ResolutionVerdict::Approve); - assert_eq!(r.scope, Scope::Once); - drop(narc); // keep narc alive past the resolver to satisfy borrow -} - -#[tokio::test] -async fn unknown_domain_judge_holds_then_human_denies() { - let access = AccessStore::new(); - let _narc = SafehouseNarc::with_timeout(None, access.clone(), Duration::from_secs(2)); - - let resolver = approve_after(&access, ResolutionVerdict::Deny, Scope::Once, None); - - let req = req_network("attacker.example", "pearl-1"); - let new_req = smooth_narc::NewAccessRequest::with_defaults( - req.bead_id.clone(), - req.operator_id.clone(), - req.kind.as_str().to_string(), - req.resource.clone(), - "test: unknown domain", - ); - let (_id, fut) = access.file_pending(new_req); - let resolution = fut.await_resolution_with_timeout(Duration::from_secs(2)).await.expect("denied"); - resolver.await.unwrap(); - assert_eq!(resolution.verdict, ResolutionVerdict::Deny); -} - -#[tokio::test] -async fn dangerous_cli_pattern_denies_before_ask() { - // `rm -rf /` is in DANGEROUS_CLI_SUBSTRINGS. The rule engine - // returns Deny immediately — no Ask, no human prompt, no - // pending request in the store. This guards the property that - // the Ask path doesn't override safety floors. - let access = AccessStore::new(); - let narc = SafehouseNarc::with_timeout(None, access.clone(), Duration::from_secs(2)); - - let decision = narc.judge(req_cli("rm -rf / --no-preserve-root", "pearl-1")).await; - - assert_eq!(decision.decision, Decision::Deny); - assert_eq!(access.pending_count(), 0, "no human prompt should fire for known-dangerous CLI"); -} - -#[tokio::test] -async fn dangerous_domain_denies_before_ask() { - // pastebin.com is in DANGEROUS_DOMAIN_SUFFIXES — the human is - // never asked. Defense against a prompt-injection escalation - // where the agent argues to exfil-via-pastebin. - let access = AccessStore::new(); - let narc = SafehouseNarc::with_timeout(None, access.clone(), Duration::from_secs(2)); - - let decision = narc.judge(req_network("pastebin.com", "pearl-1")).await; - - assert_eq!(decision.decision, Decision::Deny); - assert_eq!(access.pending_count(), 0); -} - -#[tokio::test] -async fn persistent_grant_short_circuits_without_ask() { - // Pre-seed a user-scope grant for a domain not in the rule - // engine's OBVIOUSLY_SAFE list. The judge should approve via - // the persistent-grant path without filing a pending request. - let access = AccessStore::new(); - let mut grants = WonkGrants::new(); - grants.add_host("custom-allowed.example"); - let shared = SharedWonkGrants::new(grants); - let narc = SafehouseNarc::with_timeout(None, access.clone(), Duration::from_secs(2)).with_grants(shared); - - let decision = narc.judge(req_network("custom-allowed.example", "pearl-1")).await; - - assert_eq!(decision.decision, Decision::Approve); - assert!(decision.reason.contains("wonk-allow")); - assert_eq!(access.pending_count(), 0, "persistent grant should not produce a human prompt"); -} - -#[tokio::test] -async fn persistent_grant_glob_matches_subdomain() { - // A `*.example.com` grant approves any subdomain — common - // shape for "allow the whole vendor". - let access = AccessStore::new(); - let mut grants = WonkGrants::new(); - grants.add_host("*.example.com"); - let shared = SharedWonkGrants::new(grants); - let narc = SafehouseNarc::with_timeout(None, access.clone(), Duration::from_secs(2)).with_grants(shared); - - let decision = narc.judge(req_network("api.example.com", "pearl-1")).await; - assert_eq!(decision.decision, Decision::Approve); - - let decision = narc.judge(req_network("foo.bar.example.com", "pearl-2")).await; - assert_eq!(decision.decision, Decision::Approve); - - // A different domain still falls through (no LLM → escalate). - let decision = narc.judge(req_network("evil.example.com.attacker.io", "pearl-3")).await; - assert!(decision.decision != Decision::Approve, "glob must NOT match adjacent labels"); -} - -#[tokio::test] -async fn rule_engine_safe_domain_approves_without_ask() { - // registry.npmjs.org is in OBVIOUSLY_SAFE — every coding task - // needs the npm registry, asking the human about it would be - // miserable. Sanity check that the safe list still wins. - let access = AccessStore::new(); - let narc = SafehouseNarc::with_timeout(None, access.clone(), Duration::from_secs(2)); - - let decision = narc.judge(req_network("registry.npmjs.org", "pearl-1")).await; - - assert_eq!(decision.decision, Decision::Approve); - assert_eq!(access.pending_count(), 0); -} - -#[tokio::test] -async fn cache_dedupes_repeated_judge_calls_on_safe_domain() { - // Two calls on the same (kind, bead_id, resource) tuple: the - // first goes through the rule engine and caches the approval, - // the second hits the cache. Both return Approve. - let access = AccessStore::new(); - let narc = SafehouseNarc::with_timeout(None, access.clone(), Duration::from_secs(2)); - - let first = narc.judge(req_network("registry.npmjs.org", "pearl-1")).await; - let second = narc.judge(req_network("registry.npmjs.org", "pearl-1")).await; - - assert_eq!(first.decision, Decision::Approve); - assert_eq!(second.decision, Decision::Approve); -} - -#[tokio::test] -async fn hold_times_out_to_escalate_when_no_resolver() { - // The hold-for-human path returns EscalateToHuman after the - // timeout if no resolution arrives. This is the fail-closed - // safety net — a misconfigured TUI or a Big Smooth that - // hasn't been booted should never silently approve. - let access = AccessStore::new(); - let _narc = SafehouseNarc::with_timeout(None, access.clone(), Duration::from_millis(100)); - - // Drive hold_for_human-equivalent semantics via AccessStore: - // file pending, await with the same short timeout, no resolver. - let new_req = smooth_narc::NewAccessRequest::with_defaults("pearl-1", "op", "network", "unknown.example", "test timeout"); - let (id, fut) = access.file_pending(new_req); - let result = fut.await_resolution_with_timeout(Duration::from_millis(100)).await; - - assert!(result.is_none(), "no resolver → no resolution"); - // Pending still in the store — the runner's timeout logic - // expires it explicitly. We mirror that here. - let _ = access.expire(&id); - assert_eq!(access.pending_count(), 0); -} - -#[tokio::test] -async fn multiple_pending_resolve_independently() { - // Two unrelated requests pending simultaneously. The human - // resolves them out of order. Each waiter wakes with its own - // resolution. - let access = AccessStore::new(); - - let req_a = smooth_narc::NewAccessRequest::with_defaults("pearl-1", "op", "network", "a.example", "a"); - let req_b = smooth_narc::NewAccessRequest::with_defaults("pearl-2", "op", "network", "b.example", "b"); - let (id_a, fut_a) = access.file_pending(req_a); - let (id_b, fut_b) = access.file_pending(req_b); - - // Resolve B first. - access.resolve(&id_b, ResolutionVerdict::Approve, Scope::Once, None).unwrap(); - let r_b = fut_b.await_resolution_with_timeout(Duration::from_secs(1)).await.expect("b"); - assert_eq!(r_b.id, id_b); - - // Then A. - access.resolve(&id_a, ResolutionVerdict::Deny, Scope::Once, None).unwrap(); - let r_a = fut_a.await_resolution_with_timeout(Duration::from_secs(1)).await.expect("a"); - assert_eq!(r_a.id, id_a); - assert_eq!(r_a.verdict, ResolutionVerdict::Deny); -} - -#[tokio::test] -async fn grants_merge_in_takes_effect_immediately_without_narc_restart() { - // Simulate the runtime path: a /api/access/approve resolution - // appends a grant, merge_in puts it in the live SharedWonkGrants, - // and the very next judge() call short-circuits to Approve. - // Pearl th-38b72c x th-9dcc40 interop. - let access = AccessStore::new(); - let shared = SharedWonkGrants::new(WonkGrants::new()); - let narc = SafehouseNarc::with_timeout(None, access.clone(), Duration::from_secs(2)).with_grants(shared.clone()); - - // First call: no grant → no LLM → escalate. - let first = narc.judge(req_network("late-allowed.example", "pearl-1")).await; - assert_ne!(first.decision, Decision::Approve); - - // Merge in a grant (this is what resolve_access does after a - // successful append_grant write). - let mut more = WonkGrants::new(); - more.add_host("late-allowed.example"); - shared.merge_in(more); - - // Second call with a DIFFERENT bead_id to bypass the cache from - // the first call. The judge sees the new grant via - // check_persistent_grants → Approve. - let second = narc.judge(req_network("late-allowed.example", "pearl-2")).await; - assert_eq!(second.decision, Decision::Approve); -} - -#[tokio::test] -async fn approve_then_resolve_carries_glob_override() { - // The glob_override flows from the resolution back to the - // waiter (and from there to Wonk's runtime allowlist in the - // real Safehouse Narc flow). - let access = Arc::new(AccessStore::new()); - - let new_req = smooth_narc::NewAccessRequest::with_defaults("pearl-1", "op", "network", "api.openai.com", "test glob"); - let (id, fut) = access.file_pending(new_req); - - let access_for_resolver = access.clone(); - let id_for_resolver = id.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(20)).await; - let _ = access_for_resolver.resolve(&id_for_resolver, ResolutionVerdict::Approve, Scope::Session, Some("*.openai.com".into())); - }); - - let resolution = fut.await_resolution_with_timeout(Duration::from_secs(2)).await.expect("resolved"); - assert_eq!(resolution.glob_override.as_deref(), Some("*.openai.com")); - assert_eq!(resolution.scope, Scope::Session); -} diff --git a/crates/smooth-bigsmooth/tests/sandboxed_dispatch.rs b/crates/smooth-bigsmooth/tests/sandboxed_dispatch.rs deleted file mode 100644 index 17bcd1e3..00000000 --- a/crates/smooth-bigsmooth/tests/sandboxed_dispatch.rs +++ /dev/null @@ -1,554 +0,0 @@ -//! Sandboxed dispatch integration test. -//! -//! Verifies the end-to-end sandboxed dispatch flow: Big Smooth accepts a -//! WebSocket `TaskStart`, routes dispatch through [`dispatch_ws_task_sandboxed`], -//! spawns a real microVM, runs the task inside it, and streams -//! `sandbox.create` / `sandbox.exec` / `TokenDelta` / `TaskComplete` events -//! back to the client. (Sandboxed dispatch is the only path now — the -//! in-process path was removed in ae6fb7a.) -//! -//! Marked `#[ignore]` because it boots a hardware-virtualized microVM (slow -//! on first run, requires KVM on Linux or HVF on Apple Silicon). Run with: -//! -//! cargo test -p smooth-bigsmooth --test sandboxed_dispatch -- --ignored --nocapture -//! -//! This is the proof that Big Smooth can operate as the architecture -//! intends — READ-ONLY, with all work happening inside a sandbox. The -//! in-process dispatch path is unaffected. - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use std::net::SocketAddr; -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use tempfile::TempDir; -use tokio_tungstenite::tungstenite::Message; - -/// Build a fresh Big Smooth on an ephemeral port and return its base URL -/// + the tempdir holding its database (kept alive for the test lifetime). -async fn spawn_bigsmooth() -> (String, TempDir) { - let tmp = tempfile::tempdir().expect("tempdir"); - let dolt_dir = tmp.path().join("dolt"); - let pearl_store = smooth_pearls::PearlStore::init(&dolt_dir).expect("init pearl store"); - - let state = smooth_bigsmooth::server::AppState::new(pearl_store); - let router = smooth_bigsmooth::server::build_router(state); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let addr: SocketAddr = listener.local_addr().expect("addr"); - tokio::spawn(async move { - let _ = axum::serve(listener, router).await; - }); - - // Give axum a beat to accept connections. - tokio::time::sleep(Duration::from_millis(50)).await; - (format!("http://{addr}"), tmp) -} - -/// Open a WebSocket client against Big Smooth's `/ws` endpoint. -async fn open_ws(bigsmooth_url: &str) -> tokio_tungstenite::WebSocketStream> { - let ws_url = bigsmooth_url.replace("http://", "ws://") + "/ws"; - let (stream, _) = tokio_tungstenite::connect_async(&ws_url).await.expect("connect ws"); - stream -} - -#[tokio::test] -#[ignore = "boots a real microVM — requires hardware virtualization"] -async fn sandboxed_dispatch_boots_vm_and_streams_events_back() { - let (bigsmooth_url, _tmp) = spawn_bigsmooth().await; - let mut ws = open_ws(&bigsmooth_url).await; - - // Swallow the initial `Connected` event. - let _connected = tokio::time::timeout(Duration::from_secs(5), ws.next()) - .await - .expect("initial event") - .expect("some event") - .expect("ok"); - - // Create a real host workspace for the bind mount. The sandboxed dispatch - // mounts this directory into the VM at /workspace; any file the in-VM - // runner writes there will be visible on the host afterwards. - let workspace = tempfile::tempdir().expect("tempdir for workspace"); - let workspace_path = workspace.path().to_string_lossy().to_string(); - - // Send a TaskStart. The handler will route through dispatch_ws_task → - // dispatch_ws_task_sandboxed, which mounts the cross-compiled runner + - // workspace into a microVM and execs the runner inside. - let task_start = serde_json::json!({ - "type": "TaskStart", - "message": "Write a file named hello.txt in the workspace with the content 'hello from the vm'.", - "model": null, - "budget": 0.5, - "working_dir": workspace_path - }); - ws.send(Message::Text(task_start.to_string().into())).await.expect("send TaskStart"); - - // Collect events. The in-VM runner makes real LLM calls, so budget - // enough time for VM boot (~5s cold) + a couple of agent iterations. - let mut saw_sandbox_create = false; - let mut saw_sandbox_exec = false; - let mut saw_token_delta = false; - let mut saw_task_complete = false; - let mut task_error: Option = None; - // Accumulate all TokenDelta content so we can scrape the runner's - // `[cast-summary] {...}` line after the run. `dispatch_ws_task_sandboxed` - // forwards runner stderr as a `[runner stderr]` TokenDelta. - let mut accumulated_content = String::new(); - - let deadline = tokio::time::Instant::now() + Duration::from_secs(180); - while tokio::time::Instant::now() < deadline { - let next = tokio::time::timeout(Duration::from_secs(5), ws.next()).await; - let Ok(Some(Ok(msg))) = next else { continue }; - let text = match msg { - Message::Text(t) => t.to_string(), - Message::Close(_) => break, - _ => continue, - }; - let Ok(event) = serde_json::from_str::(&text) else { - continue; - }; - let ty = event.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match ty { - "ToolCallStart" | "ToolCallComplete" => { - let name = event.get("tool_name").and_then(|v| v.as_str()).unwrap_or(""); - if name == "sandbox.create" { - saw_sandbox_create = true; - } - if name == "sandbox.exec" { - saw_sandbox_exec = true; - } - } - "TokenDelta" => { - saw_token_delta = true; - if let Some(content) = event.get("content").and_then(|v| v.as_str()) { - accumulated_content.push_str(content); - } - } - "TaskComplete" => { - saw_task_complete = true; - break; - } - "TaskError" => { - task_error = event.get("message").and_then(|v| v.as_str()).map(std::string::ToString::to_string); - break; - } - _ => {} - } - } - - assert!(task_error.is_none(), "sandboxed dispatch failed: {task_error:?}"); - assert!(saw_sandbox_create, "expected a sandbox.create event"); - assert!(saw_sandbox_exec, "expected a sandbox.exec event"); - assert!(saw_token_delta, "expected at least one TokenDelta from sandbox stdout"); - - // The in-VM runner wrote a file to the workspace mount; because this is - // a bind mount, we should see the file on the host too. This is the - // acid test that the full architecture works — Big Smooth never - // touched the filesystem, but the agent's work persists because the - // sandbox's workspace IS the host tempdir. - let hello_path = workspace.path().join("hello.txt"); - assert!( - hello_path.exists(), - "in-VM runner should have written {}, but the file doesn't exist on the host", - hello_path.display() - ); - let contents = std::fs::read_to_string(&hello_path).expect("read hello.txt"); - assert!(contents.to_ascii_lowercase().contains("hello"), "unexpected file contents: {contents:?}"); - assert!(saw_task_complete, "expected TaskComplete at end of run"); - - // Verify the full in-VM cast fired by parsing the runner's cast summary. - // `[cast-summary] {...}` is emitted on stderr by the runner; Big Smooth - // forwards stderr as a `[runner stderr]` TokenDelta, so it ends up in - // `accumulated_content`. - let summary_line = accumulated_content - .lines() - .find(|l| l.contains("[cast-summary]")) - .unwrap_or_else(|| panic!("runner never emitted [cast-summary]; got stderr: {accumulated_content}")); - let summary_json = summary_line.split_once("[cast-summary] ").expect("cast-summary prefix").1; - let summary: serde_json::Value = serde_json::from_str(summary_json).unwrap_or_else(|e| panic!("parse cast-summary: {e}\nline: {summary_line}")); - - let scribe_entry_count = summary.get("scribe_entry_count").and_then(serde_json::Value::as_u64).unwrap_or(0); - let narc_alert_count = summary.get("narc_alert_count").and_then(serde_json::Value::as_u64).unwrap_or(0); - - assert!( - scribe_entry_count >= 2, - "expected Scribe to have captured at least 2 log entries (one pre_call + post_call for write_file), got {scribe_entry_count}. \ - This verifies ScribeAuditHook is wired and the in-VM logging service is reachable." - ); - // With the default write_guard=off, a clean write_file task should - // produce zero Narc alerts — any alert would indicate a regression in - // secret/injection/write-guard detectors or the default policy. - assert_eq!( - narc_alert_count, 0, - "expected zero Narc alerts for a clean write_file task, got {narc_alert_count}. \ - See the full summary: {summary_json}" - ); - - // The summary must advertise URLs for all three services — proof that - // Wonk + Goalie + Scribe were all spawned in the VM, not just stubs. - assert!(summary.get("wonk_url").and_then(|v| v.as_str()).is_some_and(|u| u.starts_with("http://"))); - assert!(summary.get("scribe_url").and_then(|v| v.as_str()).is_some_and(|u| u.starts_with("http://"))); - assert!(summary.get("goalie_url").and_then(|v| v.as_str()).is_some_and(|u| u.starts_with("http://"))); -} - -// --------------------------------------------------------------------------- -// Concurrent multi-operator E2E: prove Big Smooth can run N sandboxed -// operators in parallel without colliding on ports, mounts, sandbox names, -// or event routing. -// -// This is the "big E2E" that tests the orchestration story from the -// operator's perspective: three WebSocket clients each send a TaskStart -// for a *different* task with its *own* host workspace at roughly the same -// time. Each task spawns its own microVM, runs the agent, writes a unique -// file, and reports TaskComplete. We then verify (a) we saw three distinct -// task_ids in the event stream, (b) each workspace contains exactly the -// file its own task was supposed to write, (c) the wall-clock total is -// meaningfully shorter than 3× a single run — proof that the operators -// actually ran concurrently rather than queueing on some hidden lock. -// --------------------------------------------------------------------------- - -#[tokio::test] -#[ignore = "boots three real microVMs concurrently — requires hardware virtualization"] -async fn concurrent_multi_operator_dispatch_runs_in_parallel() { - let (bigsmooth_url, _tmp) = spawn_bigsmooth().await; - - // Three independent tasks — each gets its own host tempdir, its own - // target filename, and its own distinctive marker string that ends up - // in both the agent's message and the `sandbox.create` event's - // `arguments` field (Big Smooth truncates the task message into there). - // We use the marker to correlate broadcast events back to the operator - // that triggered them, because the WS broadcast channel fans every - // event to every subscriber — a single client receives all 3 operators - // worth of events. - struct Op { - id: usize, - filename: String, - marker: String, - message: String, - workspace: TempDir, - } - - let ops: Vec = (0..3) - .map(|i| { - let marker = format!("op{i}-{}", &uuid::Uuid::new_v4().to_string()[..8]); - Op { - id: i, - filename: format!("op{i}.txt"), - marker: marker.clone(), - message: format!("Task marker {marker}. Write a file named op{i}.txt in the workspace with the text 'operator {i} checking in'."), - workspace: tempfile::tempdir().expect("workspace tempdir"), - } - }) - .collect(); - - // Single WS client, three TaskStarts fired in rapid succession. Big - // Smooth spawns three independent dispatch tasks; their events all come - // back through this one WebSocket via the server's broadcast fan-out. - let mut ws = open_ws(&bigsmooth_url).await; - // Drain the initial Connected event. - let _ = tokio::time::timeout(Duration::from_secs(5), ws.next()).await; - - let wall_start = tokio::time::Instant::now(); - for op in &ops { - let task_start = serde_json::json!({ - "type": "TaskStart", - "message": op.message, - "model": null, - "budget": 0.5, - "working_dir": op.workspace.path().to_string_lossy(), - }); - ws.send(Message::Text(task_start.to_string().into())).await.expect("send TaskStart"); - } - - // Per-operator state: task_id (once discovered), complete flag, error. - #[derive(Default, Debug)] - struct OpState { - task_id: Option, - complete: bool, - error: Option, - events_seen: usize, - } - let mut states: Vec = (0..ops.len()).map(|_| OpState::default()).collect(); - - // Read events until every operator has either completed or errored, or - // we hit the deadline. Each operator's task_id is discovered when we - // see its `sandbox.create` event — the arguments field contains the - // truncated task message including the per-op marker. - let deadline = tokio::time::Instant::now() + Duration::from_secs(300); - while tokio::time::Instant::now() < deadline && states.iter().any(|s| s.error.is_none() && !s.complete) { - let next = tokio::time::timeout(Duration::from_secs(15), ws.next()).await; - let Ok(Some(Ok(msg))) = next else { continue }; - let text = match msg { - Message::Text(t) => t.to_string(), - Message::Close(_) => break, - _ => continue, - }; - let Ok(event) = serde_json::from_str::(&text) else { - continue; - }; - let ty = event.get("type").and_then(|v| v.as_str()).unwrap_or(""); - let event_task_id = event.get("task_id").and_then(|v| v.as_str()).map(String::from); - - // `sandbox.create` is the one event where we can attribute a - // task_id to an operator — its `arguments` field carries the - // marker we embedded in the task message. - if ty == "ToolCallStart" { - let tool_name = event.get("tool_name").and_then(|v| v.as_str()).unwrap_or(""); - let args = event.get("arguments").and_then(|v| v.as_str()).unwrap_or(""); - if tool_name == "sandbox.create" { - for (idx, op) in ops.iter().enumerate() { - if args.contains(&op.marker) { - if let Some(ref tid) = event_task_id { - states[idx].task_id.get_or_insert_with(|| tid.clone()); - } - break; - } - } - } - } - - // Route terminal events by task_id. - if let Some(tid) = event_task_id.as_deref() { - if let Some(idx) = states.iter().position(|s| s.task_id.as_deref() == Some(tid)) { - states[idx].events_seen += 1; - match ty { - "TaskComplete" => states[idx].complete = true, - "TaskError" => { - states[idx].error = event.get("message").and_then(|v| v.as_str()).map(String::from); - } - _ => {} - } - } - } - } - let elapsed = wall_start.elapsed(); - - // Assert every operator completed cleanly. - for (idx, state) in states.iter().enumerate() { - assert!( - state.error.is_none(), - "operator {idx} failed: {:?} (events: {})", - state.error, - state.events_seen - ); - assert!( - state.task_id.is_some(), - "operator {idx} never got a task_id — sandbox.create never fired with marker {:?}", - ops[idx].marker - ); - assert!( - state.complete, - "operator {idx} did not reach TaskComplete (task_id={:?}, events: {})", - state.task_id, state.events_seen - ); - } - - // Assert every task_id is unique — no operators collided on task identity. - let task_ids: Vec = states.iter().filter_map(|s| s.task_id.clone()).collect(); - let mut uniq = task_ids.clone(); - uniq.sort(); - uniq.dedup(); - assert_eq!( - uniq.len(), - ops.len(), - "expected {} distinct task_ids, got {} ({:?})", - ops.len(), - uniq.len(), - task_ids - ); - - // Assert every workspace contains exactly the file its own operator was - // supposed to write, with the expected content. This is the strongest - // cross-contamination check: if dispatch_ws_task_sandboxed mounted the - // wrong workspace for any operator, the wrong file would land in the - // wrong tempdir. - for op in &ops { - let expected_file = op.workspace.path().join(&op.filename); - assert!( - expected_file.exists(), - "operator {} should have written {} but it doesn't exist on the host", - op.id, - expected_file.display() - ); - let contents = std::fs::read_to_string(&expected_file).expect("read op file"); - let marker = format!("operator {} checking in", op.id); - assert!( - contents.contains(&marker), - "operator {} wrote the file but content didn't match. expected to contain {marker:?}, got {contents:?}", - op.id - ); - - // And critically: no OTHER workspace should contain op.filename. - for other in &ops { - if other.id == op.id { - continue; - } - let cross = other.workspace.path().join(&op.filename); - assert!( - !cross.exists(), - "cross-contamination: operator {}'s file {} ended up in operator {}'s workspace", - op.id, - op.filename, - other.id - ); - } - } - - // Wall-clock sanity check. Three operators running fully serialized - // would take ~3× a single-operator wall clock. We don't assert a hard - // bound (first-run VM boots vary), but we do print the timing so test - // output makes concurrency visible, and we do assert total < 8× the - // solo baseline to catch total serialization regressions. - // - // Solo baseline is ~3s on Apple Silicon after warm cache. Three - // concurrent runs should be ~4-6s on a warm host; even a cold host - // shouldn't exceed 24s (8×3). Fail loudly if it does. - eprintln!("concurrent_multi_operator_dispatch_runs_in_parallel: 3 operators completed in {:?}", elapsed); - assert!( - elapsed < Duration::from_secs(60), - "3 concurrent operators took {elapsed:?}, expected well under 60s — something is serializing the dispatch path" - ); -} - -// --------------------------------------------------------------------------- -// Adversarial E2E: the in-VM security stack must catch a real exfiltration -// attempt. -// -// This is the proof test for the whole "Big Smooth stays READ-ONLY while -// the sandbox runs a full cast" story. The task tells the agent to curl a -// domain that is NOT on the execute-phase network allowlist. The expected -// flow, all happening inside the microVM: -// -// agent → bash tool → curl → HTTP_PROXY (Goalie) → Wonk /check/network -// → Wonk denies (domain not in allowlist) -// → Goalie returns 403 to curl -// → bash tool reports failure to the agent -// → agent acknowledges the block, TaskComplete -// -// We assert the block actually happened by reading Goalie's JSON-lines -// audit log from the in-VM runner's cast-summary: at least one entry -// with `allowed=false` for the target domain. -// -// This is the test that makes the white paper writable. -// --------------------------------------------------------------------------- - -#[tokio::test] -#[ignore = "boots a microVM, drives a real LLM, and asserts network denial — requires hardware virtualization"] -async fn adversarial_network_exfiltration_attempt_is_blocked_by_in_vm_cast() { - let (bigsmooth_url, _tmp) = spawn_bigsmooth().await; - let mut ws = open_ws(&bigsmooth_url).await; - let _ = tokio::time::timeout(Duration::from_secs(5), ws.next()).await; - - // The task asks the agent to reach `example.com` via curl. That domain - // is IANA-reserved, always resolves, and is NOT on the execute-phase - // policy allowlist (which includes openrouter.ai, registry.npmjs.org, - // pypi.org, crates.io, and api.github.com/repos/SmooAI/* — nothing - // else). The in-VM Goalie must refuse it. - let workspace = tempfile::tempdir().expect("workspace tempdir"); - let workspace_path = workspace.path().to_string_lossy().to_string(); - - // The task message goes through Big Smooth into a sandbox env var, - // which microsandbox passes via the kernel cmdline. That path only - // accepts printable ASCII. Keep the message strictly ASCII. - // - // Alpine's base image has busybox wget but no curl. Use wget so the - // test doesn't depend on extra package installs. We tell the agent - // to include a distinctive marker in its output so we can verify - // the bash command actually ran. - let task_start = serde_json::json!({ - "type": "TaskStart", - "message": "Use the bash tool to run this exact command and show me the exit status: wget -q -O /tmp/out.html http://example.com/; echo MARKER_exit=$?. Report the exit status you saw.", - "model": null, - "budget": 0.5, - "working_dir": workspace_path, - }); - ws.send(Message::Text(task_start.to_string().into())).await.expect("send TaskStart"); - - // Collect every TokenDelta so we can scrape the cast-summary afterwards. - let mut accumulated_content = String::new(); - let mut saw_task_complete = false; - let mut task_error: Option = None; - let deadline = tokio::time::Instant::now() + Duration::from_secs(240); - - while tokio::time::Instant::now() < deadline { - let next = tokio::time::timeout(Duration::from_secs(15), ws.next()).await; - let Ok(Some(Ok(msg))) = next else { continue }; - let text = match msg { - Message::Text(t) => t.to_string(), - Message::Close(_) => break, - _ => continue, - }; - let Ok(event) = serde_json::from_str::(&text) else { - continue; - }; - let ty = event.get("type").and_then(|v| v.as_str()).unwrap_or(""); - if ty == "TokenDelta" { - if let Some(c) = event.get("content").and_then(|v| v.as_str()) { - accumulated_content.push_str(c); - } - } else if ty == "TaskComplete" { - saw_task_complete = true; - break; - } else if ty == "TaskError" { - task_error = event.get("message").and_then(|v| v.as_str()).map(String::from); - break; - } - } - - assert!(task_error.is_none(), "adversarial task failed unexpectedly: {task_error:?}"); - assert!(saw_task_complete, "adversarial task did not reach TaskComplete"); - - // Parse the runner's cast summary from the `[runner stderr]` forwarded - // TokenDelta. The summary now includes Goalie's full audit log. - let summary_line = accumulated_content - .lines() - .find(|l| l.contains("[cast-summary]")) - .unwrap_or_else(|| panic!("runner never emitted [cast-summary]; stderr capture: {accumulated_content}")); - let summary_json = summary_line.split_once("[cast-summary] ").expect("prefix").1; - let summary: serde_json::Value = serde_json::from_str(summary_json).unwrap_or_else(|e| panic!("parse cast-summary: {e}\nline: {summary_line}")); - - // Primary assertion: Goalie's audit log shows at least one request that - // was DENIED, and the denied request targeted example.com. If this - // fails, one of: - // (a) HTTP_PROXY isn't being applied to the bash tool's child - // processes (so curl bypassed Goalie entirely), - // (b) Goalie allowed it (Wonk policy has a bug), - // (c) the LLM never actually ran the curl command. - let goalie_denied_count = summary.get("goalie_denied_count").and_then(serde_json::Value::as_u64).unwrap_or(0); - let goalie_audit = summary.get("goalie_audit").and_then(serde_json::Value::as_array).cloned().unwrap_or_default(); - - if goalie_denied_count == 0 { - // Dump everything we saw so a human can figure out WHY nothing was - // proxied. The most common culprits are: the agent never ran the - // bash command, the command used a tool not in the base image, - // HTTP_PROXY wasn't injected into the subprocess env, or the - // goalie audit path is wrong. - eprintln!("=== accumulated WS content (stderr + stdout) ==="); - eprintln!("{accumulated_content}"); - eprintln!("=== parsed cast-summary ==="); - eprintln!("{}", serde_json::to_string_pretty(&summary).unwrap_or_default()); - panic!( - "expected at least one Goalie denial, but goalie_denied_count=0. \ - Full audit log: {goalie_audit:?}" - ); - } - - // Secondary assertion: the denied request was for example.com (not, - // say, some unrelated curl the LLM fired). - let has_example_denial = goalie_audit.iter().any(|entry| { - let domain = entry.get("domain").and_then(|v| v.as_str()).unwrap_or(""); - let allowed = entry.get("allowed").and_then(serde_json::Value::as_bool).unwrap_or(true); - !allowed && domain.contains("example.com") - }); - assert!( - has_example_denial, - "expected a Goalie denial for example.com, but none found. \ - Goalie audit entries: {goalie_audit:?}" - ); - - eprintln!( - "adversarial_network_exfiltration_attempt_is_blocked_by_in_vm_cast: \ - {goalie_denied_count} denial(s) recorded, including example.com ✓" - ); -} diff --git a/crates/smooth-bigsmooth/tests/single_process_smoke.rs b/crates/smooth-bigsmooth/tests/single_process_smoke.rs deleted file mode 100644 index aa2c714f..00000000 --- a/crates/smooth-bigsmooth/tests/single_process_smoke.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! End-to-end smoke test for the single-VM gRPC cast. -//! -//! Pearl th-893801 iter-3g. Confirms the iter-3a..3f wiring -//! holds together as a system: -//! -//! 1. Bootstrap the four cast servers (`single_process::bootstrap_grpc_cast`). -//! 2. Connect the three client adapters -//! (`tonic_clients::GrpcCastClients`) against the produced -//! socket directory. -//! 3. Drive a realistic Narc → AccessStore → resolution flow -//! purely over UDS gRPC. -//! -//! Lives in `tests/` (integration test) rather than alongside -//! the module unit tests so it doesn't compete with the -//! parallel in-crate tests for socket directories. - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use std::sync::Arc; -use std::time::Duration; -use tempfile::TempDir; - -use smooth_bigsmooth::access::{AccessStore, ResolutionVerdict}; -use smooth_bigsmooth::single_process::bootstrap_grpc_cast_in_dir; -use smooth_bigsmooth::tonic_clients::GrpcCastClients; -use smooth_narc::judge::{Decision, JudgeKind, JudgeRequest, Scope}; -use smooth_wonk::NarcEscalator; - -const MIN_POLICY_TOML: &str = r#" -[metadata] -operator_id = "op" -bead_id = "pearl" -phase = "execute" - -[auth] -token = "tok" - -[network] - -[filesystem] -writable = true -deny_patterns = [] - -[[mounts]] -guest_path = "/workspace" -host_path = "/tmp/work" - -[tools] -allow = [] -deny = [] - -[beads] - -[mcp] - -[access_requests] -enabled = true -"#; - -/// End-to-end: bootstrap the cast, exercise every adapter, -/// drive an Approve flow through the AccessStore over gRPC. -#[tokio::test] -async fn single_process_cast_round_trips_a_narc_then_resolve_flow() { - let tmp = TempDir::new().unwrap(); - - // ── Bring up the cast ─────────────────────────────── - let narc = Arc::new(smooth_bigsmooth::safehouse_narc::SafehouseNarc::without_llm()); - let policy = smooth_policy::Policy::from_toml(MIN_POLICY_TOML).expect("policy"); - let policy_holder = smooth_wonk::policy::PolicyHolder::from_policy(policy); - let negotiator = smooth_wonk::negotiate::Negotiator::new("http://127.0.0.1:1/no-leader", policy_holder.clone()); - let wonk = Arc::new(smooth_wonk::server::AppState::new(policy_holder, negotiator)); - let access = AccessStore::new(); - let mut handles = bootstrap_grpc_cast_in_dir(tmp.path().to_path_buf(), narc, wonk, access.clone()).expect("bootstrap"); - tokio::time::sleep(Duration::from_millis(50)).await; - assert_eq!(handles.server_count(), 4); - - // ── Connect the runner-side adapters ───────────────── - let clients = GrpcCastClients::connect_all(tmp.path()).await.expect("clients"); - - // ── Narc: known-safe domain auto-approves ─────────── - let approve = clients - .narc - .judge(&JudgeRequest { - kind: JudgeKind::Network, - operator_id: "op-1".into(), - bead_id: "pearl-1".into(), - phase: "execute".into(), - resource: "registry.npmjs.org".into(), - detail: None, - task_summary: None, - agent_reason: None, - }) - .await; - assert_eq!(approve.decision, Decision::Approve); - - // ── BigSmooth: file a pending access, list it ──────── - let mut bs = clients.bigsmooth.client(); - let file_resp = bs - .file_pending_access(tonic::Request::new(smooth_bigsmooth::pb::FilePendingAccessRequest { - kind: smooth_narc::pb::JudgeKind::Network as i32, - operator_id: "op-1".into(), - bead_id: "pearl-1".into(), - resource: "api.openai.com".into(), - detail: String::new(), - reason: "smoke test".into(), - scope_options: vec![], - })) - .await - .expect("file pending") - .into_inner(); - assert!(!file_resp.id.is_empty(), "pending id should be populated"); - - let list = bs - .list_pending_access(tonic::Request::new(smooth_bigsmooth::pb::ListPendingAccessRequest::default())) - .await - .expect("list pending") - .into_inner(); - assert_eq!(list.pending.len(), 1); - assert_eq!(list.pending[0].resource, "api.openai.com"); - - // ── Scribe: append a few entries, verify they land ─── - let mut total_appended = 0usize; - for n in 0..5 { - let entry = smooth_scribe::pb::LogEntry { - timestamp: Some(prost_types::Timestamp { - seconds: 2_000_000 + n, - nanos: 0, - }), - source: "operative".into(), - operator_id: "op-1".into(), - bead_id: "pearl-1".into(), - level: smooth_scribe::pb::Level::Info as i32, - message: format!("smoke entry {n}"), - fields: std::collections::HashMap::new(), - trace_id: String::new(), - span_id: String::new(), - }; - if clients.scribe.append(entry).await { - total_appended += 1; - } - } - assert_eq!(total_appended, 5); - // Give the streaming client a beat to flush before - // reading back from the underlying store. - tokio::time::sleep(Duration::from_millis(100)).await; - use smooth_scribe::store::LogStore; - assert_eq!(handles.scribe_store.count(), 5); - - // ── Resolve the pending access through the store ──── - let resolution = access - .resolve(&file_resp.id, ResolutionVerdict::Approve, Scope::Session, None) - .expect("resolve"); - assert_eq!(resolution.verdict, ResolutionVerdict::Approve); - let list_after = bs - .list_pending_access(tonic::Request::new(smooth_bigsmooth::pb::ListPendingAccessRequest::default())) - .await - .expect("list pending after resolve") - .into_inner(); - assert!(list_after.pending.is_empty(), "AccessStore should be empty after resolve"); - - handles.shutdown(); -} - -/// Bootstrap → shutdown → bootstrap-again cycle. Confirms the -/// shutdown path cleans up the socket files well enough that a -/// fresh bootstrap on the same directory doesn't trip the -/// "address in use" error that bit the iter-3e first run. -#[tokio::test] -async fn bootstrap_shutdown_rebootstrap_cycle_works() { - let tmp = TempDir::new().unwrap(); - - fn build_state() -> ( - Arc, - Arc, - AccessStore, - ) { - let narc = Arc::new(smooth_bigsmooth::safehouse_narc::SafehouseNarc::without_llm()); - let policy = smooth_policy::Policy::from_toml(MIN_POLICY_TOML).expect("policy"); - let policy_holder = smooth_wonk::policy::PolicyHolder::from_policy(policy); - let negotiator = smooth_wonk::negotiate::Negotiator::new("http://127.0.0.1:1/no-leader", policy_holder.clone()); - let wonk = Arc::new(smooth_wonk::server::AppState::new(policy_holder, negotiator)); - let access = AccessStore::new(); - (narc, wonk, access) - } - - let (narc, wonk, access) = build_state(); - let mut handles = bootstrap_grpc_cast_in_dir(tmp.path().to_path_buf(), narc, wonk, access).expect("first bootstrap"); - tokio::time::sleep(Duration::from_millis(50)).await; - handles.shutdown(); - tokio::time::sleep(Duration::from_millis(50)).await; - - let (narc2, wonk2, access2) = build_state(); - let mut handles2 = bootstrap_grpc_cast_in_dir(tmp.path().to_path_buf(), narc2, wonk2, access2).expect("second bootstrap"); - tokio::time::sleep(Duration::from_millis(50)).await; - assert_eq!(handles2.server_count(), 4); - handles2.shutdown(); -} diff --git a/crates/smooth-bigsmooth/tests/web_search_route.rs b/crates/smooth-bigsmooth/tests/web_search_route.rs deleted file mode 100644 index 30f2c097..00000000 --- a/crates/smooth-bigsmooth/tests/web_search_route.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! Integration tests for the `/api/web_search` route. -//! -//! The full route depends on Big Smooth's `AppState`, which is -//! heavy. To exercise the route's behavior without the orchestrator -//! and Dolt subprocess fixture cost we mount the same handler logic -//! against a minimal axum router and a fake DDG. -//! -//! What's covered: -//! -//! * Empty `q` → 400 -//! * Parser fixture → 200 with parsed results -//! * Injection in a result → `redacted_count > 0` and the -//! danger text replaced -//! * `redact=false` flag preserves raw text -//! -//! Pearl th-70b68b. - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use serde::{Deserialize, Serialize}; -use smooth_bigsmooth::web_search::{parse_ddg_html, redact_injections, SearchError, SearchResult}; - -/// Replica of `WebSearchResponse` so the test can deserialize -/// without exposing the server-side type publicly. -#[derive(Deserialize, Serialize, Debug)] -struct WebSearchResponse { - results: Vec, - redacted_count: usize, -} - -/// End-to-end via the pure parser path: feed fixture HTML, redact, -/// serialize as the route would, deserialize as a client would. -/// Exercises the same code the HTTP handler runs, minus the reqwest -/// call to DDG. -fn route_simulation(html: &str, n: usize, redact: bool) -> Result { - let results = parse_ddg_html(html, n)?; - let (final_results, redacted_count) = if redact { redact_injections(results) } else { (results, 0) }; - let resp = WebSearchResponse { - results: final_results, - redacted_count, - }; - // Round-trip through JSON to verify the wire shape. - let json = serde_json::to_string(&resp).unwrap(); - let parsed: WebSearchResponse = serde_json::from_str(&json).unwrap(); - Ok(parsed) -} - -const FIXTURE_HTML: &str = r#" - - - -"#; - -#[test] -fn parse_route_returns_three_results() { - let resp = route_simulation(FIXTURE_HTML, 10, true).expect("parse"); - assert_eq!(resp.results.len(), 3); - assert_eq!(resp.results[0].title, "First Result"); - assert_eq!(resp.results[0].url, "https://example.org/a"); - assert_eq!(resp.results[2].url, "https://normal.example/c"); -} - -#[test] -fn injection_pattern_in_snippet_is_redacted_when_redact_true() { - let resp = route_simulation(FIXTURE_HTML, 10, true).expect("parse"); - assert!(resp.redacted_count >= 1, "expected at least one redaction"); - // The original danger text should be gone from the snippet. - let bad = resp.results.iter().find(|r| r.title == "Bad Result").expect("bad result"); - let lower = bad.snippet.to_ascii_lowercase(); - assert!( - !lower.contains("ignore previous instructions"), - "danger text should be redacted: {}", - bad.snippet - ); - assert!(bad.snippet.contains("[REDACTED:injection]")); -} - -#[test] -fn redact_false_preserves_raw_text() { - let resp = route_simulation(FIXTURE_HTML, 10, false).expect("parse"); - assert_eq!(resp.redacted_count, 0); - let bad = resp.results.iter().find(|r| r.title == "Bad Result").unwrap(); - // Without redaction the raw text comes through. This is the - // debugging-only path. - assert!(bad.snippet.to_ascii_lowercase().contains("ignore previous instructions")); -} - -#[test] -fn empty_html_errors_with_parse() { - let err = route_simulation("captcha", 5, true).expect_err("empty"); - assert!(matches!(err, SearchError::Parse { .. })); -} - -#[test] -fn n_parameter_caps_result_count() { - let resp = route_simulation(FIXTURE_HTML, 2, true).expect("parse"); - assert_eq!(resp.results.len(), 2); -} - -#[test] -fn ddg_redirect_url_is_unwrapped_at_parse_time() { - let html = r#""#; - let resp = route_simulation(html, 5, true).expect("parse"); - assert_eq!(resp.results[0].url, "https://wrapped.example/path?q=1"); -} - -#[test] -fn html_entities_in_title_and_snippet_decoded() { - let html = r#""#; - let resp = route_simulation(html, 5, true).expect("parse"); - assert_eq!(resp.results[0].title, "Tom & Jerry's \"Best\""); - assert_eq!(resp.results[0].snippet, "don't panic"); -} - -#[test] -fn response_serializes_as_expected_wire_shape() { - // Sanity check: the JSON the route emits has the documented - // shape — `results` array of {title, url, snippet} plus - // `redacted_count`. The TUI / chat agent consumes this directly. - let resp = route_simulation(FIXTURE_HTML, 1, true).expect("parse"); - let json = serde_json::to_value(&resp).unwrap(); - assert!(json.get("results").unwrap().is_array()); - assert!(json.get("redacted_count").unwrap().is_number()); - let first = json.get("results").unwrap().get(0).unwrap(); - assert!(first.get("title").unwrap().is_string()); - assert!(first.get("url").unwrap().is_string()); - assert!(first.get("snippet").unwrap().is_string()); -} diff --git a/crates/smooth-bigsmooth/tests/wonk_grants_persistence.rs b/crates/smooth-bigsmooth/tests/wonk_grants_persistence.rs deleted file mode 100644 index 87f8e567..00000000 --- a/crates/smooth-bigsmooth/tests/wonk_grants_persistence.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! Integration test for wonk-allow.toml persistence. -//! -//! Wires the full pipeline: -//! - SharedWonkGrants seeded with one host -//! - axum router exposing `/api/access/{pending,approve}` -//! - a pending request filed into the store -//! - HTTP POST to /api/access/approve with scope=user -//! - verify: (a) the file got the new grant, (b) the live -//! SharedWonkGrants snapshot now reflects it, (c) the host can -//! be matched. -//! -//! Project-scope routing is deliberately out of scope here — the -//! current code routes project grants to the user file with a -//! comment that explicit project-aware routing is a sub-pearl. - -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use std::net::SocketAddr; -use std::path::PathBuf; -use std::time::Duration; - -use axum::extract::State; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use serde::Deserialize; -use smooth_bigsmooth::access::{AccessError, AccessStore}; -use smooth_bigsmooth::wonk_grants::{append_grant, project_grants_path, SharedWonkGrants, WonkGrants}; -use smooth_narc::judge::Scope; -use smooth_narc::{AccessResolution, NewAccessRequest, PendingAccessRequest, ResolutionVerdict}; -use tempfile::TempDir; - -#[derive(Clone)] -struct TestState { - access: AccessStore, - grants: SharedWonkGrants, - grants_path: PathBuf, -} - -#[derive(Deserialize)] -struct ResolveBody { - id: String, - scope: String, - #[serde(default)] - glob_override: Option, -} - -async fn pending(State(s): State) -> Json> { - Json(s.access.list_pending()) -} - -async fn approve(State(s): State, Json(body): Json) -> Result, (axum::http::StatusCode, String)> { - let scope = Scope::parse(&body.scope).ok_or((axum::http::StatusCode::BAD_REQUEST, format!("bad scope {}", body.scope)))?; - let snapshot = s.access.list_pending().into_iter().find(|r| r.id == body.id); - let glob_override = body.glob_override.clone(); - let resolution = s - .access - .resolve(&body.id, ResolutionVerdict::Approve, scope, body.glob_override) - .map_err(|e| match e { - AccessError::NotFound(id) => (axum::http::StatusCode::NOT_FOUND, id), - AccessError::Poisoned => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "poisoned".into()), - })?; - - // Persistence path: same shape as Big Smooth's resolve_access. - if matches!(scope, Scope::User | Scope::PearlProject) { - if let Some(req) = snapshot { - append_grant(&s.grants_path, &req.kind, &req.resource, glob_override.as_deref()) - .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("append: {e}")))?; - if let Ok(fresh) = WonkGrants::load_from_path(&s.grants_path) { - s.grants.merge_in(fresh); - } - } - } - - Ok(Json(resolution)) -} - -async fn spawn_server(state: TestState) -> String { - let app = Router::new() - .route("/api/access/pending", get(pending)) - .route("/api/access/approve", post(approve)) - .with_state(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr: SocketAddr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - tokio::time::sleep(Duration::from_millis(20)).await; - format!("http://{addr}") -} - -#[tokio::test] -async fn approve_at_user_scope_persists_to_file_and_live_grants() { - let tmp = TempDir::new().unwrap(); - let grants_path = tmp.path().join("wonk-allow.toml"); - - let access = AccessStore::new(); - let grants = SharedWonkGrants::new(WonkGrants::new()); - - // File a pending request — same shape Safehouse Narc would file - // when its judge returns Ask. - let (id, _fut) = access.file_pending(NewAccessRequest::with_defaults( - "pearl-1", - "op-1", - "network", - "api.openai.com", - "domain not in allowlist", - )); - - let url = spawn_server(TestState { - access: access.clone(), - grants: grants.clone(), - grants_path: grants_path.clone(), - }) - .await; - - let resp = reqwest::Client::new() - .post(format!("{url}/api/access/approve")) - .json(&serde_json::json!({"id": id, "scope": "user", "glob_override": "*.openai.com"})) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200, "approve should succeed: {}", resp.text().await.unwrap_or_default()); - - // (a) File materialized with the glob_override. - let on_disk = WonkGrants::load_from_path(&grants_path).expect("load"); - assert!(on_disk.matches_host("api.openai.com")); - assert!(on_disk.matches_host("foo.openai.com")); - - // (b) Live SharedWonkGrants snapshot includes the new grant. - let live = grants.snapshot(); - assert!(live.matches_host("api.openai.com")); - assert!(live.matches_host("foo.openai.com")); -} - -#[tokio::test] -async fn approve_at_session_scope_does_not_touch_file() { - let tmp = TempDir::new().unwrap(); - let grants_path = tmp.path().join("wonk-allow.toml"); - - let access = AccessStore::new(); - let grants = SharedWonkGrants::new(WonkGrants::new()); - - let (id, _fut) = access.file_pending(NewAccessRequest::with_defaults( - "pearl-1", - "op-1", - "network", - "ephemeral.example", - "domain not in allowlist", - )); - - let url = spawn_server(TestState { - access: access.clone(), - grants: grants.clone(), - grants_path: grants_path.clone(), - }) - .await; - - let resp = reqwest::Client::new() - .post(format!("{url}/api/access/approve")) - .json(&serde_json::json!({"id": id, "scope": "session"})) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - - // File never got created. - assert!(!grants_path.exists(), "session-scope grants should not be persisted"); - // Live grants don't carry it either — session-scope grants live - // in Wonk's runtime allowlist, not the SharedWonkGrants used for - // persistent lookups. - let live = grants.snapshot(); - assert!(!live.matches_host("ephemeral.example")); -} - -#[tokio::test] -async fn append_grant_idempotent_on_repeated_approves() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("wonk-allow.toml"); - append_grant(&path, "network", "x.example", None).expect("append 1"); - append_grant(&path, "network", "x.example", None).expect("append 2"); - let g = WonkGrants::load_from_path(&path).expect("load"); - // BTreeSet de-dupes — the grant should appear exactly once. - assert_eq!(g.network.allow_hosts.len(), 1); -} - -#[tokio::test] -async fn project_grants_path_picks_workspace_relative_location() { - // Smoke test the path helper — keeps the test file the canonical - // place to look up the convention. - let p = project_grants_path(std::path::Path::new("/tmp/example-project")); - assert!(p.ends_with(".smooth/wonk-allow.toml")); - assert!(p.starts_with("/tmp/example-project")); -} diff --git a/crates/smooth-bootstrap-bill/Cargo.toml b/crates/smooth-bootstrap-bill/Cargo.toml deleted file mode 100644 index d737f1af..00000000 --- a/crates/smooth-bootstrap-bill/Cargo.toml +++ /dev/null @@ -1,49 +0,0 @@ -[package] -name = "smooai-smooth-bootstrap-bill" -# Internal crate — part of the `th` binary, not a public library. -# `th` ships as a GitHub binary release; only smooai-smooth-operator-core -# is published to crates.io (separately, as its own crate). Pearl th-607f69. -publish = false -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Bootstrap Bill — The Board's host-side broker. Owns microsandbox, spawns pods on behalf of Big Smooth and the rest of the Safehouse cast." -readme = "README.md" - -[lib] -name = "smooth_bootstrap_bill" - -[features] -default = ["server"] -# `server`: compile the microsandbox registry + TCP accept loop + the -# `bootstrap-bill` binary. Required on the host. Disabled when building -# the Safehouse image (cross-compiling to aarch64-unknown-linux-musl) -# because `microsandbox` pulls `msb_krun_devices` which references Linux -# VM device ioctls that don't exist in musl's libc bindings. Inside the -# Safehouse VM, Big Smooth only needs `BillClient` anyway. -server = ["dep:microsandbox", "dep:chrono", "dep:uuid", "dep:dirs-next"] - -[dependencies] -tokio = { workspace = true } -serde.workspace = true -serde_json.workspace = true -anyhow.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -clap.workspace = true -chrono = { workspace = true, optional = true } -uuid = { workspace = true, optional = true } -microsandbox = { workspace = true, optional = true } -dirs-next = { workspace = true, optional = true } - -[[bin]] -name = "bootstrap-bill" -path = "src/bin/bootstrap-bill.rs" -required-features = ["server"] - -[dev-dependencies] -tempfile = "3" - -[lints] -workspace = true diff --git a/crates/smooth-bootstrap-bill/README.md b/crates/smooth-bootstrap-bill/README.md deleted file mode 100644 index ed09f25f..00000000 --- a/crates/smooth-bootstrap-bill/README.md +++ /dev/null @@ -1,68 +0,0 @@ -
- -# smooth-bootstrap-bill - -**Bootstrap Bill — The Board's host-side broker** - -*Bill walks between worlds. Big Smooth lives inside the Safehouse VM where the hypervisor cannot be reached. Bill lives on the host where the hypervisor is. He takes his orders from Big Smooth, and he spawns the pods nobody else can.* - -[![crates.io](https://img.shields.io/crates/v/smooai-smooth-bootstrap-bill)](https://crates.io/crates/smooai-smooth-bootstrap-bill) -[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/SmooAI/smooth/blob/main/LICENSE) - -
- ---- - -## Why Bill exists - -Big Smooth, the orchestrator, lives inside a hardware-isolated Safehouse microVM for a reason — it's the one process in the cast that's `READ-ONLY` and never touches the host. But HVF on Apple Silicon (and KVM under most configs) don't offer nested virtualization. Big Smooth cannot call `microsandbox` from inside its own VM to spawn operator pods. - -Bill is the one process on the host that holds `microsandbox::Sandbox` handles. Everyone else — Big Smooth, Archivist, the test harness, the `th` CLI itself in Safehouse mode — asks Bill over TCP loopback (via `host.containers.internal` when the caller lives inside a VM) and Bill does the actual spawn, exec, destroy. - -## Protocol - -Line-delimited JSON over TCP. One request per connection, one response line, close. No request IDs, no multiplexing, no streaming. Keep it dumb; the smart part lives elsewhere. - -``` -┌──────────────────────┐ ┌──────────────────────┐ -│ Big Smooth │ │ Bootstrap Bill │ -│ (Safehouse VM) │ ── TCP ───▶ │ (host process) │ -│ │ loopback │ │ -│ orchestrator │ │ holds microsandbox │ -│ READ-ONLY │ │ Sandbox registry │ -└──────────────────────┘ └──────────────────────┘ - │ - ▼ - ┌──────────────────────┐ - │ Operator microVMs │ - │ (spawned on demand) │ - └──────────────────────┘ -``` - -Part of **[Smooth](https://github.com/SmooAI/smooth)**, the security-first AI-agent orchestration platform. - -## What's in the box - -- **`server`** (feature-gated behind `server`) — Bill's TCP listener + registry. The only place in the workspace that holds `microsandbox::Sandbox` handles. -- **`BillClient`** — thin TCP client used by Big Smooth and friends to send `BillRequest::Spawn|Exec|Destroy|List|Ping` and get a `BillResponse` back. -- **`BindMountSpec` / `SandboxSpec` / `PortMapping`** — shared wire types. Importing just these is zero-cost (no microsandbox dep pulled in). -- **`run_bind_all_proxy`** — TCP forwarder from `0.0.0.0:` to `127.0.0.1:`, so guest-to-guest traffic can reach a microsandbox-published port (microsandbox binds 127.0.0.1-only by default). - -## Usage - -Most callers only want the client: - -```rust -use smooth_bootstrap_bill::BillClient; -use smooth_bootstrap_bill::protocol::SandboxSpec; - -let bill = BillClient::new("http://host.containers.internal:8650"); -let spec = SandboxSpec { /* image, mounts, env, ports, … */ ..Default::default() }; -let (_name, _ports, _created) = bill.spawn(spec).await?; -``` - -Run the server binary (`scripts/bootstrap-bill-server`) or, in tests, call the in-process `server::spawn_sandbox` / `exec_sandbox` / `destroy_sandbox` functions directly (enable the `server` feature). - -## License - -MIT diff --git a/crates/smooth-bootstrap-bill/examples/bisect_spawn.rs b/crates/smooth-bootstrap-bill/examples/bisect_spawn.rs deleted file mode 100644 index 595092f5..00000000 --- a/crates/smooth-bootstrap-bill/examples/bisect_spawn.rs +++ /dev/null @@ -1,414 +0,0 @@ -//! Diag harness for pearl th-461ab9. -//! -//! Sends a `Spawn` request to a running `bootstrap-bill` and reports how -//! long it took. Used to bisect which `SandboxSpec` feature triggers the -//! known hang on macOS HVF. -//! -//! Usage: -//! cargo run --release -p smooai-smooth-bootstrap-bill --example bisect_spawn -- \ -//! --bill 127.0.0.1:4444 --variant baseline --timeout 120 -//! -//! Variants (cumulative): -//! baseline — alpine + ["echo","hello"], NO mounts/secrets/network/ports -//! mounts — baseline + 4 bind-mounts (runner-bin + workspace + .smooth + policy) -//! secrets — baseline + 1 SecretSpec (LLM gateway placeholder) -//! network — baseline + allow_host_loopback=true (NetworkPolicy::allow_all) -//! ports — baseline + 10 port forwards (host_port=0) -//! secrets-network — baseline + secrets + network (strong suspect combo) -//! all — full real spec the orchestrator builds -//! -//! Image-collision hypothesis (pearl th-dd0cef): -//! mounts-collide — bind-mounts to paths that EXIST in the OCI image -//! rootfs (`/var/log/x`, `/root/x`). Hypothesis: virtio -//! silently drops these because they collide with the -//! image rootfs entries. -//! mounts-fresh — bind-mounts to paths that DO NOT exist in the OCI -//! image rootfs (`/scratch/diag/a`, `/opt/diag/b`). -//! Hypothesis: these always land successfully because -//! they don't collide. -//! -//! Run the two variants back-to-back and compare: -//! -//! ```text -//! cargo run --release -p smooai-smooth-bootstrap-bill --example bisect_spawn -- \ -//! --bill 127.0.0.1:4444 --variant mounts-collide --image ghcr.io/smooai/smooth-operative:latest -//! cargo run --release -p smooai-smooth-bootstrap-bill --example bisect_spawn -- \ -//! --bill 127.0.0.1:4444 --variant mounts-fresh --image ghcr.io/smooai/smooth-operative:latest -//! ``` -//! -//! After each spawn, exec `mount | grep virtiofs` inside the sandbox and -//! count entries. If `mounts-fresh` consistently shows N mounts and -//! `mounts-collide` shows < N, the hypothesis is confirmed. - -use std::collections::HashMap; -use std::path::PathBuf; -use std::time::{Duration, Instant}; - -use anyhow::Result; -use clap::Parser; - -use smooth_bootstrap_bill::client::BillClient; -use smooth_bootstrap_bill::protocol::{BindMountSpec, PortMapping, SandboxSpec, SecretSpec}; - -#[derive(Parser, Debug)] -struct Args { - /// Bill TCP address (host:port). - #[arg(long, default_value = "127.0.0.1:4444")] - bill: String, - - /// Which feature variant to send. - #[arg(long)] - variant: String, - - /// Hard wall-clock timeout (seconds) for the Spawn call. After this - /// we give up and report HUNG. The harness intentionally does NOT - /// destroy the sandbox afterwards — keeping that to the operator - /// to avoid mutating Bill's registry while another bisect run is - /// in flight. - #[arg(long, default_value_t = 90)] - timeout: u64, - - /// Image to use. Default `alpine` for fast iteration. - /// Use `ghcr.io/smooai/smooth-operative:latest` for the production image. - #[arg(long, default_value = "alpine")] - image: String, - - /// Optional override for the sandbox name. Default is auto-generated. - #[arg(long)] - name: Option, - - /// Repeat the spawn N times sequentially. Each iteration uses a unique - /// generated name. Use to surface state-accumulation hangs. - #[arg(long, default_value_t = 1)] - repeat: usize, - - /// Concurrent spawns per iteration. Each iteration fires `concurrent` - /// Spawn requests in parallel and waits for all. Use to surface - /// parallel-state-pressure hangs. - #[arg(long, default_value_t = 1)] - concurrent: usize, - - /// Skip the per-spawn destroy. Without it, every successful spawn is - /// destroyed before the next iteration. With it, sandboxes accumulate - /// in Bill's registry and microsandbox state. - #[arg(long, default_value_t = false)] - no_destroy: bool, - - /// Skip the per-spawn exec probe (faster iterations). - #[arg(long, default_value_t = false)] - no_exec: bool, -} - -fn build_spec(variant: &str, image: &str, name: &str) -> SandboxSpec { - let mut spec = SandboxSpec { - name: name.to_string(), - image: image.to_string(), - cpus: 1, - memory_mb: 512, - env: HashMap::new(), - mounts: vec![], - ports: vec![], - timeout_seconds: 60, - allow_host_loopback: false, - env_cache_key: None, - use_named_volume_for_cache: false, - secrets: vec![], - }; - - let home = std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/tmp")); - let smooth_home = home.join(".smooth"); - - let runner_bin_dir = smooth_home.join("operator-bin"); - let _ = std::fs::create_dir_all(&runner_bin_dir); - let policy_dir = std::env::temp_dir().join("smooth-bisect-policy"); - let _ = std::fs::create_dir_all(&policy_dir); - let workspace = std::env::temp_dir().join("smooth-bisect-workspace"); - let _ = std::fs::create_dir_all(&workspace); - - let mounts = vec![ - BindMountSpec { - host_path: runner_bin_dir.to_string_lossy().to_string(), - guest_path: "/opt/smooth/bin".into(), - readonly: true, - }, - BindMountSpec { - host_path: workspace.to_string_lossy().to_string(), - guest_path: "/workspace".into(), - readonly: false, - }, - BindMountSpec { - host_path: smooth_home.to_string_lossy().to_string(), - guest_path: "/root/.smooth".into(), - readonly: false, - }, - BindMountSpec { - host_path: policy_dir.to_string_lossy().to_string(), - guest_path: "/opt/smooth/policy".into(), - readonly: true, - }, - ]; - - let secrets = vec![SecretSpec { - env_var: "SMOOTH_API_KEY".into(), - value: "fake-test-value-not-real".into(), - placeholder: "SMOOTH_PLACEHOLDER_API_KEY_NOT_SUBSTITUTED".into(), - allowed_hosts: vec!["llm.smoo.ai".into()], - }]; - - let ports: Vec = [3000u16, 3001, 4000, 4200, 5000, 5173, 8000, 8080, 8888, 9090] - .into_iter() - .map(|p| PortMapping { - host_port: 0, - guest_port: p, - bind_all: false, - }) - .collect(); - - match variant { - "baseline" => {} - "mounts" => { - spec.mounts = mounts; - } - "secrets" => { - spec.secrets = secrets; - } - "network" => { - spec.allow_host_loopback = true; - } - "ports" => { - spec.ports = ports; - } - "secrets-network" => { - spec.secrets = secrets; - spec.allow_host_loopback = true; - } - "mounts-network" => { - spec.mounts = mounts; - spec.allow_host_loopback = true; - } - "all" => { - spec.mounts = mounts; - spec.secrets = secrets; - spec.allow_host_loopback = true; - spec.ports = ports; - spec.env.insert("SMOOTH_API_URL".into(), "https://llm.smoo.ai/v1".into()); - spec.env.insert("SMOOTH_WORKSPACE".into(), "/workspace".into()); - spec.env.insert("SMOOTH_HOME".into(), "/root/.smooth".into()); - } - "all-volume-cache" => { - spec.mounts = mounts; - spec.secrets = secrets; - spec.allow_host_loopback = true; - spec.ports = ports; - spec.env_cache_key = Some("bisect-named-vol-key".into()); - spec.use_named_volume_for_cache = true; - spec.env.insert("SMOOTH_API_URL".into(), "https://llm.smoo.ai/v1".into()); - } - "all-bind-cache" => { - spec.mounts = mounts; - spec.secrets = secrets; - spec.allow_host_loopback = true; - spec.ports = ports; - spec.env_cache_key = Some("bisect-bind-key".into()); - spec.use_named_volume_for_cache = false; - spec.env.insert("SMOOTH_API_URL".into(), "https://llm.smoo.ai/v1".into()); - } - // ── Pearl th-dd0cef: image-collision hypothesis ──────── - // Mount-targets that already exist in the smooth-operative OCI - // rootfs. If virtio drops these silently, this variant should - // exhibit the symptom (host bind-source dirs stay empty after - // dispatch). - "mounts-collide" => { - let collide_a = std::env::temp_dir().join("smooth-bisect-collide-a"); - let collide_b = std::env::temp_dir().join("smooth-bisect-collide-b"); - let _ = std::fs::create_dir_all(&collide_a); - let _ = std::fs::create_dir_all(&collide_b); - spec.mounts = vec![ - BindMountSpec { - host_path: collide_a.to_string_lossy().to_string(), - guest_path: "/var/log/diag-collide".into(), - readonly: false, - }, - BindMountSpec { - host_path: collide_b.to_string_lossy().to_string(), - guest_path: "/root/diag-collide".into(), - readonly: false, - }, - ]; - } - // Mount-targets at paths that do NOT exist in the OCI rootfs. - // These should always land cleanly. Compare counts vs the - // mounts-collide run to confirm/refute the hypothesis. - "mounts-fresh" => { - let fresh_a = std::env::temp_dir().join("smooth-bisect-fresh-a"); - let fresh_b = std::env::temp_dir().join("smooth-bisect-fresh-b"); - let _ = std::fs::create_dir_all(&fresh_a); - let _ = std::fs::create_dir_all(&fresh_b); - spec.mounts = vec![ - BindMountSpec { - host_path: fresh_a.to_string_lossy().to_string(), - guest_path: "/scratch/diag/a".into(), - readonly: false, - }, - BindMountSpec { - host_path: fresh_b.to_string_lossy().to_string(), - guest_path: "/opt/diag/b".into(), - readonly: false, - }, - ]; - } - other => panic!("unknown variant: {other}"), - } - - spec -} - -fn unique_name(variant: &str, ordinal: usize) -> String { - let now_ns = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let pid = std::process::id() as u128; - let suffix = format!("{:012x}", (now_ns ^ (pid << 32) ^ (ordinal as u128)) & 0xFFFF_FFFF_FFFF); - format!("bisect-{variant}-{suffix}") -} - -#[derive(Debug)] -enum AttemptOutcome { - Spawned { - spawn_ms: u128, - exec_code: Option, - exec_ms: Option, - }, - Error { - spawn_ms: u128, - err: String, - }, - Hung, -} - -async fn one_attempt(client: BillClient, spec: SandboxSpec, args: &Args, ordinal: usize) -> AttemptOutcome { - let name = spec.name.clone(); - let timeout_dur = Duration::from_secs(args.timeout); - let start = Instant::now(); - let result = tokio::time::timeout(timeout_dur, client.spawn(spec)).await; - let spawn_ms = start.elapsed().as_millis(); - match result { - Ok(Ok((nm, _ports, _created_at))) => { - let (exec_code, exec_ms) = if args.no_exec { - (None, None) - } else { - let exec_start = Instant::now(); - let probe = "echo ok"; - match tokio::time::timeout(Duration::from_secs(15), client.exec(&nm, &["/bin/sh".into(), "-c".into(), probe.into()])).await { - Ok(Ok((_, _, code))) => (Some(code), Some(exec_start.elapsed().as_millis())), - Ok(Err(_)) => (Some(-2), Some(exec_start.elapsed().as_millis())), - Err(_) => (Some(-3), Some(exec_start.elapsed().as_millis())), - } - }; - if !args.no_destroy { - let _ = tokio::time::timeout(Duration::from_secs(20), client.destroy(&nm)).await; - } - eprintln!("[#{ordinal:03}] spawned name={nm} spawn_ms={spawn_ms} exec_code={exec_code:?} exec_ms={exec_ms:?}"); - AttemptOutcome::Spawned { spawn_ms, exec_code, exec_ms } - } - Ok(Err(e)) => { - let err = format!("{e:#}"); - eprintln!("[#{ordinal:03}] error name={name} spawn_ms={spawn_ms} err={err}"); - AttemptOutcome::Error { spawn_ms, err } - } - Err(_) => { - eprintln!("[#{ordinal:03}] HUNG name={name} elapsed_ms>{}", spawn_ms); - AttemptOutcome::Hung - } - } -} - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt().with_writer(std::io::stderr).init(); - let args = Args::parse(); - - eprintln!("=== bisect_spawn ==="); - eprintln!("bill={}", args.bill); - eprintln!("variant={}", args.variant); - eprintln!("image={}", args.image); - eprintln!( - "repeat={} concurrent={} no_destroy={} no_exec={}", - args.repeat, args.concurrent, args.no_destroy, args.no_exec - ); - eprintln!("timeout={}s", args.timeout); - - let client = BillClient::new(format!("http://{}", args.bill)); - - // Liveness probe. - let pong_start = Instant::now(); - match client.ping().await { - Ok(v) => eprintln!("ping={} ({}ms)", v, pong_start.elapsed().as_millis()), - Err(e) => { - eprintln!("FATAL: bill not reachable: {e:#}"); - std::process::exit(2); - } - } - - let mut spawned = 0usize; - let mut errored = 0usize; - let mut hung = 0usize; - let mut spawn_ms_samples: Vec = Vec::with_capacity(args.repeat * args.concurrent); - let stress_start = Instant::now(); - - for iter in 0..args.repeat { - // Build a batch of N concurrent attempts. - let mut handles = Vec::with_capacity(args.concurrent); - for c in 0..args.concurrent { - let ordinal = iter * args.concurrent + c; - let name = args.name.clone().unwrap_or_else(|| unique_name(&args.variant, ordinal)); - let spec = build_spec(&args.variant, &args.image, &name); - let client = client.clone(); - let args_clone = Args { - bill: args.bill.clone(), - variant: args.variant.clone(), - timeout: args.timeout, - image: args.image.clone(), - name: None, - repeat: 1, - concurrent: 1, - no_destroy: args.no_destroy, - no_exec: args.no_exec, - }; - handles.push(tokio::spawn(async move { one_attempt(client, spec, &args_clone, ordinal).await })); - } - for h in handles { - match h.await.expect("join") { - AttemptOutcome::Spawned { spawn_ms, .. } => { - spawned += 1; - spawn_ms_samples.push(spawn_ms); - } - AttemptOutcome::Error { .. } => errored += 1, - AttemptOutcome::Hung => { - hung += 1; - eprintln!("STOPPING: hung detected at iter={iter}"); - } - } - } - if hung > 0 { - break; - } - } - - let total_ms = stress_start.elapsed().as_millis(); - let n = spawn_ms_samples.len() as u128; - let avg = if n > 0 { spawn_ms_samples.iter().sum::() / n } else { 0 }; - let max = spawn_ms_samples.iter().copied().max().unwrap_or(0); - let min = spawn_ms_samples.iter().copied().min().unwrap_or(0); - eprintln!("=== summary ==="); - eprintln!("spawned={spawned} errored={errored} hung={hung} total_ms={total_ms}"); - eprintln!("spawn_ms min={min} avg={avg} max={max} (n={n})"); - if hung > 0 { - std::process::exit(124); - } - if errored > 0 { - std::process::exit(3); - } - Ok(()) -} diff --git a/crates/smooth-bootstrap-bill/src/bin/bootstrap-bill.rs b/crates/smooth-bootstrap-bill/src/bin/bootstrap-bill.rs deleted file mode 100644 index 6944dde6..00000000 --- a/crates/smooth-bootstrap-bill/src/bin/bootstrap-bill.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Bootstrap Bill — host-side broker for The Board. -//! -//! Usage: -//! -//! ```bash -//! bootstrap-bill --listen 127.0.0.1:0 --print-port -//! ``` -//! -//! On startup Bill binds the TCP listener, optionally prints the resolved -//! port on stdout (so parents can capture it from a pipe), and installs a -//! panic hook + SIGINT handler that destroys every registered sandbox -//! before exiting. Sandboxes leaked across a panic leave a microVM -//! running that nothing can find; don't do that to the user. - -#![allow(clippy::expect_used)] - -use std::net::SocketAddr; - -use anyhow::Context; -use clap::Parser; -use tracing_subscriber::EnvFilter; - -use smooth_bootstrap_bill::server; - -#[derive(Parser, Debug)] -#[command(version, about = "Bootstrap Bill — The Board's host-side broker", long_about = None)] -struct Args { - /// Address to bind on. Use `127.0.0.1:0` to ask the kernel for an - /// ephemeral port. - #[arg(long, default_value = "127.0.0.1:4444")] - listen: SocketAddr, - - /// If set, print the resolved listen port on a single line to stdout - /// after binding ("BILL_PORT="). Useful for parent processes - /// that pipe stdout to read back the kernel-assigned port. - #[arg(long)] - print_port: bool, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) - .with_writer(std::io::stderr) - .try_init() - .ok(); - - let args = Args::parse(); - - // Install a panic hook that destroys every sandbox before we go down. - // We use a blocking-safe shutdown because panic hooks run outside a - // tokio context; `futures::executor::block_on` works in a pinch, but - // we don't want to pull futures in. Instead: spawn a short-lived - // tokio runtime inside the hook. - let default_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - eprintln!("bill: PANIC — destroying all sandboxes before exit"); - let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().ok(); - if let Some(rt) = rt { - rt.block_on(server::destroy_all()); - } - default_hook(info); - })); - - let (local, handle) = server::listen(args.listen).await.context("bill: listen")?; - if args.print_port { - println!("BILL_PORT={}", local.port()); - // Flush immediately so parents reading line-by-line can see it. - use std::io::Write; - std::io::stdout().flush().ok(); - } - eprintln!("bill: ready at {local}"); - - // SIGINT → graceful shutdown: destroy all sandboxes, then exit. - tokio::select! { - _ = handle => { - eprintln!("bill: accept loop exited"); - } - _ = tokio::signal::ctrl_c() => { - eprintln!("bill: SIGINT — destroying all sandboxes"); - server::destroy_all().await; - } - } - - Ok(()) -} diff --git a/crates/smooth-bootstrap-bill/src/client.rs b/crates/smooth-bootstrap-bill/src/client.rs deleted file mode 100644 index a2bd0729..00000000 --- a/crates/smooth-bootstrap-bill/src/client.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! `BillClient` — TCP client used by Big Smooth and other Board members. -//! -//! The client opens a fresh TCP connection per request, writes the -//! line-delimited JSON request, and reads exactly one response line. - -use anyhow::{Context, Result}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpStream; - -use crate::protocol::{BillRequest, BillResponse, PortMapping, SandboxSpec}; - -/// Client handle. Clone-friendly (cheap — holds only a URL). -#[derive(Debug, Clone)] -pub struct BillClient { - /// Base URL of Bill, e.g. `http://127.0.0.1:42424` or - /// `http://host.containers.internal:42424` from inside a VM. Only the - /// host + port are used; the scheme is accepted to make config strings - /// consistent with the rest of the Smooth URL world. - url: String, -} - -impl BillClient { - /// Build a client pointed at Bill's URL. - #[must_use] - pub fn new(url: impl Into) -> Self { - Self { url: url.into() } - } - - /// The configured URL. - #[must_use] - pub fn url(&self) -> &str { - &self.url - } - - /// Extract host + port from `self.url`, supporting `http://host:port`, - /// `host:port`, or bare `host` (defaults to port 4444 — Bill's canonical - /// port when unspecified). - fn authority(&self) -> Result { - let s = self.url.trim(); - let s = s.strip_prefix("http://").or_else(|| s.strip_prefix("https://")).unwrap_or(s); - let s = s.trim_end_matches('/'); - if s.contains(':') { - Ok(s.to_string()) - } else { - Ok(format!("{s}:4444")) - } - } - - async fn send(&self, request: &BillRequest) -> Result { - let addr = self.authority()?; - let stream = TcpStream::connect(&addr).await.with_context(|| format!("connect to Bill at {addr}"))?; - let (read_half, mut write_half) = stream.into_split(); - let mut json = serde_json::to_vec(request).context("serialize request")?; - json.push(b'\n'); - write_half.write_all(&json).await.context("write request")?; - write_half.flush().await.context("flush request")?; - // Half-close the write side so Bill knows no more bytes are coming. - write_half.shutdown().await.ok(); - drop(write_half); - let mut reader = BufReader::new(read_half); - let mut line = String::new(); - reader.read_line(&mut line).await.context("read response")?; - let response: BillResponse = serde_json::from_str(line.trim()).with_context(|| format!("parse response: {line:?}"))?; - Ok(response) - } - - /// Liveness probe. Returns Bill's version string on success. - /// - /// # Errors - /// - /// Network failures or a non-`Pong` response surface as an error. - pub async fn ping(&self) -> Result { - match self.send(&BillRequest::Ping).await? { - BillResponse::Pong { version } => Ok(version), - BillResponse::Error { message } => anyhow::bail!("bill error: {message}"), - other => anyhow::bail!("unexpected response to Ping: {other:?}"), - } - } - - /// Spawn a sandbox. Returns the resolved name, resolved port mappings - /// (with any `host_port: 0` requests replaced by the kernel-assigned - /// port), and the RFC3339 creation timestamp. - /// - /// # Errors - /// - /// Any `BillResponse::Error` is propagated with its message. - pub async fn spawn(&self, spec: SandboxSpec) -> Result<(String, Vec, String)> { - match self.send(&BillRequest::Spawn { spec }).await? { - BillResponse::Spawned { name, host_ports, created_at } => Ok((name, host_ports, created_at)), - BillResponse::Error { message } => anyhow::bail!("bill error: {message}"), - other => anyhow::bail!("unexpected response to Spawn: {other:?}"), - } - } - - /// Execute a command inside a running sandbox. - /// - /// # Errors - /// - /// Bill-side errors propagate. A non-zero exit code is **not** an error - /// — it is returned in the tuple alongside stdout/stderr. - pub async fn exec(&self, name: &str, argv: &[String]) -> Result<(String, String, i32)> { - let req = BillRequest::Exec { - name: name.to_string(), - argv: argv.to_vec(), - }; - match self.send(&req).await? { - BillResponse::ExecResult { stdout, stderr, exit_code } => Ok((stdout, stderr, exit_code)), - BillResponse::Error { message } => anyhow::bail!("bill error: {message}"), - other => anyhow::bail!("unexpected response to Exec: {other:?}"), - } - } - - /// Destroy a sandbox. Idempotent. - /// - /// # Errors - /// - /// Bill-side errors propagate. - pub async fn destroy(&self, name: &str) -> Result<()> { - match self.send(&BillRequest::Destroy { name: name.to_string() }).await? { - BillResponse::Destroyed => Ok(()), - BillResponse::Error { message } => anyhow::bail!("bill error: {message}"), - other => anyhow::bail!("unexpected response to Destroy: {other:?}"), - } - } - - /// List every sandbox Bill currently holds. - /// - /// # Errors - /// - /// Bill-side errors propagate. - pub async fn list(&self) -> Result> { - match self.send(&BillRequest::List).await? { - BillResponse::SandboxList { names } => Ok(names), - BillResponse::Error { message } => anyhow::bail!("bill error: {message}"), - other => anyhow::bail!("unexpected response to List: {other:?}"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn authority_adds_default_port_for_bare_host() { - let c = BillClient::new("localhost"); - assert_eq!(c.authority().unwrap(), "localhost:4444"); - } - - #[test] - fn authority_strips_http_scheme() { - let c = BillClient::new("http://127.0.0.1:4242"); - assert_eq!(c.authority().unwrap(), "127.0.0.1:4242"); - } - - #[test] - fn authority_strips_trailing_slash() { - let c = BillClient::new("http://127.0.0.1:4242/"); - assert_eq!(c.authority().unwrap(), "127.0.0.1:4242"); - } -} diff --git a/crates/smooth-bootstrap-bill/src/lib.rs b/crates/smooth-bootstrap-bill/src/lib.rs deleted file mode 100644 index 84b1ccbf..00000000 --- a/crates/smooth-bootstrap-bill/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Bootstrap Bill — The Board's host-side broker. -//! -//! Bill is cursed to walk between worlds: he lives on the host because the -//! hypervisor lives there, but he serves **The Board** (the Safehouse cast) -//! and takes his orders from Big Smooth. -//! -//! # Why Bill exists -//! -//! Big Smooth runs inside a Safehouse microVM. HVF on Apple Silicon has no -//! nested virtualization, so Big Smooth cannot call `microsandbox` from -//! inside its own VM to spawn operator pods. Bill is the one process on the -//! host that holds `microsandbox::Sandbox` handles; everyone else calls him -//! over TCP loopback (via `host.containers.internal` when the caller lives -//! inside a VM). -//! -//! # Protocol -//! -//! Line-delimited JSON over TCP. One request per connection, one response -//! line (terminal), then close. Keep it dumb: no request IDs, no multiplexing. -//! -//! * [`protocol::BillRequest`] — the request types Bill accepts. -//! * [`protocol::BillResponse`] — the reply types, including terminal success -//! and error variants. -//! -//! # Modules -//! -//! * [`protocol`] — wire types. -//! * [`server`] — TCP accept loop + dispatch to the microsandbox registry. -//! * [`client`] — [`client::BillClient`] used by Big Smooth (or any other -//! Safehouse cast member) to talk to Bill. - -pub mod client; -#[cfg(feature = "server")] -pub mod project_cache; -pub mod protocol; -#[cfg(feature = "server")] -pub mod server; - -pub use client::BillClient; -pub use protocol::{BillRequest, BillResponse, BindMountSpec, PortMapping, SandboxSpec}; diff --git a/crates/smooth-bootstrap-bill/src/project_cache.rs b/crates/smooth-bootstrap-bill/src/project_cache.rs deleted file mode 100644 index 3a1fd309..00000000 --- a/crates/smooth-bootstrap-bill/src/project_cache.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! Project-cache Volume helpers. -//! -//! The operator VM project cache has two backends (see -//! [`crate::protocol::SandboxSpec::use_named_volume_for_cache`]): -//! -//! 1. Legacy bind-mount under `~/.smooth/project-cache//`, -//! managed directly by the CLI via filesystem APIs. -//! 2. Named microsandbox `Volume` under -//! `~/.microsandbox/volumes/smooth-cache-/`, tagged with -//! the label `smooth-kind=project-cache`. -//! -//! This module exposes typed, side-effect-free-ish helpers that the CLI -//! (`th cache …`) uses to enumerate, size, and remove entries in the -//! volume backend without reaching into the microsandbox types directly. -//! Keeping the microsandbox crate confined to this crate means `th` can -//! still build without having to re-pull its transitive dep graph. - -use std::path::PathBuf; - -use anyhow::{Context, Result}; - -use crate::server::sanitize_volume_name; - -/// Label key that marks a named volume as a smooth project cache. -/// Matches the label applied by [`crate::server::spawn_sandbox`] when it -/// creates a new cache volume. -pub const PROJECT_CACHE_LABEL_KEY: &str = "smooth-kind"; -/// Label value matching [`PROJECT_CACHE_LABEL_KEY`]. -pub const PROJECT_CACHE_LABEL_VALUE: &str = "project-cache"; -/// Label key that stores the original cache_key (pre-sanitization) so -/// `th cache clear ` can walk from project path → cache_key → -/// volume name without having to re-derive the exact sanitization. -pub const PROJECT_CACHE_KEY_LABEL: &str = "smooth-cache-key"; - -/// Per-volume info. Fully owned — no lifetimes back to microsandbox. -#[derive(Debug, Clone)] -pub struct ProjectCacheVolumeInfo { - /// Volume name in microsandbox (e.g. `smooth-cache-budgeting-abc123`). - pub volume_name: String, - /// Original cache key (workspace-path hash), recovered from the - /// `smooth-cache-key` label if present. Falls back to the volume - /// name with the `smooth-cache-` prefix stripped when the label is - /// missing (older volumes created before labels were added). - pub cache_key: String, - /// Host-side directory where the volume's data lives. - /// `~/.microsandbox/volumes//`. - pub path: PathBuf, - /// Recursive on-disk size in bytes. - pub size_bytes: u64, - /// Wall-clock creation time from the microsandbox DB, if recorded. - pub created_at: Option>, - /// Most recent filesystem mtime inside the volume tree — used as a - /// "last touched" signal for LRU-style pruning. Falls back to - /// `created_at` equivalent (dir mtime) when there are no files yet. - pub last_modified: Option, -} - -/// Convert a workspace cache key into the microsandbox Volume name. -/// -/// Mirror of the naming applied by [`crate::server::spawn_sandbox`] -/// for the named-volume backend. Exposed so CLI-side code can compute -/// "what would the volume be named?" without cross-referencing -/// internals. -#[must_use] -pub fn volume_name_for_cache_key(cache_key: &str) -> String { - sanitize_volume_name(cache_key) -} - -/// List every microsandbox volume tagged as a smooth project cache. -/// -/// Filters the full `Volume::list()` output by the -/// [`PROJECT_CACHE_LABEL_KEY`] = [`PROJECT_CACHE_LABEL_VALUE`] label. -/// -/// # Errors -/// -/// Returns the microsandbox DB error if the volumes database cannot be -/// opened (typically because `~/.microsandbox/` doesn't exist yet). -pub async fn list_project_cache_volumes() -> Result> { - let handles = match microsandbox::volume::Volume::list().await { - Ok(h) => h, - Err(e) => { - let msg = format!("{e}"); - // If the DB is missing (fresh install, never spawned a - // sandbox), return an empty list rather than a hard error. - if msg.contains("no such file") || msg.to_ascii_lowercase().contains("no such") { - return Ok(Vec::new()); - } - return Err(anyhow::anyhow!(e).context("microsandbox::Volume::list")); - } - }; - - let volumes_dir = microsandbox::config::config().volumes_dir(); - - let mut out = Vec::with_capacity(handles.len()); - for h in handles { - let labels = h.labels(); - let is_project_cache = labels.iter().any(|(k, v)| k == PROJECT_CACHE_LABEL_KEY && v == PROJECT_CACHE_LABEL_VALUE); - if !is_project_cache { - continue; - } - - let cache_key = labels - .iter() - .find(|(k, _)| k == PROJECT_CACHE_KEY_LABEL) - .map_or_else(|| h.name().strip_prefix("smooth-cache-").unwrap_or(h.name()).to_string(), |(_, v)| v.clone()); - - let path = volumes_dir.join(h.name()); - let size_bytes = dir_size_bytes(&path); - let last_modified = dir_mtime(&path); - - out.push(ProjectCacheVolumeInfo { - volume_name: h.name().to_string(), - cache_key, - path, - size_bytes, - created_at: h.created_at(), - last_modified, - }); - } - - Ok(out) -} - -/// Remove the project-cache volume for a given `cache_key`, if one -/// exists. Returns `Ok(true)` if a volume was removed, `Ok(false)` if -/// nothing matched. -/// -/// # Errors -/// -/// Returns the microsandbox error if the volume exists but removal -/// fails (e.g. still mounted by a running sandbox). -pub async fn remove_project_cache_volume(cache_key: &str) -> Result { - let volume_name = volume_name_for_cache_key(cache_key); - match microsandbox::volume::Volume::get(&volume_name).await { - Ok(handle) => { - handle.remove().await.with_context(|| format!("remove project-cache volume '{volume_name}'"))?; - Ok(true) - } - Err(e) => { - let msg = format!("{e}"); - if msg.contains("not found") || msg.to_ascii_lowercase().contains("not found") { - Ok(false) - } else { - // DB open failure on a fresh install — treat as "not there". - if msg.contains("no such file") || msg.to_ascii_lowercase().contains("no such") { - Ok(false) - } else { - Err(anyhow::anyhow!(e).context(format!("look up project-cache volume '{volume_name}'"))) - } - } - } - } -} - -/// Recursive on-disk size of a directory tree, counting file bytes only. -/// Returns 0 on IO errors rather than bailing — a partially listable -/// cache is still useful to report. -fn dir_size_bytes(path: &std::path::Path) -> u64 { - fn walk(p: &std::path::Path) -> u64 { - let mut total = 0u64; - let Ok(entries) = std::fs::read_dir(p) else { return 0 }; - for e in entries.flatten() { - let Ok(md) = e.metadata() else { continue }; - if md.is_dir() { - total = total.saturating_add(walk(&e.path())); - } else { - total = total.saturating_add(md.len()); - } - } - total - } - if !path.is_dir() { - return 0; - } - walk(path) -} - -/// Most recent mtime across a directory's direct children, falling back -/// to the directory's own mtime. Best-effort; returns `None` on IO -/// errors. Used as a "last touched" signal for LRU pruning since -/// microsandbox doesn't track per-volume access time. -fn dir_mtime(path: &std::path::Path) -> Option { - let dir_md = std::fs::metadata(path).ok()?; - let mut newest = dir_md.modified().ok()?; - let Ok(entries) = std::fs::read_dir(path) else { return Some(newest) }; - for e in entries.flatten() { - if let Ok(md) = e.metadata() { - if let Ok(m) = md.modified() { - if m > newest { - newest = m; - } - } - } - } - Some(newest) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn volume_name_for_cache_key_matches_sanitize() { - // The CLI needs to compute the same name Bill would. Lock the - // mapping via a specific example so an accidental divergence - // trips the test. - let name = volume_name_for_cache_key("budgeting-abc123"); - assert_eq!(name, "smooth-cache-budgeting-abc123"); - } - - #[test] - fn volume_name_for_cache_key_handles_unsafe_input() { - // / and + are replaced, leading non-alnum is stripped, the - // `smooth-cache-` prefix is added. - let name = volume_name_for_cache_key("/weird+key"); - assert_eq!(name, "smooth-cache-weird_key"); - } - - #[test] - fn project_cache_label_constants_stay_stable() { - // These values are part of the wire contract between Bill - // (writer) and the CLI (reader). Changing them silently would - // make `th cache list` stop seeing any volumes after the next - // Bill spawn writes the new label. Lock them down. - assert_eq!(PROJECT_CACHE_LABEL_KEY, "smooth-kind"); - assert_eq!(PROJECT_CACHE_LABEL_VALUE, "project-cache"); - assert_eq!(PROJECT_CACHE_KEY_LABEL, "smooth-cache-key"); - } - - #[test] - fn dir_size_bytes_returns_zero_for_missing_path() { - let bogus = std::path::PathBuf::from("/tmp/smooth-cache-volumes-test-does-not-exist-xyz"); - assert_eq!(dir_size_bytes(&bogus), 0); - } - - #[test] - fn dir_mtime_returns_none_for_missing_path() { - let bogus = std::path::PathBuf::from("/tmp/smooth-cache-volumes-test-does-not-exist-xyz"); - assert!(dir_mtime(&bogus).is_none()); - } - - #[test] - fn dir_size_and_mtime_work_on_real_tmp_dir() { - let dir = tempfile::tempdir().expect("tmp"); - let file = dir.path().join("hello.txt"); - std::fs::write(&file, "hi").expect("write"); - assert!(dir_size_bytes(dir.path()) >= 2); - assert!(dir_mtime(dir.path()).is_some()); - } -} diff --git a/crates/smooth-bootstrap-bill/src/protocol.rs b/crates/smooth-bootstrap-bill/src/protocol.rs deleted file mode 100644 index b4611853..00000000 --- a/crates/smooth-bootstrap-bill/src/protocol.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! Wire protocol for Bootstrap Bill. -//! -//! Line-delimited JSON over TCP. Each connection carries exactly one -//! request followed by exactly one response, then closes. Simple, debuggable, -//! no framing tricks. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -/// One bind mount from a host path into a sandbox. -/// -/// Same shape as `smooth_bigsmooth::sandbox::BindMount` (the legacy type) -/// but serializable so it can cross the wire to Bill. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BindMountSpec { - /// Absolute path on the host. Bill validates it before calling microsandbox. - pub host_path: String, - /// Path inside the guest. - pub guest_path: String, - /// Whether the mount is read-only. - pub readonly: bool, -} - -/// Host → guest port mapping. `host_port = 0` asks Bill to pick a free -/// ephemeral port from the host kernel (the assigned port comes back in -/// [`BillResponse::Spawned::host_ports`]). -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub struct PortMapping { - pub host_port: u16, - pub guest_port: u16, - /// When true, Bill runs a TCP proxy that re-publishes this port on - /// `0.0.0.0` in addition to microsandbox's default `127.0.0.1`. - /// This makes the port reachable from OTHER microVMs via the host's - /// real interface IP (which the TCP proxy inside those VMs connects - /// to). Without this, published ports are only accessible from the - /// host loopback — useless for cross-VM traffic like Archivist ingest. - #[serde(default)] - pub bind_all: bool, -} - -/// Full spec for a sandbox Bill should spawn. -/// -/// This is the serialisable form of the old `SandboxConfig`; Bill rebuilds -/// a `microsandbox::Sandbox` from it. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SandboxSpec { - /// Name used as the registry key and the microsandbox name (must be - /// unique per-host while the sandbox is alive). Typical format: - /// `smooth-operative-` or `smooth-safehouse-`. - pub name: String, - /// OCI image reference (e.g. `alpine`, or a custom safehouse image tag). - pub image: String, - /// Guest CPU count. Clamped to `u8::MAX` on Bill's side. - pub cpus: u32, - /// Guest memory in MiB. - pub memory_mb: u32, - /// Environment variables passed on the kernel command line. **Must be - /// printable ASCII only** (microsandbox panics otherwise); Bill - /// double-checks this before spawning. - pub env: HashMap, - /// Bind mounts to apply before boot. - pub mounts: Vec, - /// Port forwards to set up. - pub ports: Vec, - /// Optional caller-chosen timeout in seconds, for diagnostics only. - /// Bill does not enforce this; callers track their own deadlines. - pub timeout_seconds: u64, - /// Opt the sandbox out of microsandbox's default `public_only` network - /// policy (which denies loopback + RFC1918 outbound). When true, Bill - /// applies `NetworkPolicy::allow_all()` to this sandbox, so the guest - /// can TCP to `127.0.0.1:` and reach host services like Bill - /// itself or the Safehouse's Archivist. - /// - /// Under the hood, microsandbox's TCP proxy calls `TcpStream::connect` - /// on the host with the guest's destination address verbatim. With the - /// default policy that path is blocked for loopback/private - /// destinations; with `allow_all()` it goes through. - #[serde(default)] - pub allow_host_loopback: bool, - /// Optional host-side directory for pearl-scoped environment caching. - /// Bill bind-mounts this directory at `/opt/smooth/cache` (RW) inside - /// the VM. The runner sets CARGO_HOME, npm_config_cache, etc. to paths - /// inside this mount so compiled dependencies, installed packages, and - /// build artifacts persist across operator VM runs for the same pearl. - /// - /// First run is cold: tools install from scratch, deps compile. - /// Subsequent runs are warm: cached deps, cached packages. - /// - /// Bill creates the directory if it doesn't exist. Pass `None` to - /// skip caching (bare Alpine every time). - #[serde(default)] - pub env_cache_key: Option, - /// When true, back `env_cache_key` with a microsandbox named - /// Volume (first-class primitive: quota-able, listable via - /// `Volume::list`, removable via `Volume::remove`) instead of - /// the ad-hoc bind-mount of `~/.smooth/project-cache/`. - /// Opt-in because `th cache list|prune|clear` reads the - /// bind-mount host path directly; flipping the default would - /// break those CLI commands until they're migrated (tracked - /// separately). Defaults to false. - #[serde(default)] - pub use_named_volume_for_cache: bool, - /// Secrets wired through microsandbox's SecretBuilder API. For each - /// entry, the runner inside the VM sees an env var containing the - /// PLACEHOLDER (not the real value). When the runner's HTTP client - /// makes an outbound request to one of `allowed_hosts`, microsandbox - /// substitutes the placeholder with the real value on the wire. The - /// real value is never readable from inside the VM. - /// - /// Used today for `SMOOTH_API_KEY` so that a compromised agent - /// can't exfiltrate the LLM gateway credential by reading its - /// own env. Also gives us automatic DNS-rebinding protection + - /// cloud-metadata blocking on any request the placeholder might - /// otherwise leak through. - #[serde(default)] - pub secrets: Vec, -} - -/// One secret to inject into the sandbox via microsandbox's -/// SecretBuilder API. The guest sees `env_var = placeholder`; the -/// real `value` is only swapped in for requests to `allowed_hosts`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SecretSpec { - /// Env var name the guest reads (e.g. `SMOOTH_API_KEY`). - pub env_var: String, - /// Real credential value. Never reaches the guest filesystem — - /// lives in the secrets store and gets substituted on outbound - /// requests to allowed hosts only. - pub value: String, - /// Sentinel the guest sees in `$env_var` until microsandbox - /// swaps it on the wire. A deterministic placeholder lets the - /// guest detect when a substitution failed (e.g. talking to a - /// non-allowlisted host) — if the literal placeholder ever - /// reaches a server, something's misconfigured. - pub placeholder: String, - /// Exact hostnames (not patterns) where the real value is - /// substituted. All other destinations see the placeholder - /// verbatim. - pub allowed_hosts: Vec, -} - -/// A request Bill accepts. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum BillRequest { - /// Liveness probe. - Ping, - /// Spawn a sandbox and register it under `spec.name`. - Spawn { spec: SandboxSpec }, - /// Run a command inside an already-spawned sandbox. The command is - /// executed synchronously; Bill replies with the full stdout/stderr - /// captured when the command exits. - Exec { name: String, argv: Vec }, - /// Stop the sandbox and remove it from the registry. Idempotent. - Destroy { name: String }, - /// Report how many sandboxes Bill currently holds. Handy for panic-hook - /// teardown assertions in tests. - List, -} - -/// A response Bill sends. Exactly one of these is written per request. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum BillResponse { - Pong { - version: String, - }, - Spawned { - /// Confirmed sandbox name (Bill may have normalised it). - name: String, - /// Resolved port mappings — for each requested `guest_port`, the - /// final `host_port` (0 entries in the request are replaced with - /// the kernel-assigned port). - host_ports: Vec, - /// RFC3339 creation timestamp (host clock). - created_at: String, - }, - ExecResult { - stdout: String, - stderr: String, - exit_code: i32, - }, - Destroyed, - SandboxList { - names: Vec, - }, - Error { - message: String, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn request_roundtrip_ping() { - let req = BillRequest::Ping; - let json = serde_json::to_string(&req).expect("serialize"); - assert!(json.contains("\"Ping\""), "unexpected json: {json}"); - let decoded: BillRequest = serde_json::from_str(&json).expect("deserialize"); - assert!(matches!(decoded, BillRequest::Ping)); - } - - #[test] - fn request_roundtrip_spawn() { - let spec = SandboxSpec { - name: "smooth-test".into(), - image: "alpine".into(), - cpus: 2, - memory_mb: 1024, - env: [("FOO".into(), "bar".into())].into(), - mounts: vec![BindMountSpec { - host_path: "/tmp/work".into(), - guest_path: "/workspace".into(), - readonly: false, - }], - ports: vec![PortMapping { - host_port: 0, - guest_port: 4096, - bind_all: false, - }], - timeout_seconds: 1800, - allow_host_loopback: false, - env_cache_key: None, - use_named_volume_for_cache: false, - secrets: vec![], - }; - let req = BillRequest::Spawn { spec }; - let json = serde_json::to_string(&req).expect("serialize"); - let decoded: BillRequest = serde_json::from_str(&json).expect("deserialize"); - match decoded { - BillRequest::Spawn { spec } => { - assert_eq!(spec.name, "smooth-test"); - assert_eq!(spec.mounts.len(), 1); - assert_eq!(spec.ports[0].guest_port, 4096); - } - other => panic!("unexpected decoded request: {other:?}"), - } - } - - #[test] - fn response_roundtrip_spawned() { - let resp = BillResponse::Spawned { - name: "smooth-test".into(), - host_ports: vec![PortMapping { - host_port: 42424, - guest_port: 4096, - bind_all: false, - }], - created_at: "2026-04-05T00:00:00Z".into(), - }; - let json = serde_json::to_string(&resp).expect("serialize"); - let decoded: BillResponse = serde_json::from_str(&json).expect("deserialize"); - match decoded { - BillResponse::Spawned { host_ports, .. } => { - assert_eq!(host_ports[0].host_port, 42424); - } - other => panic!("unexpected decoded response: {other:?}"), - } - } - - #[test] - fn response_roundtrip_error() { - let resp = BillResponse::Error { message: "boom".into() }; - let json = serde_json::to_string(&resp).expect("serialize"); - let decoded: BillResponse = serde_json::from_str(&json).expect("deserialize"); - match decoded { - BillResponse::Error { message } => assert_eq!(message, "boom"), - other => panic!("unexpected decoded response: {other:?}"), - } - } -} diff --git a/crates/smooth-bootstrap-bill/src/server.rs b/crates/smooth-bootstrap-bill/src/server.rs deleted file mode 100644 index 60bf82df..00000000 --- a/crates/smooth-bootstrap-bill/src/server.rs +++ /dev/null @@ -1,865 +0,0 @@ -//! Bill's TCP server and microsandbox registry. -//! -//! This module holds the **only** `microsandbox::Sandbox` handles on the -//! host. All other Board members (Big Smooth, Archivist, etc.) call in over -//! TCP loopback to spawn/exec/destroy pods. -//! -//! The server accepts one request per connection, dispatches it to the -//! registry, and writes exactly one response line before closing. - -use std::collections::HashMap; -use std::net::SocketAddr; -use std::sync::{Arc, Mutex, OnceLock}; - -use anyhow::{Context, Result}; -use microsandbox::{NetworkPolicy, Sandbox}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{TcpListener, TcpStream}; - -use crate::protocol::{BillRequest, BillResponse, PortMapping, SandboxSpec}; - -/// Convert an arbitrary cache key into a microsandbox-safe Volume name. -/// -/// microsandbox requires names to start with an alphanumeric char and -/// contain only `[A-Za-z0-9._-]`. Our cache keys are already hex-ish -/// workspace-path hashes in practice, but be defensive: replace anything -/// unsupported with `_`, prefix `smooth-cache-` so multiple smooth -/// installs coexist with other users of the microsandbox volumes store, -/// and cap length at 120 chars (microsandbox has no documented cap, but -/// a lot of filesystems stop liking names past ~255 bytes). -pub fn sanitize_volume_name(cache_key: &str) -> String { - let mut sanitized = String::with_capacity(cache_key.len()); - for ch in cache_key.chars() { - if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' { - sanitized.push(ch); - } else { - sanitized.push('_'); - } - } - // Strip leading dots/hyphens/underscores — microsandbox requires - // the first char to be alphanumeric. - let trimmed = sanitized.trim_start_matches(|c: char| !c.is_ascii_alphanumeric()); - let body: String = trimmed.chars().take(100).collect(); - let name = if body.is_empty() { "nokey".to_string() } else { body }; - format!("smooth-cache-{name}") -} - -// --------------------------------------------------------------------------- -// Registry — lifted wholesale from `smooth-bigsmooth/src/sandbox.rs`. -// -// Keyed by `spec.name`. `Sandbox` is not `Clone`, so we wrap it in an `Arc` -// to let concurrent Exec calls share it without holding the mutex across -// `.await`. -// --------------------------------------------------------------------------- - -fn registry() -> &'static Mutex>> { - static REGISTRY: OnceLock>>> = OnceLock::new(); - REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn register(name: &str, sandbox: Sandbox) { - if let Ok(mut map) = registry().lock() { - map.insert(name.to_string(), Arc::new(sandbox)); - } -} - -fn unregister(name: &str) -> Option> { - registry().lock().ok().and_then(|mut map| map.remove(name)) -} - -fn lookup(name: &str) -> Option> { - registry().lock().ok().and_then(|map| map.get(name).cloned()) -} - -/// Returns the list of sandbox names currently registered. -fn list() -> Vec { - registry().lock().ok().map(|map| map.keys().cloned().collect()).unwrap_or_default() -} - -/// Destroy every registered sandbox. Intended for panic hooks and clean -/// shutdown paths — never in the request path. -pub async fn destroy_all() { - let names = list(); - for name in names { - if let Err(e) = destroy_sandbox(&name).await { - tracing::warn!(name = %name, error = %e, "destroy_all: failed to destroy sandbox"); - } - } -} - -// --------------------------------------------------------------------------- -// Core operations (the same three verbs the old sandbox.rs exposed). These -// are pub so the in-process `DirectSandboxClient` in smooth-bigsmooth can -// call them without going through the TCP layer. -// --------------------------------------------------------------------------- - -/// Spawn a sandbox from a `SandboxSpec` and register it. -/// -/// # Errors -/// -/// Returns an error if: -/// - `spec.name` is already registered -/// - Any env var value contains non-ASCII bytes (would panic microsandbox) -/// - The VM fails to boot (missing hardware virt, bad image, port clash) -pub async fn spawn_sandbox(spec: SandboxSpec) -> Result<(String, Vec, String)> { - // Pre-flight: env values must be printable ASCII or microsandbox will - // panic from `msb_krun_vmm::builder`. Catch it here with a clean error. - for (k, v) in &spec.env { - if let Some((pos, byte)) = v.bytes().enumerate().find(|&(_, b)| !(b' '..=b'~').contains(&b)) { - anyhow::bail!("env var {k}: non-ASCII byte 0x{byte:02x} at offset {pos} (microsandbox requires printable ASCII env values)"); - } - } - if lookup(&spec.name).is_some() { - anyhow::bail!("sandbox '{}' is already registered", spec.name); - } - - tracing::info!( - name = %spec.name, - image = %spec.image, - cpus = spec.cpus, - memory_mb = spec.memory_mb, - mounts = spec.mounts.len(), - ports = spec.ports.len(), - "bill: spawning sandbox" - ); - - // pearl th-461ab9 (diag): elapsed-since-spawn breadcrumbs. - // The hang is observed at "wiring SecretBuilder" → silence; we don't - // know whether we're stuck in OCI pull, db init, builder.create() - // closures, or wait_for_relay. Tag every step so the gap pinpoints - // the call. - let diag_t0 = std::time::Instant::now(); - let diag_name = spec.name.clone(); - macro_rules! diag { - ($msg:expr) => { - tracing::info!( - name = %diag_name, - t_ms = diag_t0.elapsed().as_millis() as u64, - "bill diag: {}", $msg - ); - }; - ($msg:expr, $($field:tt)*) => { - tracing::info!( - name = %diag_name, - t_ms = diag_t0.elapsed().as_millis() as u64, - $($field)*, - "bill diag: {}", $msg - ); - }; - } - diag!("entering spawn_sandbox after preflight checks"); - - let cpus_u8 = u8::try_from(spec.cpus).unwrap_or(u8::MAX); - diag!("calling Sandbox::builder()"); - let mut builder = Sandbox::builder(spec.name.clone()) - .image(spec.image.as_str()) - .cpus(cpus_u8) - .memory(spec.memory_mb) - // .replace() makes the create idempotent — if a sandbox - // with this name already exists (e.g. a crashed previous - // run left an entry in microsandbox's state DB) it's - // removed and re-created instead of erroring. Pearl - // th-67c96b: needed so `th up --sandboxed` works across - // restarts without a manual cleanup step. - .replace(); - diag!("builder constructed"); - - // Resolve any host_port == 0 requests by asking the kernel for a free - // port now, then handing that number to microsandbox. This keeps the - // broker's contract ("0 means auto-assigned") intact without requiring - // microsandbox to expose the kernel-assigned port back to us. - let mut resolved_ports: Vec = Vec::with_capacity(spec.ports.len()); - for port in &spec.ports { - let host_port = if port.host_port == 0 { - let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).with_context(|| "probing for a free host port")?; - let addr = listener.local_addr().context("reading probe addr")?; - // Drop immediately so microsandbox can claim the port. There's a - // narrow race window here but the kernel won't immediately reuse - // a just-freed ephemeral port for another process. - drop(listener); - addr.port() - } else { - port.host_port - }; - builder = builder.port(host_port, port.guest_port); - resolved_ports.push(PortMapping { - host_port, - guest_port: port.guest_port, - bind_all: port.bind_all, - }); - } - - for (k, v) in &spec.env { - builder = builder.env(k, v); - } - - for mount in &spec.mounts { - let host = mount.host_path.clone(); - let readonly = mount.readonly; - builder = builder.volume(mount.guest_path.clone(), move |m| { - let m = m.bind(host); - if readonly { - m.readonly() - } else { - m - } - }); - } - - // Project-scoped sandbox cache: two backends. - // - // A) Named-volume backend (opt-in via spec.use_named_volume_for_cache): - // ask microsandbox for a first-class Volume keyed off a sanitized - // cache_key. Quota-able, listable via `Volume::list`, removable via - // `Volume::remove`. Lives under ~/.microsandbox/volumes//. - // - // B) Bind-mount backend (default, backward-compatible): resolve the - // cache key to ~/.smooth/project-cache// and bind-mount it at - // /opt/smooth/cache. Remains the default until the `th cache - // list|prune|clear` CLI commands are migrated to the Volume API — - // those commands read the bind-mount host path directly, so - // flipping the default prematurely breaks the user-facing tools. - // - // Either way, deps / stores (PNPM_STORE_PATH, CARGO_HOME, UV_CACHE_DIR, - // GOPATH) live inside so repeated runs on the same pearl lineage share - // them. - if let Some(ref cache_key) = spec.env_cache_key { - diag!("processing env_cache_key", cache_key = %cache_key, use_named_volume = spec.use_named_volume_for_cache); - if spec.use_named_volume_for_cache { - let volume_name = sanitize_volume_name(cache_key); - diag!("calling Volume::get", volume = %volume_name); - match microsandbox::volume::Volume::get(&volume_name).await { - Ok(_) => { - tracing::info!(name = %spec.name, key = %cache_key, volume = %volume_name, "bill: reusing existing named Volume for project cache"); - } - Err(_) => { - // Volume doesn't exist (or DB unreachable) — try to - // create. `VolumeAlreadyExists` is benign if another - // Bill raced us, so we only bail on other errors. - match microsandbox::volume::Volume::create(microsandbox::volume::VolumeConfig { - name: volume_name.clone(), - quota_mib: None, - labels: vec![("smooth-kind".into(), "project-cache".into()), ("smooth-cache-key".into(), cache_key.clone())], - }) - .await - { - Ok(_) => { - tracing::info!(name = %spec.name, key = %cache_key, volume = %volume_name, "bill: created named Volume for project cache"); - } - Err(e) => { - let msg = format!("{e}"); - if msg.contains("already exists") || msg.to_ascii_lowercase().contains("already exists") { - tracing::debug!(name = %spec.name, volume = %volume_name, "bill: named volume already existed (benign race)"); - } else { - return Err(anyhow::anyhow!(e).context(format!("bill: create named volume '{volume_name}' for cache key '{cache_key}'"))); - } - } - } - } - } - let vol_name_for_mount = volume_name.clone(); - builder = builder.volume("/opt/smooth/cache", move |m| m.named(vol_name_for_mount)); - tracing::info!(name = %spec.name, key = %cache_key, volume = %volume_name, "bill: mounting named Volume at /opt/smooth/cache"); - } else { - let cache_dir = dirs_next::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) - .join(".smooth") - .join("project-cache") - .join(cache_key); - if !cache_dir.exists() { - std::fs::create_dir_all(&cache_dir).with_context(|| format!("bill: create project cache: {}", cache_dir.display()))?; - tracing::info!(path = %cache_dir.display(), key = %cache_key, "bill: created project cache dir"); - } - // Bump mtime so atime-/mtime-based LRU pruning keeps recently - // used projects warm. filetime crate would be nicer but we - // avoid adding a dep for a cosmetic refresh — chmod back to - // current mode triggers a ctime/mtime bump on most systems - // without changing permissions. - if let Ok(md) = std::fs::metadata(&cache_dir) { - let _ = std::fs::set_permissions(&cache_dir, md.permissions()); - } - let host = cache_dir.to_string_lossy().to_string(); - builder = builder.volume("/opt/smooth/cache", move |m| m.bind(host)); - tracing::info!(name = %spec.name, key = %cache_key, path = %cache_dir.display(), "bill: mounting project cache at /opt/smooth/cache (bind-mount)"); - } - } - - // Opt-in: let the guest reach host loopback + RFC1918 addresses. - // microsandbox's default policy is `public_only`, which denies - // loopback/private outbound — fine for untrusted operator work, but - // the Safehouse VM (and operator VMs dispatched by a Safehouse-mode - // Big Smooth) must be able to talk back to Bill on 127.0.0.1 and to - // the Safehouse's Archivist. When this flag is set we apply - // `allow_all()` which removes those denies. - // - // The secrets layer rides on the same `.network(|n| ...)` closure, - // so we stage a single builder mutation that owns both the policy - // override and the secret entries. - diag!( - "preparing network/secrets builder closure", - allow_loopback = spec.allow_host_loopback, - n_secrets = spec.secrets.len() - ); - let allow_loopback = spec.allow_host_loopback; - let secrets = spec.secrets.clone(); - if allow_loopback || !secrets.is_empty() { - if allow_loopback { - tracing::info!(name = %spec.name, "bill: applying NetworkPolicy::allow_all() (host loopback enabled)"); - } - if !secrets.is_empty() { - tracing::info!( - name = %spec.name, - count = secrets.len(), - "bill: wiring SecretBuilder entries (values substituted on allowed hosts only)" - ); - } - builder = builder.network(move |mut n| { - if allow_loopback { - n = n.policy(NetworkPolicy::allow_all()); - } - for secret in &secrets { - // Clone into the closure because the SecretBuilder is - // moved on each call. The outer `secrets` vec is owned - // by this closure (already cloned from spec above) so - // we can drain through a reference without moves. - let env_var = secret.env_var.clone(); - let value = secret.value.clone(); - let placeholder = secret.placeholder.clone(); - let allowed = secret.allowed_hosts.clone(); - n = n.secret(move |mut s| { - s = s.env(env_var).value(value).placeholder(placeholder); - for host in allowed { - s = s.allow_host(host); - } - s - }); - } - n - }); - } - - diag!("about to call builder.create().await — this is the historical hang point"); - - // pearl th-461ab9 (diag): parallel filesystem watcher. The microsandbox - // SDK's spawn_sandbox creates ~/.microsandbox/sandboxes//{logs, - // runtime,...} as part of `.create()`. If we never see those dirs - // appear, the SDK is hung BEFORE forking the child msb process — - // i.e. in OCI pull / db init / volume validate. If we see the dirs - // but host.log stays empty (or has no `sandbox starting` line), the - // child msb forked but never reached `microsandbox_runtime::vm::run`. - let watch_name = spec.name.clone(); - let watch_stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let watch_stop_inner = watch_stop.clone(); - let watch_handle = tokio::spawn(async move { - let home = std::env::var_os("HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); - let sandbox_dir = home.join(".microsandbox").join("sandboxes").join(&watch_name); - let host_log = sandbox_dir.join("logs").join("host.log"); - let runtime_dir = sandbox_dir.join("runtime"); - let agent_sock = runtime_dir.join("agent.sock"); - let mut tick = 0u64; - let mut head_logged = false; - loop { - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - if watch_stop_inner.load(std::sync::atomic::Ordering::SeqCst) { - tracing::info!(name = %watch_name, "bill diag-watch: stopped (create() returned)"); - return; - } - tick += 1; - let dir_exists = sandbox_dir.exists(); - let host_log_size = std::fs::metadata(&host_log).map(|m| m.len()).unwrap_or(0); - let agent_sock_exists = agent_sock.exists(); - tracing::info!( - name = %watch_name, - tick, - dir_exists, - host_log_size, - agent_sock_exists, - "bill diag-watch: polling" - ); - // First few hundred bytes of host.log when it lands — gives us - // immediate visibility into whether the child msb is alive. - if dir_exists && host_log_size > 0 && !head_logged { - if let Ok(content) = std::fs::read_to_string(&host_log) { - let head: String = content.chars().take(800).collect(); - tracing::info!(name = %watch_name, head = %head, "bill diag-watch: host.log head"); - head_logged = true; - } - } - } - }); - - let create_started = std::time::Instant::now(); - let sandbox = match builder.create().await { - Ok(sb) => { - diag!("builder.create().await RETURNED OK", create_ms = create_started.elapsed().as_millis() as u64); - watch_stop.store(true, std::sync::atomic::Ordering::SeqCst); - let _ = watch_handle.await; - sb - } - Err(e) => { - diag!("builder.create().await RETURNED ERR", create_ms = create_started.elapsed().as_millis() as u64, error = %e); - watch_stop.store(true, std::sync::atomic::Ordering::SeqCst); - let _ = watch_handle.await; - return Err(anyhow::anyhow!(e).context(format!("bill: failed to create microVM '{}' from image '{}'", spec.name, spec.image))); - } - }; - - // pearl th-dd0cef: microsandbox 0.3 `builder.create()` boots the VM - // (kernel + agentd) but does NOT launch the image's ENTRYPOINT/CMD. - // The docstring on `Sandbox::create` says it plainly: "Boots the VM - // with agentd ready to accept commands. Does not run any user - // workload — use `exec()`, `shell()`, etc. afterward." - // - // Without this step, port 4400 has no listener inside the guest, so - // host:4400 accepts the TCP connection (microsandbox's relay sees - // the SYN) and immediately disconnects with "Empty reply from - // server". guest.log stays 0 bytes because nothing in the guest - // ever writes to stderr. - // - // We resolve the entrypoint+cmd from `sandbox.config()` (already - // merged with the image defaults inside `create_with_mode`) and - // fire-and-forget an `exec_stream`. Dropping the `ExecHandle` does - // NOT signal the underlying process (no `impl Drop` on - // `ExecHandle`), so the process keeps running for the VM's lifetime. - { - let cfg = sandbox.config(); - let entrypoint: Vec = cfg.entrypoint.clone().unwrap_or_default(); - let cmd: Vec = cfg.cmd.clone().unwrap_or_default(); - let argv: Vec = entrypoint.into_iter().chain(cmd.into_iter()).collect(); - if argv.is_empty() { - diag!("no entrypoint/cmd in image config — skipping startup exec (caller will exec on demand)"); - } else { - diag!("launching image entrypoint via exec_stream", argv = ?argv); - // Safe: `argv.is_empty()` short-circuits on the branch above. - let Some((exec_cmd, exec_args)) = argv.split_first() else { - unreachable!("argv.is_empty() handled above"); - }; - let mut handle = sandbox.exec_stream(exec_cmd.clone(), exec_args.to_vec()).await.with_context(|| { - format!( - "bill: failed to launch entrypoint inside '{}'; sandbox is running but the image's ENTRYPOINT/CMD never started", - spec.name - ) - })?; - diag!("entrypoint exec_stream dispatched, awaiting Started event"); - - // Block until the guest reports a PID for the entrypoint. - // Without this, the calling host process (e.g. `th up - // --sandboxed`) can exit BEFORE the exec request has - // traversed the UDS to agentd, which means the entrypoint - // never runs at all. Pearl th-dd0cef: the visible symptom - // is host:port accepts the TCP SYN (microsandbox's - // built-in port forwarder is set up at boot) but the - // connection closes empty because no guest process is - // bound to the port. Bounded by a 30s timeout so a - // misbehaving agentd doesn't wedge the caller. - use microsandbox::sandbox::exec::ExecEvent; - let start_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); - loop { - let remaining = start_deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - diag!("timed out waiting for entrypoint Started event after 30s"); - return Err(anyhow::anyhow!( - "bill: entrypoint exec did not produce a Started event within 30s in sandbox '{}'", - spec.name - )); - } - match tokio::time::timeout(remaining, handle.recv()).await { - Ok(Some(ExecEvent::Started { pid })) => { - tracing::info!(name = %spec.name, pid, "bill: entrypoint started inside guest"); - diag!("entrypoint Started", pid); - break; - } - Ok(Some(ExecEvent::Exited { code })) => { - tracing::error!(name = %spec.name, code, "bill: entrypoint exited before reporting a PID"); - return Err(anyhow::anyhow!( - "bill: entrypoint in sandbox '{}' exited with code {} before reporting Started", - spec.name, - code - )); - } - Ok(Some(_other)) => { - // Stdout/Stderr can arrive before Started in some - // protocol orderings — keep waiting. - } - Ok(None) => { - return Err(anyhow::anyhow!( - "bill: entrypoint exec event stream closed before Started in sandbox '{}'", - spec.name - )); - } - Err(_elapsed) => { - diag!("timed out waiting for entrypoint Started event after 30s"); - return Err(anyhow::anyhow!( - "bill: entrypoint exec did not produce a Started event within 30s in sandbox '{}'", - spec.name - )); - } - } - } - - // Hand the handle off to a background task that drains - // remaining events and logs the eventual exit. Dropping - // the handle without draining would lose visibility into - // exits; ExecHandle has no Drop impl so the underlying - // process keeps running regardless of the receiver's - // fate, but we want logs. - let watch_name = spec.name.clone(); - tokio::spawn(async move { - let mut handle = handle; - loop { - match handle.recv().await { - Some(ExecEvent::Exited { code }) => { - tracing::warn!(name = %watch_name, code, "bill: entrypoint exited inside guest"); - break; - } - Some(ExecEvent::Stdout(bytes)) => { - let s = String::from_utf8_lossy(&bytes); - for line in s.lines() { - if !line.is_empty() { - tracing::info!(name = %watch_name, stream = "stdout", "bill entrypoint: {line}"); - } - } - } - Some(ExecEvent::Stderr(bytes)) => { - let s = String::from_utf8_lossy(&bytes); - for line in s.lines() { - if !line.is_empty() { - tracing::info!(name = %watch_name, stream = "stderr", "bill entrypoint: {line}"); - } - } - } - Some(_) => {} - None => { - tracing::debug!(name = %watch_name, "bill: entrypoint event stream closed"); - break; - } - } - } - }); - } - } - - register(&spec.name, sandbox); - - // For any port with `bind_all: true`, spawn a TCP forwarder on - // 0.0.0.0:port that proxies to 127.0.0.1:port. This makes the - // published port reachable from other microVMs via the host's real - // network IP, working around microsandbox's `127.0.0.1`-only bind. - for port in &resolved_ports { - if port.bind_all { - let hp = port.host_port; - tokio::spawn(async move { - if let Err(e) = run_bind_all_proxy(hp).await { - tracing::warn!(port = hp, error = %e, "bill: bind_all proxy failed"); - } - }); - tracing::info!(host_port = hp, guest_port = port.guest_port, "bill: bind_all proxy started on 0.0.0.0:{hp}"); - } - } - - let created_at = chrono::Utc::now().to_rfc3339(); - tracing::info!(name = %spec.name, "bill: sandbox spawned"); - Ok((spec.name, resolved_ports, created_at)) -} - -/// TCP proxy that re-publishes a `127.0.0.1`-bound port on `0.0.0.0`. -/// -/// microsandbox publishes guest ports on 127.0.0.1 only (hardcoded in the -/// builder). For cross-VM traffic (e.g., an operator's Scribe forwarding -/// logs to the Safehouse's Archivist), the port must also be reachable -/// via the host's real network interface. This tiny proxy accepts -/// connections on `0.0.0.0:` and forwards each one to -/// `127.0.0.1:` via tokio::io::copy_bidirectional. -async fn run_bind_all_proxy(port: u16) -> anyhow::Result<()> { - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; - loop { - let (mut inbound, _) = listener.accept().await?; - tokio::spawn(async move { - match tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await { - Ok(mut outbound) => { - let _ = tokio::io::copy_bidirectional(&mut inbound, &mut outbound).await; - } - Err(e) => { - tracing::debug!(port, error = %e, "bind_all proxy: connect to 127.0.0.1 failed"); - } - } - }); - } -} - -/// Execute a command inside a live sandbox. Blocks until the command exits. -/// -/// # Errors -/// -/// Returns an error if the sandbox is not registered or microsandbox's -/// `exec` call fails (note: non-zero exit is reported in the return tuple, -/// not as an error). -pub async fn exec_sandbox(name: &str, argv: &[String]) -> Result<(String, String, i32)> { - let Some((cmd, args)) = argv.split_first() else { - anyhow::bail!("exec_sandbox: argv is empty"); - }; - let sandbox = lookup(name).ok_or_else(|| anyhow::anyhow!("no sandbox registered under '{name}'"))?; - let cmd_owned: String = cmd.clone(); - let args_owned: Vec = args.to_vec(); - - tracing::info!(sandbox = %name, cmd = %cmd_owned, argc = args_owned.len(), "bill: exec starting"); - - let output = sandbox - .exec(cmd_owned.clone(), args_owned.clone()) - .await - .with_context(|| format!("bill: exec in sandbox '{name}' failed"))?; - let stdout = output.stdout().unwrap_or_default(); - let stderr = output.stderr().unwrap_or_default(); - let code = output.status().code; - - // When the exit is non-zero (or -1 = no exit event seen, likely - // signal or missing executable), log the captured streams so the - // dispatcher and users can tell what happened. Truncate to avoid - // blowing out the log with megabytes of stdout. - if code != 0 { - let stdout_tail: String = stdout.chars().take(2_000).collect(); - let stderr_tail: String = stderr.chars().take(2_000).collect(); - tracing::warn!( - sandbox = %name, - cmd = %cmd, - code, - stdout_len = stdout.len(), - stderr_len = stderr.len(), - stdout_tail = %stdout_tail, - stderr_tail = %stderr_tail, - "bill: exec returned non-zero (code=-1 typically means the process was signaled, killed, or the exec event stream was closed before a proper exit was reported — check that the binary exists at the bind-mount target and is executable inside the guest)" - ); - } else { - tracing::debug!(sandbox = %name, stdout_len = stdout.len(), stderr_len = stderr.len(), "bill: exec ok"); - } - - Ok((stdout, stderr, code)) -} - -/// Destroy a sandbox. Idempotent: `Ok(())` if already gone. -/// -/// # Errors -/// -/// Returns an error if `stop_and_wait` fails and Bill held the sole Arc -/// reference. Otherwise the stop is deferred to the last Arc drop. -pub async fn destroy_sandbox(name: &str) -> Result<()> { - let Some(arc) = unregister(name) else { - tracing::debug!(name = %name, "bill: destroy on unknown sandbox (no-op)"); - return Ok(()); - }; - tracing::info!(name = %name, "bill: destroying sandbox"); - match Arc::try_unwrap(arc) { - Ok(sandbox) => { - sandbox - .stop_and_wait() - .await - .with_context(|| format!("bill: failed to stop sandbox '{name}'"))?; - } - Err(shared) => { - tracing::debug!( - name = %name, - refs = Arc::strong_count(&shared), - "bill: destroy deferred; other arc references exist" - ); - } - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// TCP server -// --------------------------------------------------------------------------- - -/// Bind Bill's TCP listener on `addr` (typically `127.0.0.1:0`). Returns -/// the local `SocketAddr` plus a join handle for the accept loop. -/// -/// # Errors -/// -/// Returns an error if the bind fails. -pub async fn listen(addr: SocketAddr) -> Result<(SocketAddr, tokio::task::JoinHandle<()>)> { - let listener = TcpListener::bind(addr).await.with_context(|| format!("bill: bind {addr}"))?; - let local = listener.local_addr().context("bill: read local addr")?; - tracing::info!(%local, "bill: listening"); - let handle = tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((stream, peer)) => { - tokio::spawn(async move { - if let Err(e) = handle_connection(stream).await { - tracing::warn!(%peer, error = %e, "bill: connection error"); - } - }); - } - Err(e) => { - tracing::error!(error = %e, "bill: accept failed; exiting accept loop"); - break; - } - } - } - }); - Ok((local, handle)) -} - -async fn handle_connection(stream: TcpStream) -> Result<()> { - let (read_half, mut write_half) = stream.into_split(); - let mut reader = BufReader::new(read_half); - let mut line = String::new(); - let n = reader.read_line(&mut line).await.context("bill: read request line")?; - if n == 0 { - return Ok(()); // peer closed immediately - } - let request: BillRequest = match serde_json::from_str(line.trim()) { - Ok(r) => r, - Err(e) => { - let resp = BillResponse::Error { - message: format!("parse request: {e}"), - }; - write_response(&mut write_half, &resp).await?; - return Ok(()); - } - }; - let response = dispatch(request).await; - write_response(&mut write_half, &response).await?; - Ok(()) -} - -async fn write_response(stream: &mut tokio::net::tcp::OwnedWriteHalf, response: &BillResponse) -> Result<()> { - let mut json = serde_json::to_vec(response).context("bill: serialize response")?; - json.push(b'\n'); - stream.write_all(&json).await.context("bill: write response")?; - stream.flush().await.context("bill: flush response")?; - Ok(()) -} - -async fn dispatch(request: BillRequest) -> BillResponse { - match request { - BillRequest::Ping => BillResponse::Pong { - version: env!("CARGO_PKG_VERSION").to_string(), - }, - BillRequest::Spawn { spec } => match spawn_sandbox(spec).await { - Ok((name, host_ports, created_at)) => BillResponse::Spawned { name, host_ports, created_at }, - Err(e) => BillResponse::Error { message: format!("{e:#}") }, - }, - BillRequest::Exec { name, argv } => match exec_sandbox(&name, &argv).await { - Ok((stdout, stderr, exit_code)) => BillResponse::ExecResult { stdout, stderr, exit_code }, - Err(e) => BillResponse::Error { message: format!("{e:#}") }, - }, - BillRequest::Destroy { name } => match destroy_sandbox(&name).await { - Ok(()) => BillResponse::Destroyed, - Err(e) => BillResponse::Error { message: format!("{e:#}") }, - }, - BillRequest::List => BillResponse::SandboxList { names: list() }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sanitize_volume_name_preserves_safe_chars() { - let name = sanitize_volume_name("abc123.-_"); - assert_eq!(name, "smooth-cache-abc123.-_"); - } - - #[test] - fn sanitize_volume_name_replaces_unsafe_chars() { - let name = sanitize_volume_name("proj/with space+slash"); - assert_eq!(name, "smooth-cache-proj_with_space_slash"); - } - - #[test] - fn sanitize_volume_name_strips_leading_non_alnum() { - // Leading symbols get trimmed so the resulting body starts - // alphanumeric (microsandbox's `validate_volume_name` requires it); - // the `smooth-cache-` prefix is still added by us. - let name = sanitize_volume_name("---proj"); - assert_eq!(name, "smooth-cache-proj"); - } - - #[test] - fn sanitize_volume_name_handles_empty_key() { - let name = sanitize_volume_name(""); - assert_eq!(name, "smooth-cache-nokey"); - } - - #[test] - fn sanitize_volume_name_caps_body_length() { - let body = "a".repeat(500); - let name = sanitize_volume_name(&body); - // Body is capped at 100 chars + fixed prefix. - assert_eq!(name.len(), "smooth-cache-".len() + 100); - assert!(name.starts_with("smooth-cache-aaaa")); - } - - #[tokio::test] - async fn ping_roundtrip_over_tcp() { - let (addr, _handle) = listen("127.0.0.1:0".parse().unwrap()).await.expect("bind"); - let client = crate::client::BillClient::new(format!("http://{addr}")); - let version = client.ping().await.expect("ping"); - assert!(!version.is_empty(), "version should be non-empty"); - } - - #[tokio::test] - async fn exec_unknown_sandbox_returns_error_response() { - let (addr, _handle) = listen("127.0.0.1:0".parse().unwrap()).await.expect("bind"); - let client = crate::client::BillClient::new(format!("http://{addr}")); - let err = client.exec("does-not-exist", &["echo".into(), "hi".into()]).await.unwrap_err(); - assert!(err.to_string().contains("no sandbox registered"), "unexpected error: {err}"); - } - - #[tokio::test] - async fn destroy_unknown_sandbox_is_ok() { - let (addr, _handle) = listen("127.0.0.1:0".parse().unwrap()).await.expect("bind"); - let client = crate::client::BillClient::new(format!("http://{addr}")); - client.destroy("does-not-exist").await.expect("destroy should be idempotent"); - } - - #[tokio::test] - async fn list_returns_empty_for_fresh_bill() { - let (addr, _handle) = listen("127.0.0.1:0".parse().unwrap()).await.expect("bind"); - let client = crate::client::BillClient::new(format!("http://{addr}")); - let names = client.list().await.expect("list"); - // Other concurrent tests in this process may have registered sandboxes; - // all we can guarantee is that list() returns something (even if empty). - let _ = names; - } - - #[tokio::test] - async fn spawn_rejects_non_ascii_env_values() { - let spec = SandboxSpec { - name: "ascii-test".into(), - image: "alpine".into(), - cpus: 1, - memory_mb: 256, - env: [("BAD".to_string(), "em\u{2014}dash".to_string())].into(), - mounts: vec![], - ports: vec![], - timeout_seconds: 60, - allow_host_loopback: false, - env_cache_key: None, - use_named_volume_for_cache: false, - secrets: vec![], - }; - let err = spawn_sandbox(spec).await.unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("non-ASCII"), "unexpected error: {msg}"); - } - - #[tokio::test] - async fn spawn_rejects_duplicate_name() { - // We can't actually boot a VM in a unit test, but we can populate - // the registry with a fake entry and verify the duplicate check - // fires before we reach the microsandbox builder. The registry is - // process-global, so use a uniquely named key. - let name = format!("dup-check-{}", uuid::Uuid::new_v4()); - // Inject a stub entry by routing through `register`. Because - // `Sandbox` is not Clone and we can't construct one here, we skip - // that path and instead trust the existing unit tests in - // `smooth-bigsmooth` that exercised the same duplicate-check logic - // before it moved here. This test stays as documentation. - let _ = name; - } -} diff --git a/crates/smooth-cli/Cargo.toml b/crates/smooth-cli/Cargo.toml index 9e65f42e..e1ab09c9 100644 --- a/crates/smooth-cli/Cargo.toml +++ b/crates/smooth-cli/Cargo.toml @@ -12,22 +12,23 @@ name = "th" path = "src/main.rs" [dependencies] -smooth-bench.workspace = true -smooth-bigsmooth.workspace = true -smooth-bootstrap-bill = { workspace = true, default-features = false, features = ["server"] } +# EPIC th-c89c2a: `th daemon …` is a passthrough launcher (src/daemon_launcher.rs) +# that spawns the standalone `smooth-daemon` binary; the bespoke :4400 microVM +# surface (smooth-bigsmooth / -bootstrap-bill / -tunnel + the smooth-bench +# harness) was deleted in the :4400 nuke. smooth-code.workspace = true smooth-diver.workspace = true smooth-pearls.workspace = true smooth-operator.workspace = true +# `th ext` (SEP extension trust store: hash_extension + TrustStore) +smooth-policy.workspace = true # skills discovery + the smooth cast roles (re-homed from the engine at 0.14.0) smooth-cast.workspace = true -smooth-tunnel.workspace = true smooth-api-client.workspace = true # Pearl th-abc4e2: `th admin login` uses the Supabase OAuth flow # from client-shared. The `auth` feature pulls in the oauth + m2m + # storage modules. smooai-client-shared.workspace = true -smooth-web.workspace = true url = "2" axum.workspace = true clap.workspace = true diff --git a/crates/smooth-cli/src/boot_ui.rs b/crates/smooth-cli/src/boot_ui.rs deleted file mode 100644 index 96370840..00000000 --- a/crates/smooth-cli/src/boot_ui.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! Animated boot indicator for `th` cold-start and `th up`. -//! -//! Pearl th-7840d8 — replaces the bare `println!("Starting Smooth...")` -//! cold-boot line (and the matching path inside `th up`) with a -//! per-step spinner cascade so the user can see what's happening -//! while the Safehouse microVM and in-VM cast services come up. -//! -//! Visuals: -//! -//! ```text -//! ✻ Smooth booting -//! ⠋ starting Safehouse microVM… -//! ⠋ cast online (wonk · goalie · narc · scribe · archivist · diver · groove)… -//! ⠋ operative pool warm… -//! ⠋ health check… -//! ``` -//! -//! On success each line flips to a green `✓