diff --git a/Cargo.toml b/Cargo.toml index dc9028773d..89012c3ff0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,10 @@ path = "src/bin/harness_subagent_audit.rs" name = "test-mcp-stub" path = "src/bin/test_mcp_stub.rs" +[[bin]] +name = "openhuman-fleet" +path = "src/bin/fleet.rs" + [lib] name = "openhuman_core" crate-type = ["rlib"] diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index a93d9cf147..60561c02c4 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -205,7 +205,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -216,7 +216,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -250,7 +250,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "x11rb", ] @@ -2093,7 +2093,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2496,7 +2496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4136,7 +4136,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5030,7 +5030,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6727,7 +6727,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7314,7 +7314,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7373,7 +7373,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -8145,7 +8145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8999,7 +8999,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -9160,7 +9160,7 @@ dependencies = [ [[package]] name = "tinyagents" -version = "1.7.1" +version = "1.8.0" dependencies = [ "async-trait", "bytes", @@ -9806,7 +9806,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/docs/plans/pluggable-core/README.md b/docs/plans/pluggable-core/README.md new file mode 100644 index 0000000000..4ad9e6dcc1 --- /dev/null +++ b/docs/plans/pluggable-core/README.md @@ -0,0 +1,237 @@ +# Pluggable Core — `openhuman_core` as an Embeddable Library + +**Status:** In progress — `CoreBuilder`, `CoreRuntime`, `CoreContext`, the +first per-context store plumbing, and the `openhuman-fleet` MVP are implemented +in this branch. Remaining production work is tracked in the phase docs. + +**Goal:** make the Rust core pluggable into arbitrary hosts — the Tauri shell +(today), a plain CLI, a stdio MCP server, cloud/team servers managing many +users, and other programs consuming it as a library — via a first-class +`CoreBuilder` → `CoreRuntime` API instead of the current "library entry is a +CLI arg vector" surface. + +**Why:** teams and programmatic use both need the same thing: the ability to +compose the core's pieces (dispatcher, stores, background services, +transports) differently per host. Today that composition is fixed inside one +700-line function. The client side already solved this problem with the +`CoreTransport` strategy layer (`app/src/services/transport/`); the core side +has no equivalent seam. + +**Anchor precedents:** [`docs/tinyagents-port-plan.md`](../../tinyagents-port-plan.md) +and [`docs/tinycortex-migration-spec.md`](../../tinycortex-migration-spec.md) — +the established "seam + staged migration + drift ledger" doctrine. This plan +restructures the _host_, not the engines: `tinyagents` / `tinycortex` seams +are untouched. + +--- + +## 1. Where we are + +### 1.1 What is already right + +The hard parts of pluggability are, surprisingly, already done: + +| Asset | Where | Why it matters | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| Transport-agnostic dispatch | `ControllerSchema` + `invoke_method` (`src/core/jsonrpc.rs:230`) shared by HTTP `/rpc`, CLI `call`, generic namespace dispatch, and MCP stdio | The _contract_ is host-neutral; only the bootstrap is not | +| Pluggable client | `app/src/services/transport/` — `CoreTransport` interface, `LocalTransport` / `LanHttpTransport` / `TunnelTransport` / `CloudHttpTransport`, `ConnectionProfile`, `TransportManager` | Any backend that answers `POST /rpc` JSON-RPC with a Bearer token is already reachable from every client, including cloud | +| Agent loop as a crate | `tinyagents` (vendored, `vendor/tinyagents`) via the seam `src/openhuman/tinyagents/` (`run_turn_via_tinyagents_shared`) | Programmatic harness use does not require extracting the loop — it's extracted | +| Memory engine as a crate | `tinycortex` via `src/openhuman/tinycortex/` | Same | +| Headless server mode | `openhuman-core run/serve` (`src/core/cli.rs:66`), `--jsonrpc-only`, Bearer token via `OPENHUMAN_CORE_TOKEN` (`src/core/auth.rs:160`) | Cloud deployment of a _single_ core already works | +| Host discrimination | `HostKind { TauriShell, Cli, Docker }` (`src/core/types.rs:167`) threaded into `bootstrap_core_runtime` | The natural seam for the refactor already exists | +| Tools as trait objects | `Box` on `Agent` (`src/openhuman/agent/harness/session/types.rs:31`) | No handler-style fn-pointer problem in the tool layer | + +### 1.2 The coupling points (what blocks embedding) + +| # | Concern | Location | Nature | +| --- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Bootstrap fused to transport | `run_server_inner` (`src/core/jsonrpc.rs:1738`) + `bootstrap_core_runtime` (`:2428`) | One function: controller registration, master key, token seeding, `Config::load_or_init`, ~10 global store inits (`:1807-1892`), port bind, env mutation, router, ready signal, inline `tokio::spawn` of every background service. You cannot get "just the dispatcher" | +| 2 | Handlers are bare `fn` pointers | `ControllerHandler` (`src/core/all.rs:21`) | No captured state possible; every handler reads process globals | +| 3 | Three hand-maintained registries | `src/core/all.rs` — handlers (`:105-344`), schemas (`:375-509`), namespace descriptions (`:530-690`) | Parallel lists in `OnceLock` statics, panic-on-drift validation | +| 4 | Process singletons | `RPC_TOKEN` (`src/core/auth.rs:75`), `GLOBAL_BUS` (`src/core/event_bus/bus.rs:20`), `NativeRegistry` (`src/core/event_bus/native_request.rs:329`), every `*::global::init(workspace_dir)` store | One of each per process | +| 5 | Single active user per process | `~/.openhuman/active_user.toml` (`src/openhuman/config/schema/load_user_state.rs:21`), resolution chain in `dirs.rs:299` | One workspace resolved once; all global stores bound to it | +| 6 | Background services spawned inline | cron (`jsonrpc.rs:~2124`), channels (`:~2162`), heartbeat/subconscious (`:~2083`), update scheduler | Flags exist (`config.cron.enabled`, `OPENHUMAN_DISABLE_CHANNEL_LISTENERS`, `--jsonrpc-only`) but the caller cannot compose a service set | +| 7 | Runtime env mutation | `set_var OPENHUMAN_CORE_RPC_URL` (`jsonrpc.rs:2010`) | Process-global side effect; needed by spawned child tools | +| 8 | `Once`-guarded subscribers | `register_domain_subscribers` (`jsonrpc.rs:2232`, `std::sync::Once`) | Second context in one process cannot re-register | +| 9 | Dead dispatch tier | `src/rpc/dispatch.rs:10` always returns `None` | Removable noise | + +### 1.3 Teams today + +`src/openhuman/team/` is a **pure thin proxy** to `tinyhumansai/backend` +(session JWT via `crate::api::jwt`, URL via +`effective_backend_api_url`, `src/api/config.rs:154`). All membership, roles, +invites, and authorization are enforced server-side. That is the right +division and this plan keeps it: **we make the core _hostable by_ a team +server; we do not reimplement team logic locally.** + +--- + +## 2. Target architecture + +Bootstrap splits into three layers that are fused today: + +```text +CoreBuilder::build() ── layer 1: context init (pure — no + └─ CoreContext sockets, no spawns): config, + stores · event bus · registries workspace, master key, stores, + security policy · approval gate subscribers, policy + +CoreRuntime::serve() ── layer 2: services (each opt-in): + ├─ rpc_http (axum /rpc + Bearer) selected by ServiceSet + ├─ socketio + ├─ cron · channels · heartbeat · update + +runtime.invoke(method, params) ── layer 3: transports are thin +CLI `call` · MCP stdio · HTTP handler adapters over the same dispatch +``` + +### 2.1 Public API (sketch) + +```rust +pub struct ServiceSet { + pub rpc_http: bool, + pub socketio: bool, + pub cron: bool, + pub channels: bool, + pub heartbeat: bool, + pub update_scheduler: bool, +} +impl ServiceSet { + pub fn desktop() -> Self; // everything on (Tauri today) + pub fn headless_api() -> Self; // rpc_http only (cloud single-core) + pub fn none() -> Self; // library / harness-only +} + +pub struct CoreBuilder { /* config, host_kind, token, bind, services */ } +impl CoreBuilder { + pub fn new(host_kind: HostKind) -> Self; + pub fn token(self, t: TokenSource) -> Self; // Fixed | EnvOrFile + pub fn services(self, set: ServiceSet) -> Self; + pub async fn build(self) -> anyhow::Result; // init only, no spawns +} + +pub struct CoreRuntime { /* Arc, CancellationToken, bound_addr */ } +impl CoreRuntime { + pub async fn serve(&self, ready: Option>, shutdown: Option) + -> anyhow::Result<()>; // spawn selected services + pub async fn invoke(&self, method: &str, params: Map) + -> Result; // same path as /rpc + pub fn events(&self) -> broadcast::Receiver; + pub fn agent_runtime(&self) -> AgentRuntime; // harness-only slice + pub async fn shutdown(self) -> anyhow::Result<()>; +} +``` + +`src/lib.rs` re-exports `CoreBuilder`, `CoreRuntime`, `ServiceSet`, +`HostKind`, `AgentRuntime` as the documented public surface; +`run_core_from_args` remains for the binary. + +### 2.2 Existing entry points become thin consumers + +| Entry | Today | After | +| --------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `run_server` (`jsonrpc.rs:1682`) | wraps `run_server_inner` | shim over `CoreBuilder` (deprecated, kept one release) | +| `run_server_embedded_with_ready` (`:1717`) | same | Tauri `core_process.rs` calls `CoreBuilder` directly: `TokenSource::Fixed(in-memory)`, `ServiceSet::desktop()`; readiness is passed to `CoreRuntime::serve` | +| CLI `call` / namespace dispatch (`src/core/cli.rs`) | `invoke_method(default_state(), …)` | `ServiceSet::none()` build → `runtime.invoke()` — no port bound for one-shot calls | +| MCP stdio (`src/core/cli.rs` `mcp`) | same funnel | transport adapter over `runtime.invoke()` | +| Cloud/team host | n/a | fleet supervisor composing one core per user (phase 4) | + +### 2.3 Multi-tenancy: fleet of processes first + +**Decision: one supervised core process per user/workspace first — not +in-process multi-tenancy.** Rationale: + +- It is the shape the architecture already has (Tauri = one embedded core for + one user); a supervisor reuses it with zero correctness risk. +- The blockers to in-process tenancy are real and slow: env-var mutation for + child tools (#7), keyring/master-key process scope, `Once`-guarded + subscribers (#8), Sentry. Separate processes improve blast-radius and + lifecycle control, but production multi-tenant security still requires + distinct OS users or containers because agents run arbitrary tools. +- The wire contract is unchanged, so `CloudHttpTransport` works as-is against + a per-user base URL. + +In-process multi-workspace (a `CoreContext` per tenant) is deferred behind +phase 3 and is an optimization, not a prerequisite, for the team story. + +### 2.4 Globals strategy: staged, bounded + +Full DI through ~110 handler registration sites in one PR is unreviewable. +Three stages (detail in [phase-2](phase-2-corecontext.md) / +[phase-3](phase-3-multi-context.md)): + +- **Stage A (facade):** `CoreContext` _owns initialization order_ and hands + out handles; existing `*::global::init` calls move inside it; handlers keep + reading globals. Zero behavior change. +- **Stage B (mechanical):** registered RPC dispatch installs an ambient + task-local `CoreContext` while handlers remain plain `fn` pointers. Domains + then migrate off globals opportunistically by reading `CoreContext::current()`, + tracked in a drift ledger. +- **Stage C (bounded):** only what multi-context isolation actually needs. + **Exit criterion: two `CoreContext`s in one test process serve + memory/people/config reads without cross-talk — not "zero `OnceLock`s".** + Keyring, Sentry, `NativeRegistry`, and env vars stay process-scoped and are + documented as such. + +### 2.5 Storage abstraction + +Backend-level pluggability also requires the _stores_ to be swappable, not +just the transports. Today every store is constructed from a workspace +directory (`*::global::init(workspace_dir)`) and is concretely +SQLite/JSON-on-disk under `~/.openhuman/users//workspace`. That is +invisible in the desktop app but load-bearing for other backends: + +- **Fleet hosting (phase 4)** works _without_ abstraction — each per-user core + process gets its own workspace volume. This is why storage abstraction is + not on the phase-4 critical path. +- **Managed/cloud storage** (Postgres, object store for attachments, + shared-nothing replicas) and **in-process multi-tenancy** both require + stores behind traits. + +The seam is the `CoreContext` handle layer introduced in Stage A/B: handlers +stop touching `*::global()` and go through `ctx.()`. Those accessors +return **trait objects** (`Arc`, `Arc`, …) +rather than concrete types, and `CoreBuilder` gains a `StorageBackend` +selector whose only shipped implementation is `WorkspaceFs` (the current +behavior, byte-for-byte). Store traits are carved per domain _as that domain +migrates onto the context_ (phase 2 drift ledger gets a "trait extracted?" +column) — not as one big up-front storage rewrite. Note the engines already +model this: `tinyagents` has `StoreRegistry`/checkpointer abstractions and +`tinycortex` owns its own store interfaces; the traits here cover the +_host-owned_ stores (people, attachments, cost/x402 ledgers, config state, +run ledgers) that sit outside those crates. + +Remote implementations (e.g. `Postgres`) are explicitly out of scope for this +plan; the deliverable is that adding one is a new `StorageBackend` impl, not +a refactor. + +--- + +## 3. Non-goals + +- **Wire contract**: `POST /rpc` JSON-RPC, `openhuman._` naming, + Bearer auth — unchanged. Client transports untouched. +- **tinyagents / tinycortex seams**: unchanged; this plan restructures the + host around them. +- **Security policy semantics**: `security::live_policy`, approval gate, + autonomy tiers — unchanged (only _who installs them_ moves into + `CoreBuilder::build`). +- **Local team logic**: membership truth stays in `tinyhumansai/backend`. +- **Not attempted**: replacing axum, changing controller schema format, + in-process tenant _security_ isolation, `inventory`/linkme distributed + registration (explicit registration lists fit this repo's ledger culture). + +## 4. Phases + +| Phase | File | Deliverable | Depends on | +| ----- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| 0 | [phase-0-defusion.md](phase-0-defusion.md) | Delete dead tier-3 shim; extract inline service spawns + store-init block into named fns. Pure motion | — | +| 1 | [phase-1-corebuilder.md](phase-1-corebuilder.md) | `CoreBuilder`/`CoreRuntime`/`ServiceSet`; `run_server*` become shims; Tauri + CLI + MCP ported; embed examples | 0 | +| 2 | [phase-2-corecontext.md](phase-2-corecontext.md) | `CoreContext` Stage A + B; registry collapse to per-domain `DomainRegistration`; store traits + `StorageBackend::WorkspaceFs`; drift ledger | 1 | +| 3 | [phase-3-multi-context.md](phase-3-multi-context.md) | Bounded Stage C; two-context isolation test; process-scoped inventory | 2 | +| 4 | [phase-4-fleet-host.md](phase-4-fleet-host.md) | `openhuman-fleet` supervisor: per-user cores, token minting, `/:user/rpc` proxy, backend membership sync | 1 (not 2/3) | + +**Value ordering:** phases 0+1 alone deliver the headline goal — embeddable +builder, programmatic harness, thin CLI/Tauri. Phase 4 can start immediately +after phase 1, in parallel with 2/3, because the fleet model needs +process-per-user, not context threading. diff --git a/docs/plans/pluggable-core/phase-0-defusion.md b/docs/plans/pluggable-core/phase-0-defusion.md new file mode 100644 index 0000000000..2be55cf0ba --- /dev/null +++ b/docs/plans/pluggable-core/phase-0-defusion.md @@ -0,0 +1,62 @@ +# Phase 0 — De-fusion cleanup + +**Status:** planned. +**Goal:** pure motion inside `run_server_inner` so phase 1 is a composition +change, not a untangling change. No behavior change; verified by diffing +startup log lines. + +## Scope + +### 0.1 Delete the dead dispatch tier + +`src/rpc/dispatch.rs:10-15` always returns `None` (module doc says the +registry is authoritative). Remove the module and its tier-3 call site in +`src/core/dispatch.rs::dispatch`. `src/rpc/` keeps `structured_error.rs` +(still used) — only the dispatch shim dies. + +### 0.2 Extract inline service spawns + +Each inline `tokio::spawn` block in `run_server_inner` becomes a named +function in a new `src/core/runtime/services.rs`: + +| Service | Today (approx.) | Extracted fn | +| --------------------------------------------------------------------------- | ----------------------- | ----------------------------------------- | +| Heartbeat + subconscious bootstrap | `jsonrpc.rs:~2083-2110` | `spawn_heartbeat_service(cancel, config)` | +| Update scheduler | `:~2113` | `spawn_update_scheduler(cancel)` | +| Cron scheduler (+ proactive-agent seeding, flow schedule-trigger reconcile) | `:~2124-2160` | `spawn_cron_service(cancel, config)` | +| Channel listeners | `:~2162-2191` | `spawn_channels_service(cancel, config)` | + +Rules: + +- Every extracted fn takes the `CancellationToken` explicitly (today some + blocks capture it, some don't — normalize). +- Existing gates stay _inside_ the fns for now (`config.cron.enabled`, + `config.heartbeat.enabled`, `OPENHUMAN_DISABLE_CHANNEL_LISTENERS`, + `has_listening_integrations()`), so call sites don't change semantics. + Phase 1 lifts the _selection_ (should this service exist) to `ServiceSet` + while the fns keep their _config_ gates (is it enabled for this user). +- Keep the deliberate asymmetry documented at `jsonrpc.rs:2259-2264`: the + flows trigger subscriber registers at unconditional core boot, not under + channels — that stays in `bootstrap_core_runtime`, not in + `spawn_channels_service`. + +### 0.3 Extract the store-init block + +`jsonrpc.rs:1807-1892` (memory, whatsapp_data, people, attachments global +inits) → `fn init_stores(config: &Config, workspace_dir: &Path) -> +anyhow::Result<()>` in `src/core/runtime/context.rs` (file seeded here, +grows in phase 1/2). Preserve init order exactly; comment each step with the +prior `jsonrpc.rs` line range so the diff is auditable. + +## Out of scope + +- Any signature or behavior change to the spawned services themselves. +- Touching `bootstrap_core_runtime` internals (phase 1 moves the call, phase + 2/3 decompose it). + +## Verification + +- `cargo check` both crates; `pnpm test:rust`. +- Boot the desktop app and `openhuman-core serve`; diff the startup log + sequence (grep-stable prefixes) against `main` — must be identical. +- `rg 'rpc::try_dispatch|rpc/dispatch'` returns nothing. diff --git a/docs/plans/pluggable-core/phase-1-corebuilder.md b/docs/plans/pluggable-core/phase-1-corebuilder.md new file mode 100644 index 0000000000..446f09fa8d --- /dev/null +++ b/docs/plans/pluggable-core/phase-1-corebuilder.md @@ -0,0 +1,99 @@ +# Phase 1 — `CoreBuilder` / `CoreRuntime` / `ServiceSet` + +**Status:** planned. +**Goal:** split bootstrap (context init) from transport (HTTP is just another +service) behind a public builder API; port all existing entry points onto it. +This phase alone delivers the headline goal: embeddable library, programmatic +harness entry, thin CLI/Tauri. + +## New modules + +```text +src/core/runtime/ +├── mod.rs # pub use CoreBuilder, CoreRuntime, ServiceSet, TokenSource +├── builder.rs # CoreBuilder — validation + build() +├── context.rs # CoreContext (Stage A facade — owns init order; see phase 2) +└── services.rs # from phase 0; gains spawn_rpc_http_service, spawn_socketio wiring +``` + +Public API per README §2.1. Additional decisions: + +- **`TokenSource`**: `Fixed(Arc)` (Tauri in-memory handoff), + `EnvOrFile` (read `OPENHUMAN_CORE_TOKEN` when present, otherwise generate + and write the standalone `{root}/core.token` fallback 0600). `build()` seeds + `auth::init_rpc_token*` exactly once, same precedence as today + (`src/core/auth.rs`). +- **`build()` is init-only**: no sockets and no detached jobs for + `ServiceSet::none()` / `ServiceSet::headless_api()`. It runs, in + order: controller registration (`all::all_registered_controllers`), master + key init, token seeding, `Config::load_or_init`, `init_stores` (phase 0), + `bootstrap_core_runtime(host_kind, services)` for pure registration plus + ServiceSet-gated legacy bootstrap jobs. Init-order regressions are the top + risk; add a startup-sequence integration test asserting the order via log + markers. +- **`serve()`** spawns only the services selected by `ServiceSet`. The HTTP + listener (bind, port fallback, router from `build_core_http_router`, + `axum::serve`) moves into `spawn_rpc_http_service`; **the + `set_var OPENHUMAN_CORE_RPC_URL` call keeps its exact timing** (post-bind, + `jsonrpc.rs:2010`) inside that service — child tools depend on it. It is + flagged in the drift ledger as single-runtime-only. +- **Ready signal**: `EmbeddedReadySignal` (port-fallback reporting) is kept + type-identical, relocated; the readiness sender is supplied to + `CoreRuntime::serve` and fires after bind (when `rpc_http` selected). +- **`invoke()`** delegates to the existing `invoke_method` + (`jsonrpc.rs:230`) — same tiered dispatch, no second path. + +## `AgentRuntime` (harness-as-library) + +`CoreRuntime::agent_runtime()` returns the slice needed to run turns with +zero ports bound: + +```rust +pub struct AgentRuntime { ctx: Arc } +impl AgentRuntime { + pub fn agent(&self, sel: AgentSelector) -> anyhow::Result; // Agent::from_config + pub async fn run_turn(&self, agent: &Agent, input: TurnInput) -> …; // tinyagents seam + pub fn events(&self) -> broadcast::Receiver; +} +``` + +Its required bootstrap subset _defines_ what `ServiceSet::none()` must still +initialize: config, workspace, master key, event bus, +`AgentDefinitionRegistry`, memory/tinycortex stores, cost/x402 ledgers, +security live-policy + approval gate, tool registry. Explicitly not required: +HTTP, socket.io, cron, channels, heartbeat, update scheduler. + +**Acceptance test for the whole phase:** an integration test (and +`examples/run_turn.rs`) that builds with `ServiceSet::none()`, runs one agent +turn through `run_turn_via_tinyagents_shared`, and asserts no listener was +bound. + +## Porting the consumers + +| Consumer | Change | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `run_server` / `run_server_embedded*` (`jsonrpc.rs:1682-1737`) | become `#[deprecated]` shims over the builder; deleted one release later | +| Tauri (`app/src-tauri/src/core_process.rs:289`) | `CoreProcessHandle::ensure_running` builds `CoreBuilder::new(HostKind::TauriShell).token(TokenSource::Fixed(..)).services(ServiceSet::desktop())`, then passes readiness to `CoreRuntime::serve`; `CancellationToken` / restart / port-takeover logic unchanged | +| CLI `run`/`serve` (`src/core/cli.rs:66`) | maps flags → `ServiceSet` (`--jsonrpc-only` → `socketio: false`) | +| CLI `call` + namespace dispatch (`cli.rs:354,435`) | `ServiceSet::none()` build → `runtime.invoke()`; one-shot calls stop constructing server state | +| MCP stdio (`cli.rs` `mcp`) | adapter over `runtime.invoke()` | +| `src/lib.rs` | re-export the new surface; keep `run_core_from_args` | + +Plus `examples/embed_headless.rs` (build + `serve()` with +`ServiceSet::headless_api()`, call `openhuman.ping` over HTTP) and +`examples/run_turn.rs` (above). + +## Risks & mitigations + +| Risk | Mitigation | +| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Init-order regression (master key before stores; policy before approval gate) | single `CoreContext::init` encoding the order, line-ref comments, startup-sequence test | +| Tauri port-fallback / ready semantics drift | `EmbeddedReadySignal` unchanged; e2e boot test in CI Full already covers | +| `set_var` timing change breaks child tools | timing preserved inside `spawn_rpc_http_service`; grep test for env presence after ready | +| Double-bootstrap when shims + builder both run in tests | builder guards with the same idempotent `Once`s during this phase (removed in phase 3) | + +## Verification + +- `pnpm test:rust`, `bash scripts/test-rust-with-mock.sh --test json_rpc_e2e`. +- Both examples run green in CI. +- Desktop app boots via ported `core_process.rs` (CI Full e2e matrix). diff --git a/docs/plans/pluggable-core/phase-2-corecontext.md b/docs/plans/pluggable-core/phase-2-corecontext.md new file mode 100644 index 0000000000..77182c7462 --- /dev/null +++ b/docs/plans/pluggable-core/phase-2-corecontext.md @@ -0,0 +1,126 @@ +# Phase 2 — `CoreContext` ownership, ambient context, registry collapse, store traits + +**Status:** Stage A + ambient-context + registry-collapse **DONE**; per-domain +store-trait migration remains. +**Goal:** make state reachable _through_ a context instead of _only_ through +process globals, without a big-bang DI rewrite. + +## 2.a Handler context access — ambient scope, not a signature sweep (Stage B) + +**Implemented (deviation from the original plan, with rationale below):** the +literal signature change to `ControllerHandler` was rejected after measuring its +true cost. `ControllerHandler` fn pointers appear at **846 handler definitions +across 109 files, plus ~381 direct call sites** — a change of that type must +land atomically to compile, needs a default-context `OnceLock` with fallbacks +for the many callers that never build a runtime (CLI one-shots, tests, MCP +dispatch), and yields **zero** functional value until each domain later reads +`ctx` (Stage C). That is disproportionate churn and a high risk of a +non-compiling tree. + +Instead, the **goal** — every handler can reach the `CoreContext` for the +current dispatch — is delivered with a `tokio::task_local!` ambient context: + +- `CoreContext::init` registers the first-built context as the process + `DEFAULT_CONTEXT` (`OnceLock>`). +- The dispatch chokepoint `all::try_invoke_registered_rpc` wraps each handler + future in `CoreContext::scope(ctx, fut)`, where `ctx` is + `CoreContext::current()` — the active scope if any (so nested dispatches stay + in the same tenant context), else the default. +- Handlers reach it via `CoreContext::current() -> Option>`. + Controller handlers stay **bare `fn` pointers** — zero per-handler churn. +- A domain migrates off a process global by reading its store handle from + `CoreContext::current()` instead. Once its state lives on the context, two + contexts dispatched under distinct `CoreContext::scope`s read isolated state — + exactly the Phase 3 exit criterion, verified by the unit tests in + `src/core/runtime/context.rs` (`scope_sets_current_context`, + `nested_scope_overrides_then_restores`). + +One implementation note: the extra future layer at the chokepoint pushed the +`Send` auto-trait solver past the default depth on the deepest axum→tinyagents +routes; the scoped future is re-boxed into a `ControllerFuture` and the crate +sets `#![recursion_limit = "256"]` (both in `src/lib.rs` / `src/core/all.rs`). + +This is strictly a better realization of Stage B's intent; the explicit-param +approach is not planned. + +## 2.b Registry collapse + +Replace the three hand-maintained parallel lists in `src/core/all.rs` +(handlers `:105-344`, schemas `:375-509`, namespace descriptions `:530-690`) +with one per-domain struct: + +```rust +pub struct DomainRegistration { + pub namespace: &'static str, + pub description: &'static str, + pub controllers: Vec, // schema + handler already paired + pub cli: Option, // absorbs CLI_ADAPTERS (all.rs:88) +} +fn all_domains() -> Vec { vec![about_app::domain(), /* … one line per domain … */] } +``` + +**Implemented (partial — the drift-elimination half):** + +- `all_controller_schemas()` now **derives** the schema list from the + registered controllers (`registry().iter().map(|c| c.schema.clone())`), so the + parallel `build_declared_controller_schemas()` list is deleted and + `validate_registry`'s declared-vs-registered cross-check is removed — + the two lists can no longer drift. `validate_registry` keeps the + duplicate-method / empty-namespace / duplicate-required-input checks on the + registered set. Obsolete drift unit tests were removed. +- **Explicitly rejected:** `inventory`/linkme link-section auto-registration. + +**Deferred (cosmetic, no correctness value):** folding the per-domain +`all_X_controller_schemas()` fns and the `namespace_description` match into a +single `DomainRegistration` struct. The per-domain schema fns still exist but +are no longer aggregated centrally; collapsing them further is churn across 109 +domains for no behavior change, tracked as a follow-up. + +## 2.c Per-domain migration + store traits + +Domains migrate `_ctx` → real context usage opportunistically; **required** +(because multi-context isolation in phase 3 needs them) for: `memory` +(tinycortex seam handle), `people`, `attachments`, `config`. Everything else +migrates when touched. + +Storage abstraction lands here, at the handle boundary: + +```rust +impl CoreContext { + pub fn people(&self) -> Arc; + pub fn attachments(&self) -> Arc; + // … per-domain accessors added as domains migrate +} + +pub enum StorageBackend { WorkspaceFs } // CoreBuilder::storage(..); only impl for now +``` + +- Traits are carved **per domain as it migrates** (drift-ledger column + "store trait extracted?"), never as one up-front storage rewrite. +- `WorkspaceFs` wraps the existing SQLite/JSON-on-disk stores byte-for-byte; + the global facades (`*::global()`) delegate to the default context's + handles so both paths hit the same instance. +- Host-owned stores only (people, attachments, cost/x402 ledgers, config + state, run ledgers). `tinyagents` (`StoreRegistry`, checkpointers) and + `tinycortex` already own their internal store interfaces — the context + holds _handles to_ those seams, it does not re-abstract them. +- Adding a remote backend (e.g. Postgres) must become "new `StorageBackend` + impl", but shipping one is out of scope. + +## Risks & mitigations + +| Risk | Mitigation | +| ---------------------------------------- | -------------------------------------------------------------------------------------- | +| Sweep-size diff unreviewable | three PR series: signature-only → registry collapse → per-domain | +| Global facade and context handle diverge | facade delegates to default context (single instance); parity asserts in debug builds | +| Store trait too narrow, churns later | extract traits from _observed_ handler usage per domain, not speculatively | +| Coverage gate on mechanical diffs | signature/registry PRs carry existing tests; per-domain PRs add store-trait unit tests | + +## Verification + +- `pnpm test:rust` + `json_rpc_e2e` green after each sub-series. +- Drift ledger lists every domain with columns: signature migrated / context + usage / store trait / notes. +- `all::try_invoke_registered_rpc` installs an ambient `CoreContext::scope` + around registered handler futures, and tests verify `CoreContext::current()` + propagation through registered RPC invocation. diff --git a/docs/plans/pluggable-core/phase-3-multi-context.md b/docs/plans/pluggable-core/phase-3-multi-context.md new file mode 100644 index 0000000000..c52e7c4873 --- /dev/null +++ b/docs/plans/pluggable-core/phase-3-multi-context.md @@ -0,0 +1,75 @@ +# Phase 3 — Bounded multi-context readiness (Stage C) + +**Status:** exit criterion **demonstrated for the first domain** (`people`); +remaining domains + `RPC_TOKEN`/subscriber relocation are follow-ups. +**Goal:** two `CoreContext`s in one test process serve memory/people/config +reads without cross-talk. That sentence is the exit criterion — **not** "zero +`OnceLock`s". Anything not needed for it stays process-scoped and gets +documented instead of refactored. + +## Delivered + +- **Isolation primitive** (Phase 2): `CoreContext::scope` / `current` + + `DEFAULT_CONTEXT`, scoped at the dispatch chokepoint. Unit-tested + (`scope_sets_current_context`, `nested_scope_overrides_then_restores`). +- **First per-context store**: `people::store::for_workspace(dir)` opens/caches a + store per workspace, and `CoreContext::people()` resolves it for the context's + workspace — additive alongside the legacy `people::store::get()` global, so the + ~40 existing people handlers are untouched and migrate to + `CoreContext::current()?.people()` incrementally. +- **Exit test (people)**: `people_store_is_isolated_per_context_workspace` — two + contexts over distinct workspaces resolve isolated stores; one context always + resolves the same cached store. This is the exit criterion, realized for the + first migrated domain. + +## Remaining (follow-ups) + +- Repeat the `for_workspace` + `CoreContext::()` pattern for `memory`, + `config`, `attachments`, then migrate their handlers to read through + `CoreContext::current()`. +- Move `RPC_TOKEN` onto the context and make event-bus subscriber registration + per-context (below) — only needed once a host actually runs >1 context in a + process (the fleet uses process-per-user, so this is not on its path). + +## Scope + +### 3.1 Per-context state + +| Item | Today | After | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| RPC bearer | `RPC_TOKEN: OnceLock` (`src/core/auth.rs:75`) | field on `CoreContext` — it gates a per-runtime HTTP listener; `auth::get_rpc_token` facade reads the default context | +| Workspace / active user | resolved once from `OPENHUMAN_WORKSPACE` → `active_user.toml` → `"local"` (`config/schema/load/dirs.rs:299`, `load_user_state.rs:21`) | resolution runs in `CoreBuilder::build` and the result is a `CoreContext` field; the marker-file chain remains the _default_ when the embedder passes no workspace | +| Event-bus domain subscribers | `register_domain_subscribers` under `std::sync::Once` (`src/core/jsonrpc.rs:2232`) | registration keyed per context (subscription handles owned by `CoreContext`, dropped on shutdown); process-level dedupe kept only for genuinely process-global targets | +| Stores migrated in phase 2.c | global facade → default context | second context constructs its own `StorageBackend::WorkspaceFs` instance over its own workspace dir | + +### 3.2 Permanently process-scoped (documented, not refactored) + +| Item | Why it stays global | +| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Keyring / master key (`keyring::init_master_key`, `src/lib.rs`) | OS keychain is per-process/user by nature | +| Sentry (`src/main.rs`) | binary concern, not library concern — embedders bring their own | +| `NativeRegistry` (`event_bus/native_request.rs:329`) | internal typed dispatch; handlers are stateless routers to context-owned state | +| Env vars (`OPENHUMAN_CORE_RPC_URL` set_var, `jsonrpc.rs:2010`; `OPENHUMAN_WORKSPACE` reads) | child-process contract; this is exactly why multi-_tenant_ hosting is process-per-user (phase 4), and it is documented as a single-runtime-per-process constraint | + +This table ships in the README of this plan (or the drift ledger) as the +authoritative "what is process-scoped and why" inventory for embedders. + +## Exit test + +`tests/` integration test: construct two `CoreContext`s over two temp +workspaces in one process (`ServiceSet::none()`), write a person + a config +value + a memory item through context A, assert context B sees none of them +and vice versa; both contexts shut down cleanly (subscription handles +dropped, no `Once` poisoning). + +Desktop path must be bit-identical: the single-context Tauri/CLI flow never +constructs a second context; the test-only second context is the only +consumer until in-process multi-workspace is deliberately productized. + +## Risks & mitigations + +| Risk | Mitigation | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Double-firing subscribers when two contexts register | subscription handles owned per context; bus events carry context/workspace identity where a handler writes to stores | +| Hidden global discovered late (some `*::global()` not in the phase-2 ledger) | the exit test is the detector; fix-forward per store, ledger updated | +| `Once` removal regresses single-context boot | keep `Once` semantics for the default context; per-context path only activates for non-default contexts | diff --git a/docs/plans/pluggable-core/phase-4-fleet-host.md b/docs/plans/pluggable-core/phase-4-fleet-host.md new file mode 100644 index 0000000000..e59fe62c0d --- /dev/null +++ b/docs/plans/pluggable-core/phase-4-fleet-host.md @@ -0,0 +1,124 @@ +# Phase 4 — Fleet supervisor: team/cloud hosting + +**Status:** MVP **DONE** — `openhuman-fleet` binary (`src/bin/fleet.rs`, +`[[bin]] name = "openhuman-fleet"`). Remaining: backend membership sync, +ready-file port discovery, admin API for edge tokens (see "MVP vs production"). + +**Goal:** a supervisor (`openhuman-fleet`) that hosts one core per team member +and fronts them behind a single endpoint, so a team admin can +provision/manage members' assistants while every existing client +(`CloudHttpTransport`) keeps working unchanged. + +## Delivered (MVP) + +`src/bin/fleet.rs` — a self-contained binary (separate compile target, zero +weight on the shipped desktop/lib build, matching the `slack-backfill` bin +pattern): + +- **Process-per-tenant MVP**: spawns `openhuman-core run --jsonrpc-only` per tenant + with a per-user `OPENHUMAN_WORKSPACE`, a minted `OPENHUMAN_CORE_TOKEN`, and + `OPENHUMAN_DISABLE_CHANNEL_LISTENERS=1` (this is the `ServiceSet::headless_api` + shape from Phase 1). Before keeping a tenant registered, probes the assigned + port through authenticated JSON-RPC with that tenant's core bearer; stale or + fallback ports fail closed. Production multi-tenant security still requires + distinct OS users or containers. +- **Reverse proxy**: axum `POST /{user_id}/rpc` forwards the JSON-RPC body + verbatim to that tenant's core, swapping the client's **edge token** for the + tenant's **core bearer** — the wire contract is unchanged end to end, so + `CloudHttpTransport` works against `http:////rpc`. +- **Edge auth / isolation**: distinct `EdgeToken` (client-facing) vs + `CoreBearer` (fleet-only) types; the proxy rejects a token whose user does not + match the path segment, so tenant A cannot reach tenant B's core. User ids are + validated as single `[A-Za-z0-9_-]` segments (no path escape). +- **Tests**: pure logic unit-tested (port assignment + overflow, user-scoped + workspace derivation, user-id validation, provisioning distinct + ports/bearers/edge-tokens, edge-token→user round-trip, bearer parsing). + +### MVP vs production (tracked follow-ups, logged not silently dropped) + +- Ports are assigned sequentially from `--base-core-port`; production should read + each core's actually-bound port from a ready file / `EmbeddedReadySignal` + (Phase 1). The MVP checks loopback availability before spawn and requires an + authenticated JSON-RPC readiness probe on the assigned port before keeping the + tenant registered. +- Tenants come from `--users`; production reconciles membership against + `tinyhumansai/backend` on a loop (same pattern as the cron scheduler). +- Minted edge tokens are never printed to stdout. The MVP can write them to a + restricted operator-selected file with required `--edge-token-output`; production + should expose them through an authenticated admin API. +- Container packaging (`HostKind::Docker`) and restart/backoff are not yet wired. + +## Decision recap (README §2.3) + +Process-per-tenant, not in-process multi-tenancy: it is the shape the +architecture already has (Tauri = one core per user), it sidesteps the +process-scoped items inventoried in phase 3 (env mutation for child tools, +keyring, Sentry), and it keeps the production security boundary explicit: +agents execute arbitrary tools, so real tenant separation must come from +distinct OS users or containers, not from a same-user supervisor process. + +## Architecture + +```text +client (CloudHttpTransport, per-user base URL + Bearer) + │ + ▼ +openhuman-fleet supervisor + ├─ edge auth: mint/validate per-tenant session tokens + ├─ reverse proxy /:user_id/rpc → 127.0.0.1: + ├─ membership sync ⇄ tinyhumansai/backend (teams stay backend-truth) + └─ lifecycle manager + └─ per user: provision workspace volume → + run core (child process or container, HostKind::Docker) with + TokenSource::Fixed(per-tenant token), bind 127.0.0.1:0, + ServiceSet::headless_api() + per-plan opt-ins (cron, heartbeat) +``` + +Key properties: + +- **Wire contract unchanged** inside and out — the proxy strips + `/:user_id` and forwards `POST /rpc` verbatim; `CloudHttpTransport` + (`app/src/services/transport/CloudHttpTransport.ts`) already speaks this + with a profile Bearer token and per-profile `rpcUrl`. +- **Port arbitration**: cores bind `:0`; supervisor reads the bound port from + the existing `EmbeddedReadySignal` (child-process variant: ready line on + stdout or a ready file — decide during implementation). +- **Teams**: membership/roles/invites remain in `tinyhumansai/backend` + (`src/openhuman/team/` proxy untouched). The supervisor consumes the same + backend API to decide _which_ cores exist and who may reach them; it adds + hosting, not authorization semantics. +- **Secrets**: per-tenant token + per-tenant keyfile/env in the child's + environment — no shared keyring across tenants (phase 3 inventory item). +- **Storage**: per-user workspace volume (`StorageBackend::WorkspaceFs`). + This is why storage abstraction (phase 2.c) is off this phase's critical + path; a managed-DB backend would slot in later as a new `StorageBackend`. +- **Health/restart**: existing health controllers over each core's `/rpc`; + restart backoff via the existing `apply_startup_restart_delay_from_env` + seam; container resource limits are the v1 noisy-neighbor answer. + +## Scope + +1. `openhuman-fleet` crate: tenant registry (backed by backend membership), + lifecycle manager, token mint/validate, reverse proxy, health loop. +2. Container image + deployment doc for `HostKind::Docker` cores + (`ServiceSet::headless_api()`). +3. Embedder guide in `gitbooks/developing/` covering all four consumption + modes: Tauri embed, CLI one-shot, library (`AgentRuntime`), fleet. +4. E2E: supervisor + 2 tenants + `CloudHttpTransport`-shaped client script; + assert tenant A's token cannot reach tenant B's core. + +## Risks & mitigations + +| Risk | Mitigation | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Token confusion between edge session tokens and core bearer tokens | supervisor is the only holder of core bearers; clients only ever see edge tokens; naming kept distinct in code (`EdgeToken` vs `CoreBearer`) | +| Resource blow-up (N cores × background services) | default `ServiceSet::headless_api()`; cron/heartbeat opt-in per plan tier | +| Backend membership drift vs running fleet | reconcile loop (same pattern as cron scheduler); deprovision = stop core, retain volume per retention policy | +| Supervisor as SPOF | stateless proxy + externalized tenant registry; ops concern, out of scope for v1 doc beyond noting it | + +## Out of scope (v1) + +- In-process multi-workspace hosting (deferred behind phase 3; optimization + for read-mostly tenants, never a security boundary). +- Managed-DB storage backend (needs phase 2.c traits; separate plan). +- Autoscaling / placement across machines. diff --git a/examples/embed_headless.rs b/examples/embed_headless.rs new file mode 100644 index 0000000000..b960d9856f --- /dev/null +++ b/examples/embed_headless.rs @@ -0,0 +1,51 @@ +//! Embed the OpenHuman core as a library — no HTTP, no background services. +//! +//! Demonstrates the Phase-1 pluggable-core API: build a fully-initialized core +//! with [`ServiceSet::none`] (no ports bound, no cron/channels/heartbeat) and +//! dispatch RPC methods in-process through [`CoreRuntime::invoke`] — the exact +//! same path the HTTP `/rpc` handler and the CLI use. +//! +//! Run with: +//! +//! ```bash +//! GGML_NATIVE=OFF cargo run --example embed_headless +//! ``` +//! +//! To instead expose the core over HTTP for a single-core cloud deployment, +//! swap `ServiceSet::none()` for `ServiceSet::headless_api()`, keep a +//! `CancellationToken`, and call `runtime.serve(None, Some(token)).await` — it +//! binds `127.0.0.1:7788` (override with `.host(..)` / `.port(..)` on the +//! builder, or `OPENHUMAN_CORE_HOST` / `OPENHUMAN_CORE_PORT`) and serves until +//! the token is cancelled. + +use openhuman_core::{CoreBuilder, HostKind, ServiceSet}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Library embedders own logging; a simple env_logger keeps the example + // self-contained. (`RUST_LOG=info cargo run --example embed_headless`) + let _ = env_logger::builder().is_test(false).try_init(); + + // Initialize the core against the local workspace. `HostKind::Cli` selects + // the standalone (non-desktop) bootstrap path; `ServiceSet::none()` means no + // transport and no background services are started. + let runtime = CoreBuilder::new(HostKind::Cli) + .services(ServiceSet::none()) + .build() + .await?; + + // Dispatch a couple of RPC methods in-process — no network involved. + let version = runtime + .invoke("core.version", serde_json::json!({})) + .await + .map_err(|e| anyhow::anyhow!("core.version failed: {e}"))?; + println!("core.version -> {version}"); + + let ping = runtime + .invoke("openhuman.ping", serde_json::json!({})) + .await + .map_err(|e| anyhow::anyhow!("openhuman.ping failed: {e}"))?; + println!("openhuman.ping -> {ping}"); + + Ok(()) +} diff --git a/src/bin/fleet.rs b/src/bin/fleet.rs new file mode 100644 index 0000000000..d36881fe46 --- /dev/null +++ b/src/bin/fleet.rs @@ -0,0 +1,987 @@ +//! `openhuman-fleet` — a process-per-user supervisor + reverse proxy. +//! +//! Hosts one `openhuman-core` process per user/workspace and fronts them behind +//! a single endpoint so a team server can manage many members' assistants while +//! every existing client (`CloudHttpTransport`) keeps working unchanged. This is +//! Phase 4 of the pluggable-core plan (`docs/plans/pluggable-core/phase-4-fleet-host.md`). +//! +//! Design (process-per-user, not in-process multi-tenancy): +//! - Each tenant gets its own OS process (`openhuman-core run --headless-api`), +//! its own workspace volume (`OPENHUMAN_WORKSPACE`), and its own core bearer +//! (`OPENHUMAN_CORE_TOKEN`). This MVP does not yet run tenants under +//! distinct OS users or containers, so it is not a production multi-tenant +//! security boundary for arbitrary agent tools. +//! - The supervisor mints a distinct **edge token** per tenant for clients; it +//! is the only holder of the tenants' **core bearers**. `EdgeToken` and +//! `CoreBearer` are kept deliberately distinct so they cannot be confused. +//! - The reverse proxy forwards `POST /{user_id}/rpc` verbatim to that tenant's +//! core `http://127.0.0.1:/rpc`, so the JSON-RPC wire contract is +//! unchanged end to end. +//! +//! MVP scope: explicit sequential port assignment with an authenticated JSON-RPC +//! readiness probe before registration (a production supervisor would read each +//! core's bound port from a ready file / `EmbeddedReadySignal` and reconcile +//! membership against `tinyhumansai/backend`). Limitations are logged, never +//! silently swallowed. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::Context as _; +use axum::{ + body::Bytes, + extract::Request, + extract::{DefaultBodyLimit, Path as AxumPath, State}, + http::{header, HeaderMap, HeaderValue, Method, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::post, + Router, +}; +use clap::Parser; +use tokio::sync::RwLock; + +// --------------------------------------------------------------------------- +// Edge auth — maps opaque client-facing tokens to a user id. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct EdgeToken(String); + +impl EdgeToken { + fn new(value: impl Into) -> Self { + Self(value.into()) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CoreBearer(String); + +impl CoreBearer { + fn new(value: impl Into) -> Self { + Self(value.into()) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +mod edge_auth { + use super::EdgeToken; + use std::collections::HashMap; + + /// Maps opaque, client-facing **edge tokens** to the user id they authorize. + /// The fleet never hands a tenant's core bearer to a client — clients only + /// ever see an edge token, which the proxy exchanges for the core bearer. + #[derive(Default)] + pub struct EdgeAuth { + tokens: HashMap, + } + + impl EdgeAuth { + pub fn new() -> Self { + Self::default() + } + + /// Mint an edge token authorizing `user_id`. Deterministic prefix + + /// caller-supplied unique suffix (a UUID at the call site) so this stays + /// pure and unit-testable. + pub fn insert(&mut self, token: EdgeToken, user_id: impl Into) { + self.tokens.insert(token, user_id.into()); + } + + /// The user id an edge token authorizes, if any. + pub fn user_for(&self, token: &EdgeToken) -> Option<&str> { + self.tokens.get(token).map(String::as_str) + } + + pub fn remove_user(&mut self, user_id: &str) { + self.tokens.retain(|_, mapped_user| mapped_user != user_id); + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self.tokens.len() + } + } +} + +use edge_auth::EdgeAuth; + +/// Keep the fleet proxy's `/rpc` request-body contract aligned with the core +/// server. Chat image attachments are base64-inlined into JSON-RPC bodies, and +/// direct core `/rpc` accepts up to 64 MiB for that path. +const MAX_RPC_BODY_BYTES: usize = 64 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// Tenant registry — pure derivation of per-user port / workspace / rpc url. +// --------------------------------------------------------------------------- + +/// A provisioned tenant core: where it listens and the bearer to reach it. +#[derive(Debug, Clone)] +struct CoreInstance { + user_id: String, + port: u16, + core_bearer: CoreBearer, + workspace_dir: PathBuf, + action_dir: PathBuf, +} + +impl CoreInstance { + /// The loopback RPC URL the proxy forwards to. + fn rpc_url(&self) -> String { + format!("http://127.0.0.1:{}/rpc", self.port) + } +} + +/// Pure port assignment: tenant `index` (0-based) maps to `base_port + index`. +/// Kept a free function so it is trivially unit-testable and the policy is +/// obvious at the call site. +fn port_for_index(base_port: u16, index: usize) -> Option { + u16::try_from(base_port as usize + index).ok() +} + +/// Pure workspace derivation: `/`. The caller is responsible for +/// having validated `user_id` (see [`is_valid_user_id`]). +fn workspace_for(root: &Path, user_id: &str) -> PathBuf { + root.join(user_id) +} + +/// Pure action sandbox derivation: `/.tenant-action-dirs/`. +/// Keep this outside `workspace_dir`; core policy treats the workspace tree as +/// internal state and blocks acting tools there. +fn action_dir_for(root: &Path, user_id: &str) -> PathBuf { + root.join(".tenant-action-dirs").join(user_id) +} + +/// A user id must be a single safe path segment — no separators, no `..`, non +/// empty — so it cannot escape the workspaces root or the proxy route. +fn is_valid_user_id(user_id: &str) -> bool { + !user_id.is_empty() + && user_id.len() <= 128 + && user_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +// --------------------------------------------------------------------------- +// Fleet state +// --------------------------------------------------------------------------- + +struct Fleet { + instances: HashMap, + edge_auth: EdgeAuth, + http: reqwest::Client, +} + +impl Fleet { + fn user_for_bearer(&self, headers: &HeaderMap) -> Option { + let token = bearer_from_headers(headers)?; + self.edge_auth.user_for(&token).map(str::to_string) + } +} + +/// Extract the bearer token from an `Authorization: Bearer ` header. +fn bearer_from_headers(headers: &HeaderMap) -> Option { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| { + let mut parts = v.trim().splitn(2, char::is_whitespace); + let scheme = parts.next()?; + let token = parts.next()?.trim(); + if scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() { + Some(EdgeToken::new(token)) + } else { + None + } + }) +} + +// --------------------------------------------------------------------------- +// Proxy handler +// --------------------------------------------------------------------------- + +async fn rpc_proxy( + State(fleet): State>>, + AxumPath(user_id): AxumPath, + headers: HeaderMap, + body: Bytes, +) -> Response { + let (http, rpc_url, core_bearer) = { + let fleet = fleet.read().await; + + // Edge auth: the bearer must map to a user, and that user must match the + // path segment — so tenant A's token cannot reach tenant B's core. + let authorized = fleet.user_for_bearer(&headers); + match authorized { + Some(u) if u == user_id => {} + Some(_) => { + log::warn!( + "[fleet] reject: edge token authorized a different user than /{user_id}" + ); + return (StatusCode::FORBIDDEN, "token/user mismatch").into_response(); + } + None => { + return (StatusCode::UNAUTHORIZED, "missing or unknown edge token").into_response(); + } + } + + let Some(instance) = fleet.instances.get(&user_id) else { + return (StatusCode::NOT_FOUND, "no such tenant").into_response(); + }; + ( + fleet.http.clone(), + instance.rpc_url(), + instance.core_bearer.clone(), + ) + }; + + // Forward verbatim to the tenant core, swapping the edge token for the + // tenant's core bearer. The JSON-RPC body is passed through untouched. + let upstream = http + .post(rpc_url) + .header( + axum::http::header::AUTHORIZATION, + format!("Bearer {}", core_bearer.as_str()), + ) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(body) + .send() + .await; + + match upstream { + Ok(resp) => { + let status = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()); + match resp.bytes().await { + Ok(bytes) => { + let mut response = (status, bytes).into_response(); + if let Some(content_type) = content_type { + response + .headers_mut() + .insert(axum::http::header::CONTENT_TYPE, content_type); + } + response + } + Err(e) => { + log::error!("[fleet] upstream body read failed for /{user_id}: {e}"); + (StatusCode::BAD_GATEWAY, "upstream body error").into_response() + } + } + } + Err(e) => { + log::error!("[fleet] upstream request to tenant {user_id} failed: {e}"); + (StatusCode::BAD_GATEWAY, "tenant core unreachable").into_response() + } + } +} + +// --------------------------------------------------------------------------- +// Core process lifecycle +// --------------------------------------------------------------------------- + +/// Spawn one `openhuman-core run --headless-api` child bound to `instance.port`, +/// scoped to the tenant's workspace and core bearer. Returns the child handle. +async fn spawn_core( + core_bin: &Path, + instance: &CoreInstance, +) -> anyhow::Result { + std::fs::create_dir_all(&instance.workspace_dir).with_context(|| { + format!( + "creating workspace dir {} for tenant {}", + instance.workspace_dir.display(), + instance.user_id + ) + })?; + std::fs::create_dir_all(&instance.action_dir).with_context(|| { + format!( + "creating action dir {} for tenant {}", + instance.action_dir.display(), + instance.user_id + ) + })?; + + log::info!( + "[fleet] spawning core for tenant={} port={} workspace={} action_dir={}", + instance.user_id, + instance.port, + instance.workspace_dir.display(), + instance.action_dir.display() + ); + + let child = tokio::process::Command::new(core_bin) + .arg("run") + .arg("--headless-api") + .arg("--host") + .arg("127.0.0.1") + .arg("--port") + .arg(instance.port.to_string()) + .env("OPENHUMAN_WORKSPACE", &instance.workspace_dir) + .env("OPENHUMAN_ACTION_DIR", &instance.action_dir) + .env("OPENHUMAN_CORE_TOKEN", instance.core_bearer.as_str()) + // Each tenant is a headless single-core; keep channel listeners off so a + // fleet host doesn't poll every member's messaging integrations. + .env("OPENHUMAN_DISABLE_CHANNEL_LISTENERS", "1") + .kill_on_drop(true) + .spawn() + .with_context(|| { + format!( + "spawning {} for tenant {}", + core_bin.display(), + instance.user_id + ) + })?; + + Ok(child) +} + +async fn ensure_loopback_port_available(port: u16) -> anyhow::Result<()> { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .with_context(|| format!("tenant port {port} is not available on 127.0.0.1"))?; + drop(listener); + Ok(()) +} + +/// Poll a tenant core through authenticated JSON-RPC until it responds or the +/// attempt budget is exhausted. This intentionally avoids unauthenticated +/// `/health`: a stale OpenHuman process on the assigned port could look healthy +/// but reject this tenant's core bearer, so authenticated readiness fails closed. +async fn wait_authenticated_ready( + http: &reqwest::Client, + instance: &CoreInstance, + attempts: u32, +) -> bool { + let url = instance.rpc_url(); + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": "fleet-ready", + "method": "openhuman.security_policy_info", + "params": {} + }); + for attempt in 1..=attempts { + match http + .post(&url) + .header( + axum::http::header::AUTHORIZATION, + format!("Bearer {}", instance.core_bearer.as_str()), + ) + .json(&body) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + match resp.json::().await { + Ok(value) if readiness_body_succeeded(&value) => return true, + Ok(value) => { + log::debug!( + "[fleet] readiness probe tenant={} port={} returned JSON-RPC failure has_result={} has_error={}", + instance.user_id, + instance.port, + value.get("result").is_some(), + value.get("error").is_some() + ); + } + Err(e) => { + log::debug!( + "[fleet] readiness probe tenant={} port={} returned invalid JSON-RPC body: {e}", + instance.user_id, + instance.port + ); + } + } + } + Ok(resp) => log::debug!( + "[fleet] readiness probe tenant={} port={} returned status={}", + instance.user_id, + instance.port, + resp.status() + ), + Err(e) => log::trace!( + "[fleet] readiness probe tenant={} port={} failed attempt={attempt}: {e}", + instance.user_id, + instance.port + ), + } + tokio::time::sleep(std::time::Duration::from_millis(250 * attempt as u64)).await; + } + false +} + +fn readiness_body_succeeded(value: &serde_json::Value) -> bool { + value.get("error").is_none() && value.get("result").is_some() +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +#[derive(Parser, Debug)] +#[command( + name = "openhuman-fleet", + about = "Process-per-user OpenHuman core supervisor + reverse proxy" +)] +struct Args { + /// Address the reverse proxy listens on. + #[arg(long, default_value = "127.0.0.1:8899")] + listen: String, + /// Root directory under which each tenant's workspace is created. + #[arg(long, default_value = "./fleet-workspaces")] + workspaces_root: PathBuf, + /// Path to the `openhuman-core` binary to spawn per tenant. + #[arg(long, default_value = "openhuman-core")] + core_bin: PathBuf, + /// First tenant core port; tenant N listens on `base_core_port + N`. + #[arg(long, default_value_t = 7900)] + base_core_port: u16, + /// Comma-separated user ids to provision at boot. + #[arg(long, value_delimiter = ',')] + users: Vec, + /// Restricted file that receives minted edge tokens. + #[arg(long)] + edge_token_output: PathBuf, +} + +type ProvisionedFleet = ( + HashMap, + EdgeAuth, + Vec<(String, EdgeToken)>, +); + +/// Provision the in-memory tenant table + edge tokens for `users`. Pure w.r.t. +/// the filesystem/network so it is unit-testable; spawning happens separately. +fn provision( + users: &[String], + workspaces_root: &Path, + base_core_port: u16, +) -> anyhow::Result { + let mut instances = HashMap::new(); + let mut edge_auth = EdgeAuth::new(); + let mut minted = Vec::new(); + + for (index, user_id) in users.iter().enumerate() { + if !is_valid_user_id(user_id) { + anyhow::bail!("invalid user id {user_id:?}: must be a single [A-Za-z0-9_-] segment"); + } + if instances.contains_key(user_id) { + anyhow::bail!("duplicate user id {user_id:?}"); + } + let port = port_for_index(base_core_port, index) + .with_context(|| format!("port overflow assigning tenant #{index}"))?; + let core_bearer = CoreBearer::new(format!("core-{}", uuid::Uuid::new_v4())); + let edge_token = EdgeToken::new(format!("edge-{}", uuid::Uuid::new_v4())); + edge_auth.insert(edge_token.clone(), user_id.clone()); + minted.push((user_id.clone(), edge_token)); + instances.insert( + user_id.clone(), + CoreInstance { + user_id: user_id.clone(), + port, + core_bearer, + workspace_dir: workspace_for(workspaces_root, user_id), + action_dir: action_dir_for(workspaces_root, user_id), + }, + ); + } + + Ok((instances, edge_auth, minted)) +} + +fn remove_tenant( + instances: &mut HashMap, + edge_auth: &mut EdgeAuth, + minted: &mut Vec<(String, EdgeToken)>, + user_id: &str, +) { + instances.remove(user_id); + edge_auth.remove_user(user_id); + minted.retain(|(minted_user, _)| minted_user != user_id); +} + +fn write_edge_tokens(path: &Path, minted: &[(String, EdgeToken)]) -> anyhow::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut output = String::new(); + for (user_id, token) in minted { + output.push_str(user_id); + output.push(' '); + output.push_str(token.as_str()); + output.push('\n'); + } + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .with_context(|| format!("opening edge token output {}", path.display()))?; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("restricting edge token output {}", path.display()))?; + use std::io::Write as _; + file.write_all(output.as_bytes()) + .with_context(|| format!("writing edge token output {}", path.display()))?; + } + + #[cfg(not(unix))] + { + let _ = minted; + anyhow::bail!( + "edge token output {} requires restrictive file permissions; unsupported on this platform", + path.display() + ); + } + + Ok(()) +} + +const FLEET_ALLOWED_ORIGINS_ENV: &str = "OPENHUMAN_CORE_ALLOWED_ORIGINS"; + +fn is_fleet_origin_allowed(origin: &str) -> bool { + is_fleet_origin_allowed_with_extra( + origin, + std::env::var(FLEET_ALLOWED_ORIGINS_ENV).ok().as_deref(), + ) +} + +fn is_fleet_origin_allowed_with_extra(origin: &str, extra_origins: Option<&str>) -> bool { + if matches!( + origin, + "tauri://localhost" | "http://tauri.localhost" | "https://tauri.localhost" + ) { + return true; + } + + if let Some(rest) = origin.strip_prefix("http://") { + let authority = rest.split('/').next().unwrap_or(""); + let host = if let Some(stripped) = authority.strip_prefix('[') { + stripped.split(']').next().unwrap_or("") + } else { + authority.split(':').next().unwrap_or("") + }; + if matches!(host, "127.0.0.1" | "localhost" | "::1") { + return true; + } + } + + if let Some(extra) = extra_origins { + for candidate in extra.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if candidate == origin { + return true; + } + } + } + + false +} + +async fn fleet_cors_middleware(req: Request, next: Next) -> Response { + let origin = req + .headers() + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + + if req.method() == Method::OPTIONS { + return with_fleet_cors_headers(StatusCode::NO_CONTENT.into_response(), origin.as_deref()); + } + + let response = next.run(req).await; + with_fleet_cors_headers(response, origin.as_deref()) +} + +fn with_fleet_cors_headers(mut response: Response, origin: Option<&str>) -> Response { + let headers = response.headers_mut(); + headers.append(header::VARY, HeaderValue::from_static("Origin")); + + if let Some(origin) = origin { + if is_fleet_origin_allowed(origin) { + if let Ok(value) = HeaderValue::from_str(origin) { + headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, value); + } + } else { + log::warn!("[fleet][cors] rejected disallowed origin: {origin}"); + } + } + + headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("POST, OPTIONS"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("Content-Type, Authorization"), + ); + headers.insert( + header::ACCESS_CONTROL_MAX_AGE, + HeaderValue::from_static("86400"), + ); + response +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let _ = env_logger::builder().is_test(false).try_init(); + let args = Args::parse(); + + if args.users.is_empty() { + anyhow::bail!("no tenants: pass --users a,b,c"); + } + + let (mut instances, mut edge_auth, mut minted) = + provision(&args.users, &args.workspaces_root, args.base_core_port)?; + + let proxy_http = reqwest::Client::builder() + .build() + .context("building fleet proxy HTTP client")?; + let readiness_http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("building readiness HTTP client")?; + + // Spawn each tenant core. Children are monitored for the lifetime of the + // process; aborting a monitor drops the child with kill_on_drop(true). + let mut children = Vec::new(); + for instance in instances.values().cloned().collect::>() { + let spawn_result = match ensure_loopback_port_available(instance.port).await { + Ok(()) => spawn_core(&args.core_bin, &instance).await, + Err(e) => Err(e), + }; + match spawn_result { + Ok(mut child) => { + let healthy = wait_authenticated_ready(&readiness_http, &instance, 20).await; + if healthy { + log::info!("[fleet] tenant {} core ready", instance.user_id); + children.push((instance.user_id.clone(), child)); + } else { + log::error!("[fleet] tenant {} health probe timed out", instance.user_id); + let _ = child.start_kill(); + let _ = child.wait().await; + remove_tenant( + &mut instances, + &mut edge_auth, + &mut minted, + &instance.user_id, + ); + } + } + Err(e) => { + log::error!("[fleet] failed to spawn tenant {}: {e:#}", instance.user_id); + remove_tenant( + &mut instances, + &mut edge_auth, + &mut minted, + &instance.user_id, + ); + } + } + } + + if instances.is_empty() { + anyhow::bail!("no tenant cores started successfully"); + } + + write_edge_tokens(&args.edge_token_output, &minted)?; + log::info!( + "[fleet] wrote {} edge token(s) to {}", + minted.len(), + args.edge_token_output.display() + ); + + let fleet = Arc::new(RwLock::new(Fleet { + instances, + edge_auth, + http: proxy_http, + })); + + let mut child_tasks = Vec::new(); + for (user_id, mut child) in children { + let fleet_for_child = Arc::clone(&fleet); + child_tasks.push(tokio::spawn(async move { + match child.wait().await { + Ok(status) => { + log::warn!("[fleet] tenant {user_id} core exited with status {status}"); + } + Err(e) => { + log::warn!("[fleet] tenant {user_id} core wait failed: {e}"); + } + } + fleet_for_child.write().await.instances.remove(&user_id); + })); + } + + let app = Router::new() + .route( + "/{user_id}/rpc", + post(rpc_proxy).route_layer(DefaultBodyLimit::max(MAX_RPC_BODY_BYTES)), + ) + .layer(middleware::from_fn(fleet_cors_middleware)) + .with_state(fleet); + + let addr: SocketAddr = args + .listen + .parse() + .with_context(|| format!("invalid --listen address {:?}", args.listen))?; + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("binding proxy on {addr}"))?; + log::info!("[fleet] reverse proxy listening on http://{addr} — POST /{{user_id}}/rpc"); + + axum::serve(listener, app).await.context("serving proxy")?; + + for task in child_tasks { + task.abort(); + let _ = task.await; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests — pure logic (no child processes / network). +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn port_assignment_is_sequential_from_base() { + assert_eq!(port_for_index(7900, 0), Some(7900)); + assert_eq!(port_for_index(7900, 5), Some(7905)); + } + + #[test] + fn port_assignment_detects_overflow() { + assert_eq!(port_for_index(u16::MAX, 1), None); + } + + #[test] + fn workspace_is_user_scoped_under_root() { + let ws = workspace_for(Path::new("/srv/fleet"), "alice"); + assert_eq!(ws, PathBuf::from("/srv/fleet/alice")); + } + + #[test] + fn action_dir_is_user_scoped_outside_workspace_tree() { + let root = Path::new("/srv/fleet"); + let workspace = workspace_for(root, "alice"); + let action_dir = action_dir_for(root, "alice"); + + assert_eq!( + action_dir, + PathBuf::from("/srv/fleet/.tenant-action-dirs/alice") + ); + assert!(!action_dir.starts_with(&workspace)); + } + + #[test] + fn user_id_validation_rejects_path_escapes() { + assert!(is_valid_user_id("alice")); + assert!(is_valid_user_id("user_42-x")); + assert!(!is_valid_user_id("")); + assert!(!is_valid_user_id("../etc")); + assert!(!is_valid_user_id("a/b")); + assert!(!is_valid_user_id("a.b")); + } + + #[test] + fn provision_assigns_distinct_ports_and_edge_tokens() { + let root = PathBuf::from("/tmp/ws"); + let users = vec!["alice".to_string(), "bob".to_string()]; + let (instances, edge_auth, minted) = provision(&users, &root, 7900).unwrap(); + + assert_eq!(instances.len(), 2); + assert_eq!(instances["alice"].port, 7900); + assert_eq!(instances["bob"].port, 7901); + assert_eq!(instances["alice"].workspace_dir, root.join("alice")); + assert_eq!( + instances["alice"].action_dir, + root.join(".tenant-action-dirs").join("alice") + ); + assert_ne!(instances["alice"].action_dir, instances["bob"].action_dir); + assert_ne!(instances["alice"].core_bearer, instances["bob"].core_bearer); + assert_eq!(instances["alice"].rpc_url(), "http://127.0.0.1:7900/rpc"); + + // Every minted edge token resolves back to exactly its user. + assert_eq!(edge_auth.len(), 2); + for (user_id, token) in &minted { + assert_eq!(edge_auth.user_for(token), Some(user_id.as_str())); + } + } + + #[test] + fn provision_rejects_duplicate_and_invalid_users() { + let root = PathBuf::from("/tmp/ws"); + assert!(provision(&["a".into(), "a".into()], &root, 7900).is_err()); + assert!(provision(&["../x".into()], &root, 7900).is_err()); + } + + #[test] + fn bearer_parsing_requires_bearer_prefix() { + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::AUTHORIZATION, + "Bearer edge-123".parse().unwrap(), + ); + assert_eq!(bearer_from_headers(&h), Some(EdgeToken::new("edge-123"))); + + let mut lower = HeaderMap::new(); + lower.insert( + axum::http::header::AUTHORIZATION, + "bearer edge-456".parse().unwrap(), + ); + assert_eq!( + bearer_from_headers(&lower), + Some(EdgeToken::new("edge-456")) + ); + + let mut h2 = HeaderMap::new(); + h2.insert( + axum::http::header::AUTHORIZATION, + "edge-123".parse().unwrap(), + ); + assert_eq!(bearer_from_headers(&h2), None); + } + + #[test] + fn readiness_body_requires_jsonrpc_result() { + assert!(readiness_body_succeeded(&serde_json::json!({ + "jsonrpc": "2.0", + "id": "fleet-ready", + "result": {"tier": "supervised"} + }))); + + assert!(!readiness_body_succeeded(&serde_json::json!({ + "jsonrpc": "2.0", + "id": "fleet-ready", + "error": {"code": -32000, "message": "config unavailable"} + }))); + + assert!(!readiness_body_succeeded(&serde_json::json!({ + "jsonrpc": "2.0", + "id": "fleet-ready" + }))); + } + + #[cfg(unix)] + #[test] + fn edge_token_output_is_written_0600() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("edge-tokens.txt"); + write_edge_tokens( + &path, + &[("alice".to_string(), EdgeToken::new("edge-secret"))], + ) + .unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + assert_eq!( + std::fs::read_to_string(path).unwrap(), + "alice edge-secret\n" + ); + } + + #[cfg(unix)] + #[test] + fn edge_token_output_rejects_symlinks() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("attacker-readable.txt"); + std::fs::write(&target, "unchanged").unwrap(); + let output = dir.path().join("edge-tokens.txt"); + symlink(&target, &output).unwrap(); + + assert!(write_edge_tokens( + &output, + &[("alice".to_string(), EdgeToken::new("edge-secret"))], + ) + .is_err()); + assert_eq!(std::fs::read_to_string(target).unwrap(), "unchanged"); + } + + #[cfg(unix)] + #[test] + fn edge_token_output_rejects_preowned_files() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("edge-tokens.txt"); + std::fs::write(&output, "attacker keeps this inode").unwrap(); + + assert!(write_edge_tokens( + &output, + &[("alice".to_string(), EdgeToken::new("edge-secret"))], + ) + .is_err()); + assert_eq!( + std::fs::read_to_string(output).unwrap(), + "attacker keeps this inode" + ); + } + + #[test] + fn fleet_cors_allows_tauri_loopback_and_extra_origins() { + assert!(is_fleet_origin_allowed_with_extra( + "tauri://localhost", + None + )); + assert!(is_fleet_origin_allowed_with_extra( + "http://127.0.0.1:1420", + None + )); + assert!(is_fleet_origin_allowed_with_extra( + "https://fleet.example", + Some("https://fleet.example") + )); + assert!(!is_fleet_origin_allowed_with_extra( + "https://evil.example", + Some("https://fleet.example") + )); + } + + #[test] + fn fleet_cors_headers_echo_allowed_origin_only() { + let allowed = with_fleet_cors_headers( + StatusCode::NO_CONTENT.into_response(), + Some("tauri://localhost"), + ); + assert_eq!( + allowed + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()), + Some("tauri://localhost") + ); + assert_eq!( + allowed + .headers() + .get(header::ACCESS_CONTROL_ALLOW_HEADERS) + .and_then(|value| value.to_str().ok()), + Some("Content-Type, Authorization") + ); + + let rejected = with_fleet_cors_headers( + StatusCode::NO_CONTENT.into_response(), + Some("https://evil.example"), + ); + assert!(rejected + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none()); + } +} diff --git a/src/core/all.rs b/src/core/all.rs index 8644d0ba30..46b17241ce 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -65,8 +65,7 @@ fn registry() -> &'static [RegisteredController] { REGISTRY .get_or_init(|| { let registered = build_registered_controllers(); - let declared = build_declared_controller_schemas(); - validate_registry(®istered, &declared).unwrap_or_else(|err| { + validate_registry(®istered).unwrap_or_else(|err| { panic!("invalid controller registry: {err}"); }); registered @@ -368,155 +367,16 @@ fn build_internal_only_controllers() -> Vec { controllers } -/// Aggregates all controller schemas from across the codebase. -/// -/// Similar to [`build_registered_controllers`], but only collects the metadata -/// (schema) for each controller. This is used for discovery and validation. -fn build_declared_controller_schemas() -> Vec { - let mut schemas = Vec::new(); - schemas.extend(crate::openhuman::about_app::all_about_app_controller_schemas()); - schemas.extend(crate::openhuman::agentbox::all_agentbox_controller_schemas()); - schemas.extend(crate::openhuman::app_state::all_app_state_controller_schemas()); - schemas.extend(crate::openhuman::audio_toolkit::all_audio_toolkit_controller_schemas()); - schemas.extend(crate::openhuman::composio::all_composio_controller_schemas()); - schemas.extend(crate::openhuman::recall_calendar::all_recall_calendar_controller_schemas()); - schemas.extend(crate::openhuman::cron::all_cron_controller_schemas()); - schemas.extend(crate::openhuman::flows::all_flows_controller_schemas()); - schemas.extend(crate::openhuman::task_sources::all_task_sources_controller_schemas()); - schemas.extend(crate::openhuman::dashboard::all_dashboard_controller_schemas()); - schemas.extend(crate::openhuman::mcp_registry::all_mcp_registry_controller_schemas()); - schemas.extend(crate::openhuman::webview_apis::all_webview_apis_controller_schemas()); - schemas.extend(crate::openhuman::agent::all_agent_controller_schemas()); - // Read-only agent run replay + status controllers (workstream 05.x). - schemas.extend(crate::openhuman::tinyagents::replay::all_agent_replay_controller_schemas()); - schemas.extend(crate::openhuman::profiles::all_profiles_controller_schemas()); - schemas.extend(crate::openhuman::agent_registry::all_agent_registry_controller_schemas()); - schemas.extend(crate::openhuman::agent_experience::all_agent_experience_controller_schemas()); - schemas.extend(crate::openhuman::health::all_health_controller_schemas()); - schemas.extend(crate::openhuman::harness_init::all_harness_init_controller_schemas()); - schemas.extend(crate::openhuman::doctor::all_doctor_controller_schemas()); - schemas.extend(crate::openhuman::encryption::all_encryption_controller_schemas()); - schemas.extend(crate::openhuman::keyring_consent::all_keyring_consent_controller_schemas()); - schemas.extend(crate::openhuman::security::all_security_controller_schemas()); - schemas.extend(crate::openhuman::approval::all_approval_controller_schemas()); - schemas.extend(crate::openhuman::plan_review::all_plan_review_controller_schemas()); - schemas.extend(crate::openhuman::artifacts::all_artifacts_controller_schemas()); - schemas.extend(crate::openhuman::heartbeat::all_heartbeat_controller_schemas()); - schemas.extend(crate::openhuman::http_host::all_http_host_controller_schemas()); - schemas.extend(crate::openhuman::cost::all_cost_controller_schemas()); - schemas.extend(crate::openhuman::x402::all_x402_controller_schemas()); - schemas.extend(crate::openhuman::autocomplete::all_autocomplete_controller_schemas()); - schemas - .extend(crate::openhuman::channels::providers::web::all_web_channel_controller_schemas()); - schemas.extend(crate::openhuman::channels::controllers::all_channels_controller_schemas()); - schemas.extend(crate::openhuman::config::all_config_controller_schemas()); - schemas.extend(crate::openhuman::connectivity::all_connectivity_controller_schemas()); - schemas.extend(crate::openhuman::credentials::all_credentials_controller_schemas()); - schemas.extend(crate::openhuman::service::all_service_controller_schemas()); - schemas.extend(crate::openhuman::migration::all_migration_controller_schemas()); - schemas.extend(crate::openhuman::council_registry::all_council_registry_controller_schemas()); - schemas.extend(crate::openhuman::model_council::all_model_council_controller_schemas()); - schemas.extend(crate::openhuman::monitor::all_monitor_controller_schemas()); - schemas.extend(crate::openhuman::inference::all_inference_controller_schemas()); - schemas.extend(crate::openhuman::inference::all_local_inference_controller_schemas()); - schemas.extend(crate::openhuman::embeddings::all_embeddings_controller_schemas()); - schemas.extend(crate::openhuman::people::all_people_controller_schemas()); - schemas.extend( - crate::openhuman::screen_intelligence::all_screen_intelligence_controller_schemas(), - ); - schemas.extend(crate::openhuman::sandbox::all_sandbox_controller_schemas()); - schemas.extend(crate::openhuman::socket::all_socket_controller_schemas()); - schemas.extend(crate::openhuman::javascript::all_javascript_controller_schemas()); - schemas.extend(crate::openhuman::skills::all_skills_controller_schemas()); - schemas.extend(crate::openhuman::skill_runtime::all_skill_runtime_controller_schemas()); - schemas.extend(crate::openhuman::skill_registry::all_skill_registry_controller_schemas()); - schemas.extend(crate::openhuman::workspace::all_workspace_controller_schemas()); - schemas.extend(crate::openhuman::tools::all_tools_controller_schemas()); - schemas.extend(crate::openhuman::tool_registry::all_tool_registry_controller_schemas()); - schemas.extend(crate::openhuman::memory::all_memory_controller_schemas()); - schemas.extend(crate::openhuman::memory_goals::all_memory_goals_controller_schemas()); - schemas.extend(crate::openhuman::thread_goals::all_thread_goals_controller_schemas()); - schemas.extend(crate::openhuman::memory_tree::all_memory_tree_controller_schemas()); - schemas.extend(crate::openhuman::memory_tree::all_retrieval_controller_schemas()); - schemas.extend( - crate::openhuman::composio::providers::slack::all_slack_memory_controller_schemas(), - ); - schemas.extend( - crate::openhuman::memory_sync::sync_status::all_memory_sync_status_controller_schemas(), - ); - schemas.extend(crate::openhuman::memory_sources::all_memory_sources_controller_schemas()); - schemas.extend(crate::openhuman::memory_diff::all_memory_diff_controller_schemas()); - schemas.extend(crate::openhuman::redirect_links::all_redirect_links_controller_schemas()); - schemas.extend(crate::openhuman::referral::all_referral_controller_schemas()); - schemas.extend(crate::openhuman::billing::all_billing_controller_schemas()); - schemas.extend(crate::openhuman::announcements::all_announcements_controller_schemas()); - schemas.extend(crate::openhuman::team::all_team_controller_schemas()); - #[cfg(feature = "e2e-test-support")] - schemas.extend(crate::openhuman::test_support::all_test_support_controller_schemas()); - schemas.extend(crate::openhuman::wallet::all_wallet_controller_schemas()); - schemas.extend(crate::openhuman::web3::all_web3_controller_schemas()); - schemas.extend(crate::openhuman::provider_surfaces::all_provider_surfaces_controller_schemas()); - schemas.extend(crate::openhuman::text_input::all_text_input_controller_schemas()); - schemas.extend(crate::openhuman::voice::all_voice_controller_schemas()); - schemas.extend(crate::openhuman::subconscious::all_subconscious_controller_schemas()); - schemas.extend( - crate::openhuman::subconscious_triggers::all_subconscious_triggers_controller_schemas(), - ); - schemas.extend(crate::openhuman::webhooks::all_webhooks_controller_schemas()); - schemas.extend(crate::openhuman::update::all_update_controller_schemas()); - schemas.extend(crate::openhuman::memory_tree::all_tree_summarizer_controller_schemas()); - schemas.extend(crate::openhuman::learning::all_learning_controller_schemas()); - // Conversation thread and message management - schemas.extend(crate::openhuman::threads::all_threads_controller_schemas()); - // TokenJuice content-router debug controllers - schemas.extend(crate::openhuman::tokenjuice::all_tokenjuice_controller_schemas()); - // Per-thread todo list (agent task board CRUD over RPC) - schemas.extend(crate::openhuman::todos::all_todos_controller_schemas()); - // Embedded webview native notifications - schemas.extend( - crate::openhuman::webview_notifications::all_webview_notifications_controller_schemas(), - ); - // Integration notification ingest, triage, and per-provider settings - schemas.extend(crate::openhuman::notifications::all_notifications_controller_schemas()); - // Google Meet call-join request validation - schemas.extend(crate::openhuman::meet::all_meet_controller_schemas()); - // Agent meetings — backend-delegated Meet bot via Socket.IO - schemas.extend(crate::openhuman::agent_meetings::all_agent_meetings_controller_schemas()); - // Live meet-agent listening + speaking loop - schemas.extend(crate::openhuman::meet_agent::all_meet_agent_controller_schemas()); - // Desktop companion — Clicky-style interaction loop. - schemas.extend(crate::openhuman::desktop_companion::all_desktop_companion_controller_schemas()); - // Structured WhatsApp Web data — local SQLite store, agent-queryable - schemas.extend(crate::openhuman::whatsapp_data::all_whatsapp_data_controller_schemas()); - // Mobile device pairing and management - schemas.extend(crate::openhuman::devices::all_devices_controller_schemas()); - // Durable agent session database - schemas.extend(crate::openhuman::session_db::all_session_db_controller_schemas()); - // One-time legacy session import into TinyAgents stores - schemas.extend(crate::openhuman::session_import::all_session_import_controller_schemas()); - // Background agent command center - schemas.extend(crate::openhuman::agent_orchestration::all_command_center_controller_schemas()); - // Durable dynamic workflow runs - schemas.extend(crate::openhuman::agent_orchestration::all_workflow_run_controller_schemas()); - // Durable agent-team coordination - schemas.extend(crate::openhuman::agent_orchestration::all_agent_team_controller_schemas()); - // Git-worktree isolation manager (#3376) - schemas.extend(crate::openhuman::agent_orchestration::all_worktree_controller_schemas()); - // User-driven cancel of detached background sub-agents (#3711) - schemas - .extend(crate::openhuman::agent_orchestration::all_subagent_control_controller_schemas()); - schemas -} - /// Returns a vector of all currently registered controllers. pub fn all_registered_controllers() -> Vec { registry().to_vec() } -/// Returns a vector of all currently declared controller schemas. +/// Returns a vector of all controller schemas, derived from the registered +/// controllers (the single source of truth). Kept identical in content to the +/// registered set — schemas can no longer drift from handlers (Phase 2). pub fn all_controller_schemas() -> Vec { - let _ = registry(); - build_declared_controller_schemas() + registry().iter().map(|c| c.schema.clone()).collect() } /// Generates a standardized RPC method name from a controller schema. @@ -862,58 +722,71 @@ pub async fn try_invoke_registered_rpc( method: &str, params: Map, ) -> Option> { - for controller in registry() { - if controller.rpc_method_name() == method { - return Some((controller.handler)(params).await); - } - } - for controller in internal_registry() { - if controller.rpc_method_name() == method { - return Some((controller.handler)(params).await); - } - } - None + let handler = registry() + .iter() + .chain(internal_registry().iter()) + .find(|c| c.rpc_method_name() == method) + .map(|c| c.handler)?; + + // Establish the ambient CoreContext for the duration of the handler so + // `CoreContext::current()` resolves inside handler bodies (Phase 2). + // `current()` inherits an already-active scope (so a handler that dispatches + // a nested RPC stays in the same tenant context) and otherwise resolves to + // the process default. Before any context is built (some unit tests) it is + // `None` and the handler runs unscoped. + // + // The scoped future is re-boxed back into a `ControllerFuture` so the + // concrete `TaskLocalFuture` type does not escape into this fn's future — + // without the type erasure the `Send` auto-trait solver overflows on the + // largest handler futures (E0275). + use crate::core::runtime::context::CoreContext; + let fut = handler(params); + let scoped: ControllerFuture = match CoreContext::current() { + Some(ctx) => Box::pin(CoreContext::scope(ctx, fut)), + None => fut, + }; + Some(scoped.await) } /// Validates the consistency of the controller registry. /// +/// The registry is the single source of truth: each [`RegisteredController`] +/// carries its own schema, and the public schema list is *derived* from it +/// (see [`all_controller_schemas`]). There is therefore no separate "declared" +/// list to drift from — the previous declared-vs-registered cross-check is +/// impossible by construction and has been removed (Phase 2 registry collapse). +/// /// Ensures that: /// - There are no duplicate controllers or RPC methods. -/// - Every declared schema has a registered handler. -/// - Every registered handler has a declared schema. /// - Namespaces and functions are not empty. /// - Required input names are unique within a controller. -fn validate_registry( - registered: &[RegisteredController], - declared: &[ControllerSchema], -) -> Result<(), String> { +fn validate_registry(registered: &[RegisteredController]) -> Result<(), String> { use std::collections::{BTreeMap, BTreeSet}; let mut errors: Vec = Vec::new(); - let mut declared_keys = BTreeSet::new(); - let mut declared_rpc_methods = BTreeSet::new(); let mut registered_keys = BTreeSet::new(); let mut registered_rpc_methods = BTreeSet::new(); - for schema in declared { + for controller in registered { + let schema = &controller.schema; let key = format!("{}.{}", schema.namespace, schema.function); - if !declared_keys.insert(key.clone()) { - errors.push(format!("duplicate declared controller `{key}`")); + if !registered_keys.insert(key.clone()) { + errors.push(format!("duplicate registered controller `{key}`")); } - let rpc_method = rpc_method_name(schema); - if !declared_rpc_methods.insert(rpc_method.clone()) { - errors.push(format!("duplicate declared rpc method `{rpc_method}`")); + let rpc_method = controller.rpc_method_name(); + if !registered_rpc_methods.insert(rpc_method.clone()) { + errors.push(format!("duplicate registered rpc method `{rpc_method}`")); } if schema.namespace.trim().is_empty() { errors.push(format!( - "invalid declared controller `{key}`: namespace must not be empty" + "invalid registered controller `{key}`: namespace must not be empty" )); } if schema.function.trim().is_empty() { errors.push(format!( - "invalid declared controller `{key}`: function must not be empty" + "invalid registered controller `{key}`: function must not be empty" )); } @@ -932,32 +805,6 @@ fn validate_registry( } } - for controller in registered { - let key = format!( - "{}.{}", - controller.schema.namespace, controller.schema.function - ); - if !registered_keys.insert(key.clone()) { - errors.push(format!("duplicate registered controller `{key}`")); - } - - let rpc_method = controller.rpc_method_name(); - if !registered_rpc_methods.insert(rpc_method.clone()) { - errors.push(format!("duplicate registered rpc method `{rpc_method}`")); - } - } - - for key in declared_keys.difference(®istered_keys) { - errors.push(format!( - "declared controller `{key}` has no registered handler" - )); - } - for key in registered_keys.difference(&declared_keys) { - errors.push(format!( - "registered controller `{key}` has no declared schema" - )); - } - if errors.is_empty() { Ok(()) } else { diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 8ffd3eec0d..449e685e8e 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -35,8 +35,8 @@ fn validate_registry_rejects_duplicate_namespace_function() { }, ]; - let err = validate_registry(®istered, &declared).expect_err("expected duplicate error"); - assert!(err.contains("duplicate declared controller `dup.fn`")); + let err = validate_registry(®istered).expect_err("expected duplicate error"); + assert!(err.contains("duplicate registered controller `dup.fn`")); } #[test] @@ -64,7 +64,7 @@ fn validate_registry_rejects_duplicate_required_inputs() { handler: noop_handler, }]; - let err = validate_registry(®istered, &declared).expect_err("expected duplicate input"); + let err = validate_registry(®istered).expect_err("expected duplicate input"); assert!(err.contains("duplicate required input `use_cache` in `doctor.models`")); } @@ -82,7 +82,7 @@ fn validate_registry_accepts_valid_registry() { handler: noop_handler, }) .collect::>(); - assert!(validate_registry(®istered, &declared).is_ok()); + assert!(validate_registry(®istered).is_ok()); } #[test] @@ -522,7 +522,7 @@ fn validate_registry_rejects_empty_namespace() { schema: declared[0].clone(), handler: noop_handler, }]; - let err = validate_registry(®istered, &declared).unwrap_err(); + let err = validate_registry(®istered).unwrap_err(); assert!(err.contains("namespace must not be empty")); } @@ -533,7 +533,7 @@ fn validate_registry_rejects_empty_function() { schema: declared[0].clone(), handler: noop_handler, }]; - let err = validate_registry(®istered, &declared).unwrap_err(); + let err = validate_registry(®istered).unwrap_err(); assert!(err.contains("function must not be empty")); } @@ -546,33 +546,17 @@ fn validate_registry_rejects_whitespace_only_namespace() { schema: declared[0].clone(), handler: noop_handler, }]; - let err = validate_registry(®istered, &declared).unwrap_err(); + let err = validate_registry(®istered).unwrap_err(); assert!(err.contains("namespace must not be empty")); } -#[test] -fn validate_registry_rejects_declared_without_registered() { - let declared = vec![schema("a", "b", vec![])]; - let registered: Vec = vec![]; - let err = validate_registry(®istered, &declared).unwrap_err(); - assert!(err.contains("declared controller `a.b` has no registered handler")); -} - -#[test] -fn validate_registry_rejects_registered_without_declared() { - let declared: Vec = vec![]; - let registered = vec![RegisteredController { - schema: schema("a", "b", vec![]), - handler: noop_handler, - }]; - let err = validate_registry(®istered, &declared).unwrap_err(); - assert!(err.contains("registered controller `a.b` has no declared schema")); -} +// Note: the previous `declared_without_registered` / `registered_without_declared` +// drift tests were removed with the registry collapse (Phase 2) — schemas are now +// derived from the registered controllers, so the two lists cannot drift. #[test] fn validate_registry_rejects_duplicate_registered_controllers() { let s = schema("a", "b", vec![]); - let declared = vec![s.clone()]; let registered = vec![ RegisteredController { schema: s.clone(), @@ -583,7 +567,7 @@ fn validate_registry_rejects_duplicate_registered_controllers() { handler: noop_handler, }, ]; - let err = validate_registry(®istered, &declared).unwrap_err(); + let err = validate_registry(®istered).unwrap_err(); assert!(err.contains("duplicate registered controller `a.b`")); } diff --git a/src/core/cli.rs b/src/core/cli.rs index a575df8476..014b75c08a 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -224,6 +224,7 @@ fn run_server_command(args: &[String]) -> Result<()> { let mut port: Option = None; let mut host: Option = None; let mut socketio_enabled = true; + let mut headless_api = false; let mut verbose = false; let mut log_scope = CliLogDefault::Global; let mut i = 0usize; @@ -253,6 +254,11 @@ fn run_server_command(args: &[String]) -> Result<()> { socketio_enabled = false; i += 1; } + "--headless-api" => { + socketio_enabled = false; + headless_api = true; + i += 1; + } "-v" | "--verbose" => { verbose = true; i += 1; @@ -263,7 +269,7 @@ fn run_server_command(args: &[String]) -> Result<()> { i += 1; } "-h" | "--help" => { - println!("Usage: openhuman run [--host ] [--port ] [--jsonrpc-only] [--autocomplete-logs] [-v|--verbose]"); + println!("Usage: openhuman run [--host ] [--port ] [--jsonrpc-only|--headless-api] [--autocomplete-logs] [-v|--verbose]"); println!(); println!( " --host Bind address (default: 127.0.0.1 or OPENHUMAN_CORE_HOST)" @@ -272,6 +278,7 @@ fn run_server_command(args: &[String]) -> Result<()> { " --port Listen address port (default: 7788 or OPENHUMAN_CORE_PORT)" ); println!(" --jsonrpc-only HTTP JSON-RPC only; disable Socket.IO"); + println!(" --headless-api HTTP JSON-RPC only; disable all background services"); autocomplete_cli_adapter::print_run_scope_help_line(); println!(" -v, --verbose Shorthand for RUST_LOG=debug when RUST_LOG is unset"); println!(); @@ -298,7 +305,11 @@ fn run_server_command(args: &[String]) -> Result<()> { .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) .build()?; rt.block_on(async { - crate::core::jsonrpc::run_server(host.as_deref(), port, socketio_enabled).await + if headless_api { + crate::core::jsonrpc::run_server_headless(host.as_deref(), port).await + } else { + crate::core::jsonrpc::run_server(host.as_deref(), port, socketio_enabled).await + } })?; Ok(()) } diff --git a/src/core/dispatch.rs b/src/core/dispatch.rs index 728d9778aa..dae0f62a6a 100644 --- a/src/core/dispatch.rs +++ b/src/core/dispatch.rs @@ -65,7 +65,7 @@ pub async fn dispatch( } // Tier 2: Registered domain controllers. - if let Some(result) = try_registry_dispatch(method, params.clone()).await { + if let Some(result) = try_registry_dispatch(method, params).await { log::debug!( "[rpc:dispatch] routed method={} subsystem=controller_registry", method @@ -73,16 +73,7 @@ pub async fn dispatch( return result; } - // Tier 3: Legacy domain-specific dispatcher. - if let Some(result) = crate::rpc::try_dispatch(method, params).await { - log::debug!( - "[rpc:dispatch] routed method={} subsystem=openhuman", - method - ); - return result; - } - - // Tier 4: unrecognised method. The JSON-RPC response is unchanged — the + // Tier 3: unrecognised method. The JSON-RPC response is unchanged — the // caller still receives a method-not-found error. Only the *severity* of // how the transport layer records it differs by class (see // `jsonrpc::rpc_handler`): known non-actionable misses diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index e53631367f..729dfc7965 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -1652,7 +1652,7 @@ async fn not_found_handler() -> impl IntoResponse { } /// Resolves the port for the core server from environment variables or defaults. -fn core_port() -> u16 { +pub(crate) fn core_port() -> u16 { std::env::var("OPENHUMAN_CORE_PORT") .ok() .and_then(|v| v.parse::().ok()) @@ -1660,7 +1660,7 @@ fn core_port() -> u16 { } /// Resolves the bind address host for the core server from environment variables or defaults. -fn core_host() -> String { +pub(crate) fn core_host() -> String { std::env::var("OPENHUMAN_CORE_HOST") .ok() .filter(|s| !s.is_empty()) @@ -1687,6 +1687,12 @@ pub async fn run_server( run_server_inner(host, port, socketio_enabled, false, None, None, None).await } +/// Runs the request/response-only HTTP API without detached background jobs. +pub async fn run_server_headless(host: Option<&str>, port: Option) -> anyhow::Result<()> { + let services = crate::core::runtime::ServiceSet::headless_api(); + run_server_with_services(host, port, services, false, None, None, None).await +} + /// Like [`run_server`] but marks the instance as embedded. pub async fn run_server_embedded( host: Option<&str>, @@ -1744,485 +1750,56 @@ async fn run_server_inner( ready_tx: Option>, rpc_token: Option>, ) -> anyhow::Result<()> { - // Ensure all controllers are registered before starting. - let _ = all::all_registered_controllers(); - - // Ensure the master encryption key is loaded from keychain before any - // config or credential operation that needs to decrypt secrets. This is - // a no-op if already called (e.g. from run_core_from_args for CLI). - crate::openhuman::keyring::init_master_key(); - - // AgentBox GMI MaaS provider bridge — no-op when env vars absent. - // Must run BEFORE `build_core_http_router` mounts the AgentBox routes so - // that by the time `/run` accepts traffic the inference catalog already - // knows about `"gmi-maas"`. Never panics; missing/blank env vars log a - // warning and leave the core booting in degraded mode. - crate::openhuman::agentbox::register_gmi_provider_if_present(); - - // Initialize the per-process RPC bearer token. - // - // Preferred path (in-process core spawned by the Tauri shell): the caller - // passes the bearer it already holds in `CoreProcessHandle.rpc_token` as - // `rpc_token: Some(_)`. The token is seeded directly into the auth - // subsystem without ever crossing `OPENHUMAN_CORE_TOKEN` on the process - // environment — closing the same-UID readback channel (sysctl - // KERN_PROCARGS2 / ps eww on macOS, /proc//environ on Linux). - // - // Fallback (standalone CLI / docker / cloud `openhuman core run`): - // `rpc_token: None` lets `init_rpc_token` read `OPENHUMAN_CORE_TOKEN` - // from the environment when present (env-as-config — legit operator - // surface), or generate a fresh token and write `{workspace_dir}/core.token` - // (0o600 on Unix) so CLI callers can authenticate. - if let Some(token) = rpc_token.as_deref() { - crate::core::auth::init_rpc_token_with_value(token)?; - } else { - let token_dir = - crate::openhuman::config::default_root_openhuman_dir().unwrap_or_else(|_| { - dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".openhuman") - }); - crate::core::auth::init_rpc_token(&token_dir)?; - } - - // Initialize the global MemoryClient so composio providers - // (gmail/slack/notion) can persist their sync_state via kv_get/kv_set, - // and so any subsystem that calls `memory::global::client_if_ready()` - // gets a live handle. Without this, every periodic sync bails with - // "[composio:gmail] memory client not ready". - { - // A `Config::load_or_init` failure here is operator-visible and - // serious (corrupt toml, bad permissions, missing/unwritable - // OPENHUMAN_WORKSPACE — common on headless/containerised deploys - // with no writable $HOME). Previously we fell back to - // `Config::default()` and initialised the memory + whatsapp_data - // stores against the *wrong* workspace dir, silently causing chunk - // loss / cross-workspace bleed-over while the app looked healthy - // (Sentry OPENHUMAN-CORE-48). Instead: skip the workspace-bound - // init entirely so memory stays explicitly *uninitialised* — - // callers then get a clear "memory client not ready" error rather - // than reading/writing the wrong workspace. The server still comes - // up; the operator sees the loud error and fixes their config or - // sets OPENHUMAN_WORKSPACE to a writable path, then restarts. - match crate::openhuman::config::Config::load_or_init().await { - Ok(cfg) => { - let keyring_dir = - crate::openhuman::keyring::store::workspace_dir_for_file_backend(); - log::info!( - "[boot] paths: config={} workspace={} keyring_dir={} keyring_backend={}", - cfg.config_path.display(), - cfg.workspace_dir.display(), - keyring_dir.display(), - crate::openhuman::keyring::backend_name(), - ); - match crate::openhuman::memory::global::init(cfg.workspace_dir.clone()) { - Ok(_) => log::info!( - "[boot] memory::global initialized (workspace={})", - cfg.workspace_dir.display() - ), - Err(e) => log::warn!("[boot] memory::global init failed: {e}"), - } - // Install the on-disk image-attachment sidecar dir so inbound - // image markers persist under /attachments/ instead - // of an in-memory FIFO (survives restarts + delegation hops). - // Also fires a best-effort stale-file sweep. - crate::openhuman::agent::multimodal::init_attachments_dir( - cfg.workspace_dir.join("attachments"), - ); - log::info!( - "[boot] image attachments sidecar dir = {}", - cfg.workspace_dir.join("attachments").display() - ); - // Initialize the WhatsApp data store so scanner ingest calls - // can write data without requiring a lazy-init fallback. - match crate::openhuman::whatsapp_data::global::init(cfg.workspace_dir.clone()) { - Ok(_) => log::info!( - "[boot] whatsapp_data::global initialized (workspace={})", - cfg.workspace_dir.display() - ), - Err(e) => log::warn!("[boot] whatsapp_data::global init failed: {e}"), - } - // Seed the people store so people controllers + `people_*` - // tools can read/write. Without this the process-global stays - // empty and every call fails with "people store not - // initialised" (Sentry TAURI-RUST-8NM). Sits inside this - // Ok(cfg) arm so it inherits the wrong-workspace guard above - // (never seed against a Config::default fallback). - match crate::openhuman::people::store::init_from_workspace(&cfg.workspace_dir) { - Ok(_) => log::info!( - "[boot] people::store initialized (workspace={})", - cfg.workspace_dir.display() - ), - Err(e) => log::warn!("[boot] people::store init failed: {e}"), - } - // Prune legacy bundled skills (dev-workflow / github-issue-crusher - // / pr-review-shepherd) that older builds seeded into - // /skills/. OpenHuman no longer ships bundled defaults; - // this removes the stale dirs on upgrade. Idempotent. - crate::openhuman::skills::registry::prune_legacy_default_workflows( - &cfg.workspace_dir, - ); - // Boot-time Sentry user binding — issue #3135. If the user is - // already signed in (typical desktop restart), the auth-profile - // store has their `user_id` *now*, before any background loop - // (Composio sync tick, heartbeat, etc.) fires its first event. - // Reading from the store here means subsequent events carry - // `user.id` even when no `app_state_snapshot` RPC has run yet. - match crate::openhuman::credentials::session_support::build_session_state(&cfg) { - Ok(state) => { - if let Some(uid) = state.user_id.as_deref() { - crate::openhuman::credentials::sentry_scope::bind(uid); - } - } - Err(e) => log::debug!( - "[boot] sentry scope user bind skipped — build_session_state failed: {e}" - ), - } - } - Err(e) => { - log::error!( - "[boot] memory::global + whatsapp_data init SKIPPED — \ - Config::load_or_init failed ({e:#}). Memory persistence is \ - DISABLED for this run; no silent fallback to the default \ - workspace (which would cause chunk loss / cross-workspace \ - bleed-over). Fix config.toml or set OPENHUMAN_WORKSPACE to a \ - writable path, then restart." - ); - } - } - } - - let (resolved_port, port_source) = match port { - Some(p) => (p, "CLI --port"), - None => ( - core_port(), - if std::env::var("OPENHUMAN_CORE_PORT").is_ok() { - "env OPENHUMAN_CORE_PORT" - } else { - "default" - }, - ), - }; - let (resolved_host, host_source) = match host { - Some(h) => (h.to_string(), "CLI --host"), - None => ( - core_host(), - if std::env::var("OPENHUMAN_CORE_HOST") - .ok() - .filter(|s| !s.is_empty()) - .is_some() - { - "env OPENHUMAN_CORE_HOST" - } else { - "default" - }, - ), - }; - - log::debug!( - "[core] Bind resolution: host={resolved_host} (from {host_source}), port={resolved_port} (from {port_source})" - ); - - // Safety check: refuse to bind on a non-loopback address without an - // explicit RPC token. Without this, the entire RPC surface (tool - // execution, file access, credentials) is unauthenticated and reachable - // from the network. See: https://github.com/tinyhumansai/openhuman/issues/1919 - // - // "Explicit token" means any of: - // - An in-memory bearer supplied by the embedded caller via the - // `rpc_token` parameter (the Tauri shell hands its - // `CoreProcessHandle.rpc_token` in this way — see - // `init_rpc_token_with_value`). This never lands on the process env. - // - `OPENHUMAN_CORE_TOKEN` set in the process environment (operator - // config for standalone CLI / Docker / cloud). - // - // Checking only the env var would emit a false security warning whenever - // an embedded caller binds on a non-loopback host with an in-memory - // bearer — the server is already protected in that case. - if crate::openhuman::security::pairing::is_public_bind(&resolved_host) { - let has_in_memory_token = rpc_token - .as_deref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); - let has_env_token = std::env::var(crate::core::auth::CORE_TOKEN_ENV_VAR) - .ok() - .filter(|s| !s.trim().is_empty()) - .is_some(); - // Fail closed (#1919): a non-loopback bind exposes the entire RPC - // surface (tool execution, file access, credentials) to the network, - // so it must be guarded by an OPERATOR-supplied bearer. Only an - // explicit operator token counts here: - // - in-memory handoff from the embedded caller (`rpc_token`), or - // - `OPENHUMAN_CORE_TOKEN` in the process environment. - // The self-generated `core.token` file (which `init_rpc_token` may - // already have seeded into the auth subsystem above) does NOT satisfy - // this requirement: remote clients cannot read that file, so treating - // it as "explicit" would be fail-open. Refuse to bind instead of - // serving an effectively unauthenticated network surface. - let has_explicit_token = has_in_memory_token || has_env_token; - if !has_explicit_token { - log::error!( - "[core] SECURITY: refusing to bind on public address {resolved_host} without an \ - explicit operator-supplied RPC token. Set {} in your environment (or hand the \ - bearer in-memory via the embedded core handle) to secure the RPC endpoint.", - crate::core::auth::CORE_TOKEN_ENV_VAR - ); - eprintln!( - "\n\x1b[1;31m[SECURITY]\x1b[0m Refusing to bind on {resolved_host} without {}.\n\ - The auto-generated {{workspace}}/core.token does NOT secure a public bind —\n\ - remote clients cannot read it. Set {} in your environment to secure the\n\ - RPC endpoint, or bind on a loopback address.\n", - crate::core::auth::CORE_TOKEN_ENV_VAR, - crate::core::auth::CORE_TOKEN_ENV_VAR - ); - anyhow::bail!( - "refusing to bind on non-loopback address {resolved_host} without an explicit \ - operator-supplied RPC token ({})", - crate::core::auth::CORE_TOKEN_ENV_VAR - ); - } - } - - let preferred_port = resolved_port; - let host = resolved_host; - let pick = crate::openhuman::connectivity::rpc::pick_listen_port_for_host( - host.as_str(), - preferred_port, + let mut services = crate::core::runtime::ServiceSet::desktop(); + services.socketio = socketio_enabled; + run_server_with_services( + host, + port, + services, + embedded_core, + shutdown_token, + ready_tx, + rpc_token, ) .await - .map_err(|err| { - log::error!("[core] Failed to bind to {host}:{preferred_port}: {err}"); - anyhow::Error::new(err) - })?; - let listen_port = pick.port; - let bind_addr = format!("{host}:{listen_port}"); - let listener = pick.listener; - - // Synchronize OPENHUMAN_CORE_RPC_URL with the actual bound port so - // connectivity::rpc::resolve_listen_port() (used by openhuman.connectivity_diag) - // reports the live listener instead of the originally-requested port when - // fallback engaged. Embedded path also calls this via apply_embedded_ready_signal, - // but the standalone CLI never did before — leaving diag stale on fallback. - // - // SAFETY: set_var is process-global; this runs once during bind and the - // standalone CLI doesn't share its env with concurrent test threads. - unsafe { - std::env::set_var("OPENHUMAN_CORE_RPC_URL", format!("http://{bind_addr}/rpc")); - } - - let app = build_core_http_router(socketio_enabled); +} - // --- Core runtime bootstrap -------------------------------------------- - // Map the legacy `embedded_core` boolean to the typed [`HostKind`] the - // bootstrap path now takes. Embedded == Tauri shell; standalone splits - // CLI / Docker via `HostKind::detect_standalone`. +async fn run_server_with_services( + host: Option<&str>, + port: Option, + services: crate::core::runtime::ServiceSet, + embedded_core: bool, + shutdown_token: Option, + ready_tx: Option>, + rpc_token: Option>, +) -> anyhow::Result<()> { + // `run_server_inner` is now a thin shim over the CoreBuilder/CoreRuntime + // composition (Phase 1). It reproduces the legacy behavior exactly: all + // background services on (`ServiceSet::desktop`), Socket.IO per the caller + // flag, and the legacy `embedded_core` → `HostKind` mapping (embedded == + // Tauri shell; standalone splits CLI / Docker via `detect_standalone`). + // See `docs/plans/pluggable-core/phase-1-corebuilder.md`. let host_kind = if embedded_core { crate::core::types::HostKind::TauriShell } else { crate::core::types::HostKind::detect_standalone() }; - bootstrap_core_runtime(host_kind).await; - - log::info!( - "[core] OpenHuman core is ready — listening on http://{bind_addr} (version {})", - env!("CARGO_PKG_VERSION") - ); - log::info!("[rpc:http] JSON-RPC — POST http://{bind_addr}/rpc (JSON-RPC 2.0)"); - if socketio_enabled { - log::info!("[rpc:socketio] Socket.IO — ws://{bind_addr}/socket.io/ (same HTTP server)"); - } else { - log::info!("[rpc:socketio] disabled (--jsonrpc-only)"); - } - - if let Some(tx) = ready_tx { - let _ = tx.send(EmbeddedReadySignal { - port: listen_port, - fallback_from: pick.fallback_from, - }); - } - - // Background bootstrap for services — gated on login state. - // - // Heavy services (local AI, voice, screen intelligence, autocomplete) - // are only started when a user is logged in. If no user session exists - // on disk, startup is deferred until the login handler in - // `credentials::ops::store_session()` triggers it. - tokio::spawn(async move { - match crate::openhuman::config::Config::load_or_init().await { - Ok(config) => { - if embedded_core { - log::debug!("[core] embedded core startup"); - } else { - log::debug!("[core] desktop core startup"); - } - - // Register autocomplete shutdown hook so the engine (and its - // Swift overlay helper) are stopped cleanly on process exit. - // This is unconditional — the hook should fire regardless of - // whether the user is currently logged in. - crate::core::shutdown::register(|| async { - let engine = crate::openhuman::autocomplete::global_engine(); - let status = engine.status().await; - if status.running { - log::info!( - "[core] stopping autocomplete engine (phase={})", - status.phase - ); - engine.stop(None).await; - log::info!("[core] autocomplete engine stopped"); - } - }); - - // Check if a user is already logged in from a previous session. - let already_logged_in = crate::openhuman::config::default_root_openhuman_dir() - .ok() - .and_then(|root| crate::openhuman::config::read_active_user_id(&root)) - .is_some(); - - if already_logged_in { - // User has an active session — start all services now. - log::info!("[services] existing session found, starting services"); - crate::openhuman::credentials::ops::start_login_gated_services(&config).await; - - // Subconscious engine + heartbeat. - if !config.heartbeat.enabled { - log::info!("[subconscious] disabled by config (heartbeat.enabled = false)"); - } else { - match crate::openhuman::subconscious::registry::bootstrap_after_login() - .await - { - Ok(()) => log::info!( - "[subconscious] bootstrapped on startup (existing session)" - ), - Err(e) => log::warn!("[subconscious] startup bootstrap failed: {e}"), - } - } - } else { - log::info!( - "[services] no active session — deferring service startup until login" - ); - } - } - Err(err) => { - log::warn!("[core] config load failed, skipping service startup: {err}"); - } - } - }); - - // Periodic self-update checker (default: every 1 hour). - tokio::spawn(async { - match crate::openhuman::config::Config::load_or_init().await { - Ok(config) => { - crate::openhuman::update::scheduler::run(config.update).await; - } - Err(err) => { - log::warn!("[core] config load failed, skipping update scheduler: {err}"); - } - } - }); - - // Cron scheduler — polls due_jobs() every ~5s and executes them automatically. - tokio::spawn(async { - match crate::openhuman::config::Config::load_or_init().await { - Ok(config) => { - if !config.cron.enabled { - log::info!("[cron] scheduler disabled via config; skipping"); - return; - } - log::info!("[cron] spawning scheduler polling loop"); - // Ensure proactive agent jobs (e.g. the autonomous bounty job) - // exist for already-onboarded users upgrading from a build that - // predates them — otherwise their Settings toggle stays hidden. - // Idempotent; no-op until onboarding is complete. - if let Err(e) = crate::openhuman::cron::seed::seed_proactive_agents_on_boot(&config) - { - log::warn!("[cron] boot seed of proactive agent jobs failed: {e}"); - } - // Re-register the cron job for every enabled, schedule-trigger - // flow (issue B2) — idempotent, so a flow whose binding - // predates this feature (or was otherwise lost) gets its - // schedule re-registered without the user re-toggling it. - if let Err(e) = - crate::openhuman::flows::ops::reconcile_schedule_triggers_on_boot(&config).await - { - log::warn!( - "[flows] boot reconciliation of schedule-trigger cron jobs failed: {e}" - ); - } - if let Err(e) = crate::openhuman::cron::scheduler::run(config).await { - log::error!("[cron] scheduler loop ended with error: {e}"); - } - } - Err(err) => { - log::warn!("[core] config load failed, skipping cron scheduler: {err}"); - } - } - }); - - // Realtime channel listeners (Telegram getUpdates, Discord gateway, etc.) live in - // `start_channels`. Without this task, `openhuman run` would only expose RPC while - // inbound bot messages are never polled. - if std::env::var("OPENHUMAN_DISABLE_CHANNEL_LISTENERS") - .ok() - .filter(|s| s == "1" || s.eq_ignore_ascii_case("true")) - .is_none() - { - tokio::spawn(async move { - let config = match crate::openhuman::config::Config::load_or_init().await { - Ok(c) => c, - Err(e) => { - log::warn!("[channels] could not load config for listeners: {e}"); - return; - } - }; - if !config.channels_config.has_listening_integrations() { - log::debug!( - "[channels] no channel integrations configured; not spawning listeners" - ); - return; - } - log::info!("[channels] spawning in-process realtime listeners (Telegram, Discord, …)"); - if let Err(e) = crate::openhuman::channels::start_channels(config).await { - log::error!("[channels] start_channels ended with error: {e}"); - } - }); - } else { - log::info!("[channels] OPENHUMAN_DISABLE_CHANNEL_LISTENERS set — skipping start_channels"); - } - - if let Some(shutdown_token) = shutdown_token { - log::info!("[core] embedded server waiting on cancellation token for graceful shutdown"); - axum::serve(listener, app) - .with_graceful_shutdown(async move { - shutdown_token.cancelled().await; - }) - .await?; - } else { - axum::serve(listener, app) - .with_graceful_shutdown(crate::core::shutdown::signal()) - .await?; + let token = match rpc_token { + Some(token) => crate::core::runtime::TokenSource::Fixed(token), + None => crate::core::runtime::TokenSource::EnvOrFile, + }; + let mut builder = crate::core::runtime::CoreBuilder::new(host_kind) + .token(token) + .services(services); + if let Some(host) = host { + builder = builder.host(host); } - - // Server has stopped accepting and in-flight requests drained. - // Kill any `ollama serve` openhuman itself spawned (no-op when the - // daemon was externally managed) and clear the spawn marker so the - // next launch doesn't try to reclaim a daemon that's already dead. - // Bounded so a wedged Ollama can't hold up app shutdown. - if let Some(svc) = crate::openhuman::inference::local::try_global() { - let cfg = crate::openhuman::config::Config::load_or_init() - .await - .unwrap_or_default(); - log::info!("[core] shutdown: cleaning up openhuman-owned ollama if any"); - let shutdown_fut = svc.shutdown_owned_ollama(&cfg); - if tokio::time::timeout(std::time::Duration::from_secs(2), shutdown_fut) - .await - .is_err() - { - log::warn!("[core] shutdown: ollama cleanup exceeded 2s budget; proceeding with exit"); - } + if let Some(port) = port { + builder = builder.port(port); } - Ok(()) + let runtime = builder.build().await?; + runtime.serve(ready_tx, shutdown_token).await } /// Registers all long-lived domain event-bus subscribers exactly once. @@ -2284,30 +1861,10 @@ fn register_domain_subscribers( crate::openhuman::composio::register_composio_trigger_subscriber(); crate::openhuman::agent_meetings::calendar::register_meet_calendar_subscriber(); crate::openhuman::agent_meetings::bus::register_meeting_event_subscriber(); - crate::openhuman::composio::start_periodic_sync(); - // Workspace-kind memory sources (GitHub repos, folders, RSS, web - // pages) get their own cadence loop — the Composio scheduler above - // only walks Composio connections, so without this they only sync - // on manual "Sync now" and silently go stale. - crate::openhuman::memory_sync::workspace::start_workspace_periodic_sync(); // Orchestration: ingest tiny.place harness session DMs off the stream bus. crate::openhuman::orchestration::register_orchestration_ingest_subscriber(); - // Orchestration: relay DMs are poll-only (`/messages`) and never traverse - // `/inbox/stream`, so this poller is the delivery path that surfaces - // inbound DMs from paired agents; ingest forwards them to the hosted brain. - crate::openhuman::orchestration::start_message_drain_supervisor(); // Task-sources proactive ingestion: connection-created hook + poll. crate::openhuman::task_sources::bus::register_task_sources_subscriber(); - crate::openhuman::task_sources::start_periodic_poll(); - // Board poller: dispatch the highest-urgency `todo` card on the - // task-sources board (catch-all for cards without a proactive trigger). - crate::openhuman::agent::task_dispatcher::start_board_poller(); - // Seed memory_sources with active Composio connections so the - // user sees their connected integrations as memory sources by - // default. Best-effort: failure is logged but does not block startup. - tokio::spawn(async { - crate::openhuman::memory_sources::reconcile::ensure_composio_sources().await; - }); // Initialise the scheduler gate before any background AI workers // start so they observe a real policy on their first iteration // (otherwise they fall back to `Policy::Normal` and miss the @@ -2361,8 +1918,6 @@ fn register_domain_subscribers( ); } - crate::openhuman::memory_queue::start(config.clone()); - // Restart requests go through a subscriber so every trigger path shares // the same respawn logic. crate::openhuman::service::bus::register_restart_subscriber(); @@ -2425,18 +1980,20 @@ fn register_domain_subscribers( /// env override is ignored and a domain event is published so the UI can /// surface a banner; under CLI / Docker the override is honored (with a /// noisy log + a domain event so any connected dashboard can flag it). -pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) { +pub async fn bootstrap_core_runtime( + host_kind: crate::core::types::HostKind, + config: Option, +) { use crate::openhuman::socket::{set_global_socket_manager, SocketManager}; use std::sync::Arc; // `embedded_core` derived from host_kind so the rest of the function (which // already keys behavior off the boolean) stays unchanged. let embedded_core = host_kind.is_desktop_shell(); - let mut cfg = match crate::openhuman::config::Config::load_or_init().await { - Ok(cfg) => cfg, - Err(e) => { - log::error!("[runtime] Failed to load config for socket manager: {e}"); - return; - } + let Some(mut cfg) = config else { + log::error!( + "[runtime] Config unavailable for runtime bootstrap; workspace-bound startup skipped" + ); + return; }; let workspace_dir = cfg.workspace_dir.clone(); @@ -2449,24 +2006,6 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) { // cannot double-subscribe. register_domain_subscribers(workspace_dir.clone(), cfg.clone(), embedded_core); - // One-time first-run initialization (managed Python runtime, spaCy model, - // managed Node runtime). Spawned AFTER subscribers are live but does NOT - // block the ready signal — the core becomes RPC-ready immediately and the - // frontend watches per-step progress via `openhuman.harness_init_status`. - // On a warm host every step's `is_done` probe passes and this settles - // instantly. See `crate::openhuman::harness_init`. - { - let cfg_for_init = cfg.clone(); - tokio::spawn(async move { - crate::openhuman::harness_init::run_harness_init(cfg_for_init).await; - }); - } - - // Warm the remote skills catalog on every core load. This updates the - // cached registry used by skill discovery/search, but runs best-effort in - // the background so Hermes/network latency cannot block core readiness. - crate::openhuman::skill_registry::ops::start_boot_catalog_refresh(); - // --- Turn-state recovery ------------------------------------------- // Any per-thread turn snapshots left on disk from a previous process // are stale by definition — there is no live driver to resume them. @@ -2699,79 +2238,50 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) { // --- Workspace migrations -------------------------------------------- crate::openhuman::startup::run_workspace_migrations(&workspace_dir); - // --- MCP registry boot-spawn ----------------------------------------- - // Bring up every locally-installed MCP server's stdio subprocess so its - // tools are available to the agent as soon as the core is ready. - // Errors are logged per-server and never block boot. Runs as a - // background task so a slow npx install can't gate startup. - { - let cfg = cfg.clone(); - tokio::spawn(async move { - crate::openhuman::mcp_registry::boot::spawn_installed_servers(&cfg).await; - }); - } - - // --- MCP registry reconnect supervisor (#3312) ----------------------- - // Keep installed MCP servers connected for the life of the process: - // transports drop silently over long uptimes (subprocess exits, HTTP - // session expires) and the boot spawn above only runs once. The - // supervisor periodically probes each connection and reconnects dropped - // ones with per-server backoff. First tick is delayed so it doesn't race - // the boot spawn. - // - // Guarded by `Once`: `bootstrap_core_runtime` is documented idempotent, and - // unlike the one-shot boot spawn above the supervisor is an infinite tick - // loop — a second instance would race the first on the shared connections - // registry (duplicate probes, reconnect thrashing, nondeterministic backoff). - { - use std::sync::Once; - static SUPERVISOR_SPAWNED: Once = Once::new(); - SUPERVISOR_SPAWNED.call_once(|| { - let cfg = cfg.clone(); - tokio::spawn(async move { - crate::openhuman::mcp_registry::supervisor::run(cfg).await; - }); - }); - } - // --- Socket manager bootstrap --- let socket_mgr = Arc::new(SocketManager::new()); set_global_socket_manager(socket_mgr.clone()); log::info!("[socket] SocketManager initialized and registered globally"); +} - // Auto-connect socket to backend if a session token is already stored. - // This runs in the background so it doesn't block server startup. - tokio::spawn(async move { - log::info!("[socket] Checking for stored session to auto-connect..."); - let config = match crate::openhuman::config::Config::load_or_init().await { - Ok(c) => c, - Err(e) => { - log::debug!("[socket] Config not available for auto-connect: {e}"); - return; - } - }; - let api_url = crate::api::config::effective_backend_api_url(&config.api_url); - let token = match crate::api::jwt::get_session_token(&config) { - Ok(Some(t)) => t, - Ok(None) => { - log::info!("[socket] No session token stored — skipping auto-connect (will connect after login)"); - return; - } - Err(e) => { - log::warn!("[socket] Failed to read session token: {e}"); - return; - } - }; - log::info!( - "[socket] Session token found — auto-connecting to {}", - api_url +/// Starts selected background jobs after the runtime has entered `serve()`. +/// +/// This deliberately sits outside [`bootstrap_core_runtime`]: embedders may call +/// `CoreBuilder::build()` only to use in-process RPC, and a failed listener bind +/// must not leave pollers, one-shot jobs, MCP processes, or socket reconnect work +/// running without a live runtime. +pub fn start_core_runtime_services( + services: crate::core::runtime::ServiceSet, + config: Option<&crate::openhuman::config::Config>, +) { + let Some(cfg) = config else { + log::error!( + "[runtime] Config unavailable for runtime service startup; selected services skipped" ); - if let Err(e) = socket_mgr.connect(&api_url, &token).await { - log::error!("[socket] Auto-connect failed: {e}"); - } else { - log::info!("[socket] Auto-connect initiated successfully"); + return; + }; + + // Long-lived bootstrap loops selected by ServiceSet. + crate::core::runtime::services::start_bootstrap_jobs(services, cfg); + + // One-time first-run initialization (managed Python runtime, spaCy model, + // managed Node runtime). Spawned AFTER subscribers are live but does NOT + // block the ready signal — the core becomes RPC-ready immediately and the + // frontend watches per-step progress via `openhuman.harness_init_status`. + // On a warm host every step's `is_done` probe passes and this settles + // instantly. See `crate::openhuman::harness_init`. + crate::core::runtime::services::start_boot_once_jobs(services, cfg); + + match crate::openhuman::socket::global_socket_manager() { + Some(socket_mgr) => { + crate::core::runtime::services::spawn_socket_auto_connect(services, socket_mgr.clone()); } - }); + None => { + log::warn!( + "[socket] SocketManager unavailable during runtime service startup; auto-connect skipped" + ); + } + } } /// JSON-serializable wrapper for the entire RPC schema dump. diff --git a/src/core/runtime/builder.rs b/src/core/runtime/builder.rs new file mode 100644 index 0000000000..5ecf718025 --- /dev/null +++ b/src/core/runtime/builder.rs @@ -0,0 +1,446 @@ +//! `CoreBuilder` → `CoreRuntime`: the embeddable composition surface. +//! +//! This is the first-class library API for hosting the OpenHuman core. It +//! splits the monolithic `run_server_inner` into two phases: +//! +//! 1. [`CoreBuilder::build`] — *initialization only*: register controllers, load +//! the master key, seed the RPC bearer, initialize workspace-bound stores, +//! and run the pure-registration part of [`bootstrap_core_runtime`]. No port +//! is bound and `ServiceSet::none` / `ServiceSet::headless_api` start no +//! background loops. After `build`, [`CoreRuntime::invoke`] can dispatch any +//! RPC method in-process, and agent turns can run — so a harness-only embedder +//! (`ServiceSet::none`) needs nothing more. +//! 2. [`CoreRuntime::serve`] — *transport + background services*: bind the HTTP +//! listener, mount the router, fire the readiness signal, spawn the selected +//! background services, and serve until shutdown. +//! +//! The legacy entry points (`run_server`, `run_server_embedded`, +//! `run_server_embedded_with_ready`) are now thin shims over this builder, so +//! the desktop shell, the standalone CLI, and any new embedder share one path. +//! See `docs/plans/pluggable-core/phase-1-corebuilder.md`. + +use std::sync::Arc; + +use tokio_util::sync::CancellationToken; + +use crate::core::jsonrpc::{self, EmbeddedReadySignal}; +use crate::core::runtime::context::CoreContext; +use crate::core::types::HostKind; +use crate::openhuman::config::Config; + +/// Selects which background services and transports a [`CoreRuntime`] runs. +/// +/// Each flag is independent. Presets cover the common hosts: +/// [`ServiceSet::desktop`] (everything — the Tauri shell / standalone CLI), +/// [`ServiceSet::headless_api`] (HTTP JSON-RPC only — single-core cloud), and +/// [`ServiceSet::none`] (no transport, no background work — library / harness). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ServiceSet { + /// Bind the axum HTTP server and serve `POST /rpc` (+ the other core routes). + pub rpc_http: bool, + /// Mount the Socket.IO realtime layer on the HTTP server (requires `rpc_http`). + pub socketio: bool, + /// Spawn the cron scheduler (still gated at runtime by `config.cron.enabled`). + pub cron: bool, + /// Spawn realtime channel listeners (Telegram, Discord, …). + pub channels: bool, + /// Spawn login-gated services (local AI, voice, autocomplete) + subconscious/heartbeat. + pub heartbeat: bool, + /// Spawn the periodic self-update checker. + pub update_scheduler: bool, + /// Start memory queue workers during runtime bootstrap. + pub memory_queue: bool, + /// Run one-shot harness initialization during runtime bootstrap. + pub harness_init: bool, + /// Refresh the skill catalog during runtime bootstrap. + pub skill_catalog_refresh: bool, + /// Boot installed MCP servers and supervise reconnects during runtime bootstrap. + pub mcp_boot: bool, +} + +impl ServiceSet { + /// Everything on — the desktop shell and the standalone `openhuman-core run`. + pub fn desktop() -> Self { + Self { + rpc_http: true, + socketio: true, + cron: true, + channels: true, + heartbeat: true, + update_scheduler: true, + memory_queue: true, + harness_init: true, + skill_catalog_refresh: true, + mcp_boot: true, + } + } + + /// HTTP JSON-RPC only — a single-core cloud/server deployment. No Socket.IO, + /// no cron/channels/heartbeat; the supervisor decides those per plan. + pub fn headless_api() -> Self { + Self { + rpc_http: true, + socketio: false, + cron: false, + channels: false, + heartbeat: false, + update_scheduler: false, + memory_queue: false, + harness_init: false, + skill_catalog_refresh: false, + mcp_boot: false, + } + } + + /// No transport and no background services — for library / harness embedders + /// that only drive the core through [`CoreRuntime::invoke`] and agent turns. + pub fn none() -> Self { + Self { + rpc_http: false, + socketio: false, + cron: false, + channels: false, + heartbeat: false, + update_scheduler: false, + memory_queue: false, + harness_init: false, + skill_catalog_refresh: false, + mcp_boot: false, + } + } +} + +/// How the per-process RPC bearer token is seeded. +pub enum TokenSource { + /// An in-memory bearer supplied by the embedder (the Tauri shell hands its + /// `CoreProcessHandle.rpc_token` this way). Seeded via + /// [`crate::core::auth::init_rpc_token_with_value`] — never crosses the + /// process environment. + Fixed(Arc), + /// Standalone fallback: read `OPENHUMAN_CORE_TOKEN` from the environment when + /// present (operator config), otherwise generate a fresh token and write + /// `{root}/core.token` (0o600 on Unix) so CLI callers can authenticate. + EnvOrFile, +} + +/// Builder for a [`CoreRuntime`]. Construct with [`CoreBuilder::new`], then +/// [`CoreBuilder::build`] to initialize the core. +pub struct CoreBuilder { + host_kind: HostKind, + token: TokenSource, + services: ServiceSet, + host: Option, + port: Option, +} + +impl CoreBuilder { + /// Start a builder for the given host kind. Defaults: [`TokenSource::EnvOrFile`] + /// and [`ServiceSet::desktop`]. + pub fn new(host_kind: HostKind) -> Self { + Self { + host_kind, + token: TokenSource::EnvOrFile, + services: ServiceSet::desktop(), + host: None, + port: None, + } + } + + /// Choose which background services / transports [`CoreRuntime::serve`] runs. + pub fn services(mut self, services: ServiceSet) -> Self { + self.services = services; + self + } + + /// Choose how the RPC bearer token is seeded. + pub fn token(mut self, token: TokenSource) -> Self { + self.token = token; + self + } + + /// Override the bind host (default: `OPENHUMAN_CORE_HOST` env or `127.0.0.1`). + pub fn host(mut self, host: impl Into) -> Self { + self.host = Some(host.into()); + self + } + + /// Override the bind port (default: `OPENHUMAN_CORE_PORT` env or `7788`). + pub fn port(mut self, port: u16) -> Self { + self.port = Some(port); + self + } + + /// Initialize the core: register controllers, load the master key, seed the + /// RPC bearer, initialize workspace-bound stores, and run + /// [`bootstrap_core_runtime`]. Binds no port and starts no transport. + /// + /// The init sequence itself is owned by [`CoreContext::init`] (Phase 2, + /// Stage A). + pub async fn build(self) -> anyhow::Result { + let (ctx, has_operator_token, config) = + CoreContext::init(self.host_kind, &self.token).await?; + + Ok(CoreRuntime { + ctx, + config, + services: self.services, + has_operator_token, + host: self.host, + port: self.port, + }) + } +} + +/// A built, initialized core. Dispatch RPC in-process with [`CoreRuntime::invoke`], +/// or run the selected transport + background services with [`CoreRuntime::serve`]. +pub struct CoreRuntime { + ctx: Arc, + config: Option, + services: ServiceSet, + has_operator_token: bool, + host: Option, + port: Option, +} + +impl CoreRuntime { + /// The services/transports this runtime is configured to run. + pub fn services(&self) -> ServiceSet { + self.services + } + + /// The initialized core context (host identity + resolved workspace). + pub fn context(&self) -> &Arc { + &self.ctx + } + + /// Dispatch an RPC method in-process — the same path the HTTP `/rpc` handler + /// and the CLI use ([`jsonrpc::invoke_method`]). No network involved. + pub async fn invoke( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + CoreContext::scope( + Arc::clone(&self.ctx), + jsonrpc::invoke_method(jsonrpc::default_state(), method, params), + ) + .await + } + + /// Spawn the selected background services and, when `rpc_http` is set, bind + /// the HTTP listener and serve until shutdown. + /// + /// When `rpc_http` is not selected this returns immediately (a harness-only + /// embedder has no transport to run); background services selected in the + /// [`ServiceSet`] are still spawned. + pub async fn serve( + &self, + ready_tx: Option>, + shutdown_token: Option, + ) -> anyhow::Result<()> { + if !self.services.rpc_http { + // No transport: just spawn the selected background services and + // return. The caller owns the process lifetime. + self.start_selected_services(); + return Ok(()); + } + + // --- Host / port resolution --- + let (resolved_port, port_source) = match self.port { + Some(p) => (p, "builder port"), + None => ( + jsonrpc::core_port(), + if std::env::var("OPENHUMAN_CORE_PORT").is_ok() { + "env OPENHUMAN_CORE_PORT" + } else { + "default" + }, + ), + }; + let (resolved_host, host_source) = match &self.host { + Some(h) => (h.clone(), "builder host"), + None => ( + jsonrpc::core_host(), + if std::env::var("OPENHUMAN_CORE_HOST") + .ok() + .filter(|s| !s.is_empty()) + .is_some() + { + "env OPENHUMAN_CORE_HOST" + } else { + "default" + }, + ), + }; + + log::debug!( + "[core] Bind resolution: host={resolved_host} (from {host_source}), port={resolved_port} (from {port_source})" + ); + + // Safety check: refuse to bind on a non-loopback address without an + // explicit operator-supplied RPC token. Without this, the entire RPC + // surface (tool execution, file access, credentials) is unauthenticated + // and reachable from the network. See issue #1919. The self-generated + // {workspace}/core.token does NOT count — remote clients cannot read it, + // so treating it as "explicit" would be fail-open. + if crate::openhuman::security::pairing::is_public_bind(&resolved_host) + && !self.has_operator_token + { + log::error!( + "[core] SECURITY: refusing to bind on public address {resolved_host} without an \ + explicit operator-supplied RPC token. Set {} in your environment (or hand the \ + bearer in-memory via the embedded core handle) to secure the RPC endpoint.", + crate::core::auth::CORE_TOKEN_ENV_VAR + ); + eprintln!( + "\n\x1b[1;31m[SECURITY]\x1b[0m Refusing to bind on {resolved_host} without {}.\n\ + The auto-generated {{workspace}}/core.token does NOT secure a public bind —\n\ + remote clients cannot read it. Set {} in your environment to secure the\n\ + RPC endpoint, or bind on a loopback address.\n", + crate::core::auth::CORE_TOKEN_ENV_VAR, + crate::core::auth::CORE_TOKEN_ENV_VAR + ); + anyhow::bail!( + "refusing to bind on non-loopback address {resolved_host} without an explicit \ + operator-supplied RPC token ({})", + crate::core::auth::CORE_TOKEN_ENV_VAR + ); + } + + let preferred_port = resolved_port; + let host = resolved_host; + let pick = crate::openhuman::connectivity::rpc::pick_listen_port_for_host( + host.as_str(), + preferred_port, + ) + .await + .map_err(|err| { + log::error!("[core] Failed to bind to {host}:{preferred_port}: {err}"); + anyhow::Error::new(err) + })?; + let listen_port = pick.port; + let bind_addr = format!("{host}:{listen_port}"); + let listener = pick.listener; + + // Synchronize OPENHUMAN_CORE_RPC_URL with the actual bound port so + // connectivity::rpc::resolve_listen_port() reports the live listener + // instead of the originally-requested port when fallback engaged. + // + // SAFETY: set_var is process-global; this runs once during bind. Flagged + // in the pluggable-core drift ledger as single-runtime-per-process. + unsafe { + std::env::set_var("OPENHUMAN_CORE_RPC_URL", format!("http://{bind_addr}/rpc")); + } + + let ctx = Arc::clone(&self.ctx); + let app = jsonrpc::build_core_http_router(self.services.socketio).layer( + axum::middleware::from_fn( + move |req: axum::extract::Request, next: axum::middleware::Next| { + let ctx = Arc::clone(&ctx); + async move { CoreContext::scope(ctx, next.run(req)).await } + }, + ), + ); + + log::info!( + "[core] OpenHuman core is ready — listening on http://{bind_addr} (version {})", + env!("CARGO_PKG_VERSION") + ); + log::info!("[rpc:http] JSON-RPC — POST http://{bind_addr}/rpc (JSON-RPC 2.0)"); + if self.services.socketio { + log::info!("[rpc:socketio] Socket.IO — ws://{bind_addr}/socket.io/ (same HTTP server)"); + } else { + log::info!("[rpc:socketio] disabled (--jsonrpc-only)"); + } + + if let Some(tx) = ready_tx { + let _ = tx.send(EmbeddedReadySignal { + port: listen_port, + fallback_from: pick.fallback_from, + }); + } + + // Background services — gated by the ServiceSet. + self.start_selected_services(); + + if let Some(shutdown_token) = shutdown_token { + log::info!( + "[core] embedded server waiting on cancellation token for graceful shutdown" + ); + axum::serve(listener, app) + .with_graceful_shutdown(async move { + shutdown_token.cancelled().await; + }) + .await?; + } else { + axum::serve(listener, app) + .with_graceful_shutdown(crate::core::shutdown::signal()) + .await?; + } + + // Server has stopped accepting and in-flight requests drained. Kill any + // `ollama serve` openhuman itself spawned (no-op when externally + // managed) so the next launch doesn't try to reclaim a dead daemon. + // Bounded so a wedged Ollama can't hold up app shutdown. + if let Some(svc) = crate::openhuman::inference::local::try_global() { + let cfg = crate::openhuman::config::Config::load_or_init() + .await + .unwrap_or_default(); + log::info!("[core] shutdown: cleaning up openhuman-owned ollama if any"); + let shutdown_fut = svc.shutdown_owned_ollama(&cfg); + if tokio::time::timeout(std::time::Duration::from_secs(2), shutdown_fut) + .await + .is_err() + { + log::warn!( + "[core] shutdown: ollama cleanup exceeded 2s budget; proceeding with exit" + ); + } + } + + Ok(()) + } + + /// Spawn each selected background service. Selection is by [`ServiceSet`]; + /// each service keeps its own runtime config gate. + fn start_selected_services(&self) { + use crate::core::runtime::services; + jsonrpc::start_core_runtime_services(self.services, self.config.as_ref()); + + if self.services.heartbeat { + services::spawn_login_gated_services(self.ctx.host_kind().is_desktop_shell()); + } + if self.services.update_scheduler { + services::spawn_update_scheduler(); + } + if self.services.cron { + services::spawn_cron_service(); + } + if self.services.channels { + services::spawn_channels_service(); + } + } +} + +#[cfg(test)] +mod tests { + use super::ServiceSet; + + #[test] + fn boot_jobs_are_independent_from_runtime_service_flags() { + let mut custom = ServiceSet::none(); + custom.rpc_http = true; + custom.heartbeat = true; + custom.update_scheduler = true; + assert!(!custom.memory_queue); + assert!(!custom.harness_init); + assert!(!custom.skill_catalog_refresh); + assert!(!custom.mcp_boot); + + let desktop = ServiceSet::desktop(); + assert!(desktop.memory_queue); + assert!(desktop.harness_init); + assert!(desktop.skill_catalog_refresh); + assert!(desktop.mcp_boot); + } +} diff --git a/src/core/runtime/context.rs b/src/core/runtime/context.rs new file mode 100644 index 0000000000..d9b4b6d184 --- /dev/null +++ b/src/core/runtime/context.rs @@ -0,0 +1,497 @@ +//! Core initialization context. +//! +//! [`CoreContext`] owns the core's initialization *order* (Phase 2, Stage A): +//! register controllers, load the master key, seed the RPC bearer, initialize +//! the workspace-bound stores, and run pure `bootstrap_core_runtime` registration. Today it is a +//! facade — the store init still targets the process globals — but centralizing +//! the sequence here is the seam the later stages build on (handler-threaded +//! context, per-context stores). See `docs/plans/pluggable-core/phase-2-corecontext.md`. +//! +//! [`init_stores`] initializes the process-global stores bound to a single +//! resolved workspace directory (memory, image attachments, WhatsApp data, +//! people) plus the boot-time Sentry user binding. It preserves the exact +//! behavior and ordering of the original inline `run_server_inner` block, +//! including the deliberate wrong-workspace guard (never seed against a +//! `Config::default` fallback — Sentry OPENHUMAN-CORE-48 / TAURI-RUST-8NM). + +use std::future::Future; +use std::sync::{Arc, OnceLock, RwLock}; + +use crate::core::runtime::TokenSource; +use crate::core::types::HostKind; + +/// The process-wide default context — the first one built. Callers that dispatch +/// RPC without an explicit per-call context (the desktop shell, the CLI, tests) +/// resolve to this. Multi-tenant hosts override it per dispatch via +/// [`CoreContext::scope`]. +static DEFAULT_CONTEXT: OnceLock> = OnceLock::new(); + +tokio::task_local! { + /// The context active for the current dispatch, set by [`CoreContext::scope`] + /// at the `try_invoke_registered_rpc` chokepoint. Absent outside a scope — + /// [`CoreContext::current`] then falls back to [`DEFAULT_CONTEXT`]. + static CURRENT_CONTEXT: Arc; +} + +/// A built, initialized core context. Holds the identity of the host and the +/// resolved workspace directory; created by [`CoreContext::init`]. +/// +/// Handlers reach the context for the current dispatch through +/// [`CoreContext::current`] rather than a threaded parameter — the ambient +/// context is established once per RPC at the dispatch chokepoint. This keeps +/// controller handlers as bare `fn` pointers (no per-handler signature churn) +/// while giving every handler a path to per-context state. +/// +/// Stage A/B: state that today still lives in process globals is reached through +/// the globals as before; a domain migrates by reading its store handle off the +/// context ([`CoreContext::current`]) instead of the global. Once a domain's +/// state lives on the context, two contexts dispatched under distinct +/// [`CoreContext::scope`]s read isolated state — the Phase 3 exit criterion. +pub struct CoreContext { + host_kind: HostKind, + workspace_dir: RwLock>, +} + +impl CoreContext { + /// Run the core initialization sequence and return the context plus whether + /// an operator-supplied RPC bearer exists (for the public-bind safety check + /// in `CoreRuntime::serve`) plus the loaded config, when boot reached + /// workspace-bound init. Order is load-bearing and mirrors the original + /// `run_server_inner` sequence: + /// + /// 1. register controllers, 2. master key, 3. AgentBox GMI provider, + /// 4. seed RPC bearer, 5. workspace stores ([`init_stores`]), + /// 6. pure runtime registration. + pub async fn init( + host_kind: HostKind, + token: &TokenSource, + ) -> anyhow::Result<( + Arc, + bool, + Option, + )> { + // 1. Ensure all controllers are registered before anything dispatches. + let _ = crate::core::all::all_registered_controllers(); + + // 2. Load the master encryption key before any config/credential op that + // needs to decrypt secrets. No-op if already called (e.g. from + // run_core_from_args for the CLI). + crate::openhuman::keyring::init_master_key(); + + // 3. AgentBox GMI MaaS provider bridge — no-op when env vars absent. Must + // run before the router mounts the AgentBox routes so the inference + // catalog knows about "gmi-maas" by the time `/run` accepts traffic. + crate::openhuman::agentbox::register_gmi_provider_if_present(); + + // 4. Seed the per-process RPC bearer. `Fixed` seeds the in-memory value + // directly (never touches the env); `EnvOrFile` reads + // OPENHUMAN_CORE_TOKEN or generates + writes {root}/core.token. + // + // `has_operator_token` records whether an OPERATOR-supplied bearer + // exists (in-memory handoff or env var). The self-generated core.token + // file does NOT count — remote clients cannot read it — so it must not + // satisfy the public-bind safety check in `serve`. + let has_operator_token = match token { + TokenSource::Fixed(token) => { + crate::core::auth::init_rpc_token_with_value(token)?; + !token.trim().is_empty() + } + TokenSource::EnvOrFile => { + let token_dir = crate::openhuman::config::default_root_openhuman_dir() + .unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".openhuman") + }); + crate::core::auth::init_rpc_token(&token_dir)?; + std::env::var(crate::core::auth::CORE_TOKEN_ENV_VAR) + .ok() + .filter(|s| !s.trim().is_empty()) + .is_some() + } + }; + + // 5. Resolve config once, then initialize workspace-bound stores + // (memory, attachments, whatsapp, people) with that exact workspace. + let config = match crate::openhuman::config::Config::load_or_init().await { + Ok(cfg) => { + init_stores(&cfg).await; + Some(cfg) + } + Err(e) => { + log::error!( + "[boot] memory::global + whatsapp_data init SKIPPED — \ + Config::load_or_init failed ({e:#}). Memory persistence is \ + DISABLED for this run; no silent fallback to the default \ + workspace (which would cause chunk loss / cross-workspace \ + bleed-over). Fix config.toml or set OPENHUMAN_WORKSPACE to a \ + writable path, then restart." + ); + None + } + }; + let workspace_dir = config.as_ref().map(|cfg| cfg.workspace_dir.clone()); + + // 6. Long-lived runtime infrastructure: event bus, domain subscribers, + // ledgers, agent-definition registry, live security policy, approval + // gate, socket manager. Idempotent (Once-guarded internally). Selected + // background jobs start later, from CoreRuntime::serve(), after bind + // succeeds. + let runtime_config = config.clone(); + crate::core::jsonrpc::bootstrap_core_runtime(host_kind, config).await; + + let ctx = Arc::new(CoreContext { + host_kind, + workspace_dir: RwLock::new(workspace_dir), + }); + + // Register the process default context (first build wins). Dispatch + // resolves to this when no per-call context is scoped. + let _ = DEFAULT_CONTEXT.set(ctx.clone()); + + Ok((ctx, has_operator_token, runtime_config)) + } + + /// The host that constructed this context (Tauri shell / CLI / Docker). + pub fn host_kind(&self) -> HostKind { + self.host_kind + } + + /// The resolved per-user workspace directory this context is bound to. + pub fn workspace_dir(&self) -> Result { + self.workspace_dir + .read() + .map_err(|e| format!("workspace unavailable: context lock poisoned: {e}"))? + .clone() + .ok_or_else(|| { + "workspace unavailable: Config::load_or_init failed during core boot; \ + fix config.toml or OPENHUMAN_WORKSPACE and restart" + .to_string() + }) + } + + /// The people store for this context's workspace — the first per-domain + /// store handle carved off the process globals (Phase 2 Stage C / + /// store-trait seam). Two contexts over different workspaces get isolated + /// stores; the same context always gets the same cached store. Handlers + /// migrate off `people::store::get()` by reading through + /// `CoreContext::current()?.people()` instead. + pub fn people(&self) -> Result, String> { + let workspace_dir = self.workspace_dir()?; + crate::openhuman::people::store::for_workspace(&workspace_dir) + } + + /// The context for the current dispatch: the one scoped by + /// [`CoreContext::scope`] if inside a scope, else the process + /// [`DEFAULT_CONTEXT`]. Returns `None` only before any context is built + /// (e.g. a unit test that dispatches without initializing the core). + /// + /// Handlers migrating off process globals read their state through this. + pub fn current() -> Option> { + CURRENT_CONTEXT + .try_with(|ctx| ctx.clone()) + .ok() + .or_else(|| DEFAULT_CONTEXT.get().cloned()) + } + + /// The process default context (first built), independent of any active + /// scope. Used by the dispatch chokepoint to establish the ambient scope. + pub fn default_context() -> Option> { + DEFAULT_CONTEXT.get().cloned() + } + + /// Rebind the process default context to the current active user's + /// workspace. Desktop login and pending-session revalidation can switch the + /// active workspace after boot without rebuilding the core. Scoped + /// multi-tenant dispatch is unaffected because tenant contexts are passed to + /// [`CoreContext::scope`] explicitly and are not the process default. + pub fn rebind_default_workspace_dir(workspace_dir: &std::path::Path) -> Result<(), String> { + let Some(ctx) = DEFAULT_CONTEXT.get() else { + log::debug!( + "[core-context] default context not initialized; skipped workspace rebind to {}", + workspace_dir.display() + ); + return Ok(()); + }; + ctx.rebind_workspace_dir(workspace_dir) + } + + fn rebind_workspace_dir(&self, workspace_dir: &std::path::Path) -> Result<(), String> { + let mut guard = self + .workspace_dir + .write() + .map_err(|e| format!("workspace rebind failed: context lock poisoned: {e}"))?; + if guard.as_deref() == Some(workspace_dir) { + log::debug!( + "[core-context] workspace already bound to {}", + workspace_dir.display() + ); + return Ok(()); + } + log::info!( + "[core-context] rebound default workspace to {}", + workspace_dir.display() + ); + *guard = Some(workspace_dir.to_path_buf()); + Ok(()) + } + + /// Run `fut` with `ctx` as the ambient [`CoreContext::current`]. The dispatch + /// layer wraps each handler invocation in this; multi-tenant hosts pass the + /// tenant's context here so the handler's `current()` reads isolated state. + pub async fn scope(ctx: Arc, fut: F) -> F::Output { + CURRENT_CONTEXT.scope(ctx, fut).await + } +} + +/// Initialize the global `MemoryClient` and the other workspace-bound stores so +/// composio providers (gmail/slack/notion) can persist their `sync_state`, and +/// so any subsystem that calls `memory::global::client_if_ready()` gets a live +/// handle. +/// +/// A `Config::load_or_init` failure here is operator-visible and serious +/// (corrupt toml, bad permissions, missing/unwritable `OPENHUMAN_WORKSPACE` — +/// common on headless/containerised deploys with no writable `$HOME`). +/// Previously the fallback to `Config::default()` initialised the memory + +/// whatsapp_data stores against the *wrong* workspace dir, silently causing +/// chunk loss / cross-workspace bleed-over while the app looked healthy (Sentry +/// OPENHUMAN-CORE-48). Instead: skip the workspace-bound init entirely so +/// memory stays explicitly *uninitialised* — callers then get a clear "memory +/// client not ready" error rather than reading/writing the wrong workspace. The +/// server still comes up; the operator sees the loud error and fixes their +/// config or sets `OPENHUMAN_WORKSPACE` to a writable path, then restarts. +pub async fn init_stores(cfg: &crate::openhuman::config::Config) { + let keyring_dir = crate::openhuman::keyring::store::workspace_dir_for_file_backend(); + log::info!( + "[boot] paths: config={} workspace={} keyring_dir={} keyring_backend={}", + cfg.config_path.display(), + cfg.workspace_dir.display(), + keyring_dir.display(), + crate::openhuman::keyring::backend_name(), + ); + match crate::openhuman::memory::global::init(cfg.workspace_dir.clone()) { + Ok(_) => log::info!( + "[boot] memory::global initialized (workspace={})", + cfg.workspace_dir.display() + ), + Err(e) => log::warn!("[boot] memory::global init failed: {e}"), + } + // Install the on-disk image-attachment sidecar dir so inbound + // image markers persist under /attachments/ instead + // of an in-memory FIFO (survives restarts + delegation hops). + // Also fires a best-effort stale-file sweep. + crate::openhuman::agent::multimodal::init_attachments_dir( + cfg.workspace_dir.join("attachments"), + ); + log::info!( + "[boot] image attachments sidecar dir = {}", + cfg.workspace_dir.join("attachments").display() + ); + // Initialize the WhatsApp data store so scanner ingest calls + // can write data without requiring a lazy-init fallback. + match crate::openhuman::whatsapp_data::global::init(cfg.workspace_dir.clone()) { + Ok(_) => log::info!( + "[boot] whatsapp_data::global initialized (workspace={})", + cfg.workspace_dir.display() + ), + Err(e) => log::warn!("[boot] whatsapp_data::global init failed: {e}"), + } + // Seed the people store so people controllers + `people_*` + // tools can read/write. Without this the process-global stays + // empty and every call fails with "people store not + // initialised" (Sentry TAURI-RUST-8NM). Sits inside this + // Ok(cfg) arm so it inherits the wrong-workspace guard above + // (never seed against a Config::default fallback). + match crate::openhuman::people::store::init_from_workspace(&cfg.workspace_dir) { + Ok(_) => log::info!( + "[boot] people::store initialized (workspace={})", + cfg.workspace_dir.display() + ), + Err(e) => log::warn!("[boot] people::store init failed: {e}"), + } + // Prune legacy bundled skills (dev-workflow / github-issue-crusher + // / pr-review-shepherd) that older builds seeded into + // /skills/. OpenHuman no longer ships bundled defaults; + // this removes the stale dirs on upgrade. Idempotent. + crate::openhuman::skills::registry::prune_legacy_default_workflows(&cfg.workspace_dir); + // Boot-time Sentry user binding — issue #3135. If the user is + // already signed in (typical desktop restart), the auth-profile + // store has their `user_id` *now*, before any background loop + // (Composio sync tick, heartbeat, etc.) fires its first event. + // Reading from the store here means subsequent events carry + // `user.id` even when no `app_state_snapshot` RPC has run yet. + match crate::openhuman::credentials::session_support::build_session_state(cfg) { + Ok(state) => { + if let Some(uid) = state.user_id.as_deref() { + crate::openhuman::credentials::sentry_scope::bind(uid); + } + } + Err(e) => { + log::debug!("[boot] sentry scope user bind skipped — build_session_state failed: {e}") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn ctx(dir: &str) -> Arc { + Arc::new(CoreContext { + host_kind: HostKind::Cli, + workspace_dir: RwLock::new(Some(PathBuf::from(dir))), + }) + } + + // The ambient-scope primitive is the mechanism Phase 3 multi-tenant + // isolation is built on: a dispatch scoped to context A must see A's state, + // not the process default or another tenant's. These assert the primitive + // directly (independent of the process DEFAULT_CONTEXT global, since + // `current()` inside a scope resolves the scoped value). + + #[tokio::test] + async fn scope_sets_current_context() { + let a = ctx("/tmp/ctx-a"); + let seen = CoreContext::scope(a, async { + CoreContext::current().map(|c| c.workspace_dir().unwrap()) + }) + .await; + assert_eq!(seen, Some(PathBuf::from("/tmp/ctx-a"))); + } + + #[tokio::test] + async fn nested_scope_overrides_then_restores() { + let a = ctx("/tmp/ctx-a"); + let b = ctx("/tmp/ctx-b"); + let (inner, outer) = CoreContext::scope(a, async { + let inner = CoreContext::scope(b, async { + CoreContext::current().unwrap().workspace_dir().unwrap() + }) + .await; + let outer = CoreContext::current().unwrap().workspace_dir().unwrap(); + (inner, outer) + }) + .await; + // Inner dispatch sees tenant B; the outer scope is restored to A after. + assert_eq!(inner, PathBuf::from("/tmp/ctx-b")); + assert_eq!(outer, PathBuf::from("/tmp/ctx-a")); + } + + // The Phase 3 exit criterion, at the store level: two contexts over distinct + // workspaces resolve isolated per-domain stores, and one context always + // resolves the same cached store. This is the vertical proof that the + // ambient-context mechanism + a per-context store handle give real + // cross-context isolation (here for the first migrated domain, `people`). + #[test] + fn people_store_is_isolated_per_context_workspace() { + let dir_a = tempfile::tempdir().unwrap(); + let dir_b = tempfile::tempdir().unwrap(); + let a = Arc::new(CoreContext { + host_kind: HostKind::Cli, + workspace_dir: RwLock::new(Some(dir_a.path().to_path_buf())), + }); + let b = Arc::new(CoreContext { + host_kind: HostKind::Cli, + workspace_dir: RwLock::new(Some(dir_b.path().to_path_buf())), + }); + + let store_a = a.people().expect("open people store for workspace A"); + let store_b = b.people().expect("open people store for workspace B"); + // Different workspaces → isolated stores. + assert!(!Arc::ptr_eq(&store_a, &store_b)); + + // Same context/workspace → same cached store (no per-call reopen). + let store_a_again = a.people().expect("reopen people store for workspace A"); + assert!(Arc::ptr_eq(&store_a, &store_a_again)); + } + + #[test] + fn rebind_workspace_updates_context_store_resolution() { + let dir_a = tempfile::tempdir().unwrap(); + let dir_b = tempfile::tempdir().unwrap(); + let ctx = CoreContext { + host_kind: HostKind::Cli, + workspace_dir: RwLock::new(Some(dir_a.path().to_path_buf())), + }; + + let store_a = ctx.people().expect("open people store for workspace A"); + ctx.rebind_workspace_dir(dir_b.path()) + .expect("rebind context workspace"); + + assert_eq!(ctx.workspace_dir().unwrap(), dir_b.path()); + let store_b = ctx.people().expect("open people store for workspace B"); + assert!(!Arc::ptr_eq(&store_a, &store_b)); + } + + #[tokio::test] + async fn people_rpc_uses_scoped_context_store() { + use crate::openhuman::people::types::Handle; + + let dir_a = tempfile::tempdir().unwrap(); + let dir_b = tempfile::tempdir().unwrap(); + let a = Arc::new(CoreContext { + host_kind: HostKind::Cli, + workspace_dir: RwLock::new(Some(dir_a.path().to_path_buf())), + }); + let b = Arc::new(CoreContext { + host_kind: HostKind::Cli, + workspace_dir: RwLock::new(Some(dir_b.path().to_path_buf())), + }); + + let params = serde_json::json!({ + "kind": "email", + "value": "tenant-a@example.com", + "create_if_missing": true + }) + .as_object() + .unwrap() + .clone(); + + let result = CoreContext::scope( + a.clone(), + crate::core::all::try_invoke_registered_rpc("openhuman.people_resolve", params), + ) + .await + .expect("people_resolve registered") + .expect("people_resolve succeeds"); + + assert_eq!(result["created"], true); + let handle = Handle::Email("tenant-a@example.com".to_string()); + assert!( + a.people() + .expect("workspace A store") + .lookup(&handle) + .await + .unwrap() + .is_some(), + "scoped RPC must write workspace A" + ); + assert!( + b.people() + .expect("workspace B store") + .lookup(&handle) + .await + .unwrap() + .is_none(), + "scoped RPC must not write workspace B" + ); + } + + #[test] + fn degraded_context_rejects_workspace_bound_stores() { + let ctx = CoreContext { + host_kind: HostKind::Cli, + workspace_dir: RwLock::new(None), + }; + + let err = match ctx.people() { + Ok(_) => panic!("degraded context unexpectedly opened a people store"), + Err(err) => err, + }; + assert!( + err.contains("workspace unavailable"), + "unexpected error: {err}" + ); + } +} diff --git a/src/core/runtime.rs b/src/core/runtime/mod.rs similarity index 59% rename from src/core/runtime.rs rename to src/core/runtime/mod.rs index 1f83bb3004..bd4a1d683a 100644 --- a/src/core/runtime.rs +++ b/src/core/runtime/mod.rs @@ -1,4 +1,13 @@ -//! Shared tokio runtime tuning constants. +//! Core runtime composition: bootstrap context, background services, and +//! shared tokio tuning constants. +//! +//! This module is the seam that separates *initialization* (workspace-bound +//! store setup — [`context`]) from *background services* (cron, channels, +//! heartbeat, update scheduler — [`services`]) so alternate hosts can compose +//! them without going through the monolithic `run_server_inner`. See +//! `docs/plans/pluggable-core/` for the full plan. +//! +//! ## Shared tokio runtime tuning constants //! //! A single agent turn is a very large async state machine (system prompt + //! hundreds of tool specs + the nested provider/tool loop), and delegating @@ -14,3 +23,9 @@ //! in sync; downstream call sites should set `.thread_stack_size(AGENT_WORKER_STACK_BYTES)` //! on every multi-thread runtime that may host an agent turn. pub const AGENT_WORKER_STACK_BYTES: usize = 16 * 1024 * 1024; + +pub mod builder; +pub mod context; +pub mod services; + +pub use builder::{CoreBuilder, CoreRuntime, ServiceSet, TokenSource}; diff --git a/src/core/runtime/services.rs b/src/core/runtime/services.rs new file mode 100644 index 0000000000..89b36521e1 --- /dev/null +++ b/src/core/runtime/services.rs @@ -0,0 +1,298 @@ +//! Background service spawns. +//! +//! Extracted (Phase 0 — pure motion) from the inline `tokio::spawn` blocks that +//! used to live in `run_server_inner` (`src/core/jsonrpc.rs`, ~lines 2050-2191). +//! Each function spawns one long-lived background service as a detached task, +//! preserving the exact gating and behavior of the original inline block. +//! +//! Today these are launched unconditionally from `run_server_inner`; the +//! per-service *config* gates (`config.cron.enabled`, `config.heartbeat.enabled`, +//! `OPENHUMAN_DISABLE_CHANNEL_LISTENERS`, `has_listening_integrations()`) stay +//! inside each function. Phase 1 lifts the *selection* (should this service be +//! spawned at all) up to a `ServiceSet` chosen by the embedder, while these +//! functions keep their config gates (is it enabled for this user). + +use std::sync::Once; + +use crate::core::runtime::ServiceSet; +use crate::openhuman::config::Config; + +/// Background bootstrap for login-gated services (local AI, voice, screen +/// intelligence, autocomplete) plus the subconscious engine + heartbeat. +/// +/// Heavy services are only started when a user is logged in. If no user session +/// exists on disk, startup is deferred until the login handler in +/// `credentials::ops::store_session()` triggers it. The autocomplete shutdown +/// hook is registered unconditionally. +pub fn spawn_login_gated_services(embedded_core: bool) { + tokio::spawn(async move { + match crate::openhuman::config::Config::load_or_init().await { + Ok(config) => { + if embedded_core { + log::debug!("[core] embedded core startup"); + } else { + log::debug!("[core] desktop core startup"); + } + + // Register autocomplete shutdown hook so the engine (and its + // Swift overlay helper) are stopped cleanly on process exit. + // This is unconditional — the hook should fire regardless of + // whether the user is currently logged in. + crate::core::shutdown::register(|| async { + let engine = crate::openhuman::autocomplete::global_engine(); + let status = engine.status().await; + if status.running { + log::info!( + "[core] stopping autocomplete engine (phase={})", + status.phase + ); + engine.stop(None).await; + log::info!("[core] autocomplete engine stopped"); + } + }); + + // Check if a user is already logged in from a previous session. + let already_logged_in = crate::openhuman::config::default_root_openhuman_dir() + .ok() + .and_then(|root| crate::openhuman::config::read_active_user_id(&root)) + .is_some(); + + if already_logged_in { + // User has an active session — start all services now. + log::info!("[services] existing session found, starting services"); + crate::openhuman::credentials::ops::start_login_gated_services(&config).await; + + // Subconscious engine + heartbeat. + if !config.heartbeat.enabled { + log::info!("[subconscious] disabled by config (heartbeat.enabled = false)"); + } else { + match crate::openhuman::subconscious::registry::bootstrap_after_login() + .await + { + Ok(()) => { + log::info!( + "[subconscious] bootstrapped on startup (existing session)" + ) + } + Err(e) => log::warn!("[subconscious] startup bootstrap failed: {e}"), + } + } + } else { + log::info!( + "[services] no active session — deferring service startup until login" + ); + } + } + Err(err) => { + log::warn!("[core] config load failed, skipping service startup: {err}"); + } + } + }); +} + +/// Periodic self-update checker (default: every 1 hour). +pub fn spawn_update_scheduler() { + tokio::spawn(async { + match crate::openhuman::config::Config::load_or_init().await { + Ok(config) => { + crate::openhuman::update::scheduler::run(config.update).await; + } + Err(err) => { + log::warn!("[core] config load failed, skipping update scheduler: {err}"); + } + } + }); +} + +/// Cron scheduler — polls `due_jobs()` every ~5s and executes them +/// automatically. Gated by `config.cron.enabled`. +pub fn spawn_cron_service() { + tokio::spawn(async { + match crate::openhuman::config::Config::load_or_init().await { + Ok(config) => { + if !config.cron.enabled { + log::info!("[cron] scheduler disabled via config; skipping"); + return; + } + log::info!("[cron] spawning scheduler polling loop"); + // Ensure proactive agent jobs (e.g. the autonomous bounty job) + // exist for already-onboarded users upgrading from a build that + // predates them — otherwise their Settings toggle stays hidden. + // Idempotent; no-op until onboarding is complete. + if let Err(e) = crate::openhuman::cron::seed::seed_proactive_agents_on_boot(&config) + { + log::warn!("[cron] boot seed of proactive agent jobs failed: {e}"); + } + // Re-register the cron job for every enabled, schedule-trigger + // flow (issue B2) — idempotent, so a flow whose binding + // predates this feature (or was otherwise lost) gets its + // schedule re-registered without the user re-toggling it. + if let Err(e) = + crate::openhuman::flows::ops::reconcile_schedule_triggers_on_boot(&config).await + { + log::warn!( + "[flows] boot reconciliation of schedule-trigger cron jobs failed: {e}" + ); + } + if let Err(e) = crate::openhuman::cron::scheduler::run(config).await { + log::error!("[cron] scheduler loop ended with error: {e}"); + } + } + Err(err) => { + log::warn!("[core] config load failed, skipping cron scheduler: {err}"); + } + } + }); +} + +/// Realtime channel listeners (Telegram getUpdates, Discord gateway, etc.). +/// +/// Without this task, `openhuman run` would only expose RPC while inbound bot +/// messages are never polled. Skipped entirely when +/// `OPENHUMAN_DISABLE_CHANNEL_LISTENERS` is set to `1`/`true`, and returns early +/// when no channel integrations are configured. +pub fn spawn_channels_service() { + if std::env::var("OPENHUMAN_DISABLE_CHANNEL_LISTENERS") + .ok() + .filter(|s| s == "1" || s.eq_ignore_ascii_case("true")) + .is_none() + { + tokio::spawn(async move { + let config = match crate::openhuman::config::Config::load_or_init().await { + Ok(c) => c, + Err(e) => { + log::warn!("[channels] could not load config for listeners: {e}"); + return; + } + }; + if !config.channels_config.has_listening_integrations() { + log::debug!( + "[channels] no channel integrations configured; not spawning listeners" + ); + return; + } + log::info!("[channels] spawning in-process realtime listeners (Telegram, Discord, …)"); + if let Err(e) = crate::openhuman::channels::start_channels(config).await { + log::error!("[channels] start_channels ended with error: {e}"); + } + }); + } else { + log::info!("[channels] OPENHUMAN_DISABLE_CHANNEL_LISTENERS set — skipping start_channels"); + } +} + +/// Starts legacy bootstrap loops that predate [`ServiceSet`]. +/// +/// These are separated from pure subscriber registration so a no-background +/// runtime can register handlers first without permanently suppressing a later +/// desktop/runtime-with-services boot. +pub fn start_bootstrap_jobs(services: ServiceSet, config: &Config) { + if services.memory_queue { + crate::openhuman::memory_queue::start(config.clone()); + } else { + log::debug!("[runtime] memory queue workers disabled by ServiceSet"); + } + + if services.channels { + crate::openhuman::composio::start_periodic_sync(); + // Workspace-kind memory sources (GitHub repos, folders, RSS, web pages) + // get their own cadence loop; the Composio scheduler only walks + // Composio connections. + crate::openhuman::memory_sync::workspace::start_workspace_periodic_sync(); + crate::openhuman::orchestration::start_message_drain_supervisor(); + tokio::spawn(async { + crate::openhuman::memory_sources::reconcile::ensure_composio_sources().await; + }); + } else { + log::debug!("[runtime] bootstrap channel/integration pollers disabled by ServiceSet"); + } + + if services.cron { + crate::openhuman::task_sources::start_periodic_poll(); + crate::openhuman::agent::task_dispatcher::start_board_poller(); + } else { + log::debug!("[runtime] bootstrap proactive task pollers disabled by ServiceSet"); + } +} + +/// Starts one-shot boot background work selected by [`ServiceSet`]. +pub fn start_boot_once_jobs(services: ServiceSet, config: &Config) { + if services.harness_init { + let cfg_for_init = config.clone(); + tokio::spawn(async move { + crate::openhuman::harness_init::run_harness_init(cfg_for_init).await; + }); + } else { + log::debug!("[runtime] harness init disabled by ServiceSet"); + } + + if services.skill_catalog_refresh { + crate::openhuman::skill_registry::ops::start_boot_catalog_refresh(); + } else { + log::debug!("[runtime] boot catalog refresh disabled by ServiceSet"); + } + + if services.mcp_boot { + let cfg_for_mcp = config.clone(); + tokio::spawn(async move { + crate::openhuman::mcp_registry::boot::spawn_installed_servers(&cfg_for_mcp).await; + }); + spawn_mcp_reconnect_supervisor(config.clone()); + } else { + log::debug!("[runtime] MCP boot-spawn disabled by ServiceSet"); + log::debug!("[runtime] MCP reconnect supervisor disabled by ServiceSet"); + } +} + +fn spawn_mcp_reconnect_supervisor(config: Config) { + static SUPERVISOR_SPAWNED: Once = Once::new(); + SUPERVISOR_SPAWNED.call_once(|| { + tokio::spawn(async move { + crate::openhuman::mcp_registry::supervisor::run(config).await; + }); + }); +} + +/// Auto-connect Socket.IO to the backend when enabled by the service selection. +pub fn spawn_socket_auto_connect( + services: ServiceSet, + socket_mgr: std::sync::Arc, +) { + if services.socketio { + tokio::spawn(async move { + log::info!("[socket] Checking for stored session to auto-connect..."); + let config = match Config::load_or_init().await { + Ok(c) => c, + Err(e) => { + log::debug!("[socket] Config not available for auto-connect: {e}"); + return; + } + }; + let api_url = crate::api::config::effective_backend_api_url(&config.api_url); + let token = match crate::api::jwt::get_session_token(&config) { + Ok(Some(t)) => t, + Ok(None) => { + log::info!( + "[socket] No session token stored — skipping auto-connect (will connect after login)" + ); + return; + } + Err(e) => { + log::warn!("[socket] Failed to read session token: {e}"); + return; + } + }; + log::info!( + "[socket] Session token found — auto-connecting to {}", + api_url + ); + if let Err(e) = socket_mgr.connect(&api_url, &token).await { + log::error!("[socket] Auto-connect failed: {e}"); + } else { + log::info!("[socket] Auto-connect initiated successfully"); + } + }); + } else { + log::debug!("[socket] auto-connect disabled by ServiceSet"); + } +} diff --git a/src/lib.rs b/src/lib.rs index fea1142cc5..43231f0111 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,14 @@ //! - Core system services (CLI, configuration, monitoring). //! - Domain-specific logic for the OpenHuman agent runtime. +// The RPC dispatch chokepoint wraps each handler future in an ambient +// `CoreContext` scope (Phase 2). Combined with the already very deep async type +// stacks in the axum routes that fan out into the tinyagents harness, the extra +// future layer pushes the compiler's `Send` auto-trait solver past the default +// depth of 128 (E0275). Raising the limit is the standard remedy for deep async +// type recursion and costs nothing at runtime. +#![recursion_limit = "256"] + pub mod api; pub mod core; pub mod openhuman; @@ -13,6 +21,12 @@ pub mod rpc; pub use openhuman::config::DaemonConfig; pub use openhuman::memory_store::{MemoryClient, MemoryState}; +/// Embeddable core composition API. Host the OpenHuman core in any process — +/// the Tauri shell, a CLI, a stdio MCP server, or a cloud/team server — via +/// [`CoreBuilder`] → [`CoreRuntime`]. See `docs/plans/pluggable-core/`. +pub use core::runtime::{CoreBuilder, CoreRuntime, ServiceSet, TokenSource}; +pub use core::types::HostKind; + /// Runs the core logic based on the provided command-line arguments. /// /// This is the primary entry point for the OpenHuman binary, delegating to the diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs index 419c41c575..b8f12bc6c4 100644 --- a/src/openhuman/app_state/ops.rs +++ b/src/openhuman/app_state/ops.rs @@ -516,6 +516,11 @@ async fn finish_revalidated_user_activation( "{LOG_PREFIX} failed to bind memory client after pending session revalidation: {error}" ); } + if let Err(error) = crate::core::runtime::context::CoreContext::rebind_default_workspace_dir( + &target_config.workspace_dir, + ) { + warn!("{LOG_PREFIX} failed to rebind core context after pending session revalidation: {error}"); + } // Rebind the people store to the activated user's workspace, mirroring the // memory-client rebind so people controllers/tools follow the active user // instead of the pre-switch workspace (#4378). diff --git a/src/openhuman/credentials/ops.rs b/src/openhuman/credentials/ops.rs index b6d75c365c..d7fe044e53 100644 --- a/src/openhuman/credentials/ops.rs +++ b/src/openhuman/credentials/ops.rs @@ -442,6 +442,18 @@ async fn store_session_inner( logs.push(format!("memory client bind warning: {e}")); } } + match crate::core::runtime::context::CoreContext::rebind_default_workspace_dir( + &effective_config.workspace_dir, + ) { + Ok(_) => logs.push(format!( + "core context bound to workspace {}", + effective_config.workspace_dir.display() + )), + Err(e) => { + tracing::warn!(error = %e, "[credentials] failed to rebind core context after login"); + logs.push(format!("core context bind warning: {e}")); + } + } // Rebind the people store to the per-user workspace too — the boot seed may // have bound it to the pre-login workspace, and it must follow the active // user like the memory client does (#4378). diff --git a/src/openhuman/orchestration/ops.rs b/src/openhuman/orchestration/ops.rs index 8402bfd40e..ebdd440dbb 100644 --- a/src/openhuman/orchestration/ops.rs +++ b/src/openhuman/orchestration/ops.rs @@ -8,6 +8,8 @@ //! - attention signals: [`command_center_needs_input`], [`gather_unread_signals`], //! [`gather_remote_approval_signals`]. +use std::sync::OnceLock; + use serde::Serialize; use serde_json::Value; @@ -17,8 +19,14 @@ use super::store; use super::types::SessionEnvelopeV1; const LOG: &str = "orchestration"; +static MESSAGE_DRAIN_SUPERVISOR_STARTED: OnceLock<()> = OnceLock::new(); pub fn start_message_drain_supervisor() { + if MESSAGE_DRAIN_SUPERVISOR_STARTED.set(()).is_err() { + log::debug!(target: LOG, "[orchestration] message drain supervisor already running"); + return; + } + tokio::spawn(async { // Receiving DMs is impossible unless this agent has published its Signal // keys (peers 404 on the prekey bundle otherwise) — the exact blocker diff --git a/src/openhuman/people/schemas.rs b/src/openhuman/people/schemas.rs index 5917ac7b69..57f95d87a1 100644 --- a/src/openhuman/people/schemas.rs +++ b/src/openhuman/people/schemas.rs @@ -9,9 +9,11 @@ use serde_json::{Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::runtime::context::CoreContext; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::openhuman::people::rpc; +use crate::openhuman::people::store::PeopleStore; use crate::openhuman::people::types::{Handle, PersonId}; -use crate::openhuman::people::{rpc, store}; use crate::rpc::RpcOutcome; pub fn all_controller_schemas() -> Vec { @@ -288,16 +290,23 @@ fn score_components_schema() -> TypeSchema { } } +fn current_people_store() -> Result, String> { + CoreContext::current() + .ok_or_else(|| "people store unavailable: core context not initialized".to_string())? + .people() + .map_err(|e| format!("people store unavailable: {e}")) +} + fn handle_refresh_address_book(_params: Map) -> ControllerFuture { Box::pin(async move { - let store = store::get().map_err(|e| e.to_string())?; + let store = current_people_store()?; to_json(rpc::handle_refresh_address_book(&store).await?) }) } fn handle_list(params: Map) -> ControllerFuture { Box::pin(async move { - let store = store::get().map_err(|e| e.to_string())?; + let store = current_people_store()?; let limit = read_optional_u64(¶ms, "limit")?.unwrap_or(100) as usize; to_json(rpc::handle_list(&store, limit).await?) }) @@ -305,7 +314,7 @@ fn handle_list(params: Map) -> ControllerFuture { fn handle_resolve(params: Map) -> ControllerFuture { Box::pin(async move { - let store = store::get().map_err(|e| e.to_string())?; + let store = current_people_store()?; let kind = read_required_string(¶ms, "kind")?; let value = read_required_string(¶ms, "value")?; let create = read_optional_bool(¶ms, "create_if_missing")?.unwrap_or(false); @@ -325,7 +334,7 @@ fn handle_resolve(params: Map) -> ControllerFuture { fn handle_score(params: Map) -> ControllerFuture { Box::pin(async move { - let store = store::get().map_err(|e| e.to_string())?; + let store = current_people_store()?; let id_s = read_required_string(¶ms, "person_id")?; let id = uuid::Uuid::parse_str(&id_s) .map(PersonId) diff --git a/src/openhuman/people/store.rs b/src/openhuman/people/store.rs index 6bf470e7fa..b9f9fc8beb 100644 --- a/src/openhuman/people/store.rs +++ b/src/openhuman/people/store.rs @@ -100,6 +100,47 @@ pub fn get() -> Result, &'static str> { .ok_or("people store not initialised — core startup hasn't completed") } +/// Per-workspace store cache keyed by workspace dir. Backs [`for_workspace`], +/// the context-scoped accessor ([`crate::core::runtime::CoreContext::people`]). +/// Distinct from the single `GLOBAL` slot above (which tracks the one +/// active-user workspace for the legacy free-function handlers): this map lets +/// multiple workspaces' stores coexist in one process, which is what per-context +/// isolation (Phase 3) needs. +static STORES: OnceLock>>> = + OnceLock::new(); + +/// Open (or return the cached) people store for a specific workspace dir. Unlike +/// [`get`], this is not tied to the single active-user global — two different +/// workspaces resolve to two isolated stores, and the same workspace always +/// resolves to the same cached `Arc`. Opening `/people/people.db` +/// runs schema migrations. +pub fn for_workspace(workspace_dir: &Path) -> Result, String> { + let cache = STORES.get_or_init(Default::default); + if let Some(store) = cache + .read() + .map_err(|e| format!("[people:store] cache read lock poisoned: {e}"))? + .get(workspace_dir) + { + return Ok(Arc::clone(store)); + } + + let db_path = workspace_dir.join("people").join("people.db"); + let store = Arc::new( + PeopleStore::open_at(&db_path).map_err(|e| format!("people store open failed: {e}"))?, + ); + + let mut guard = cache + .write() + .map_err(|e| format!("[people:store] cache write lock poisoned: {e}"))?; + // Re-check under the write lock: a concurrent caller may have opened the + // same workspace while we were opening — reuse theirs so callers always + // share one store per workspace. + let entry = guard + .entry(workspace_dir.to_path_buf()) + .or_insert_with(|| Arc::clone(&store)); + Ok(Arc::clone(entry)) +} + pub struct PeopleStore { pub conn: ConnHandle, } diff --git a/src/openhuman/people/tools.rs b/src/openhuman/people/tools.rs index f540b7cd3e..35c87f9047 100644 --- a/src/openhuman/people/tools.rs +++ b/src/openhuman/people/tools.rs @@ -15,14 +15,18 @@ use async_trait::async_trait; use chrono::Utc; use serde_json::json; +use crate::core::runtime::context::CoreContext; use crate::openhuman::people::rpc; -use crate::openhuman::people::store::{self, PeopleStore}; +use crate::openhuman::people::store::PeopleStore; use crate::openhuman::people::types::{Handle, Interaction, PersonId}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; -/// Acquire the global people store or surface a uniform error. +/// Acquire the people store for the current runtime context. fn people_store() -> anyhow::Result> { - store::get().map_err(|e| anyhow::anyhow!("people store unavailable: {e}")) + CoreContext::current() + .ok_or_else(|| anyhow::anyhow!("people store unavailable: core context not initialized"))? + .people() + .map_err(|e| anyhow::anyhow!("people store unavailable: {e}")) } fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { diff --git a/src/rpc/dispatch.rs b/src/rpc/dispatch.rs deleted file mode 100644 index 1b1f7f78e2..0000000000 --- a/src/rpc/dispatch.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Legacy compatibility shim for domain-specific RPC dispatch. -//! -//! Domain routing now lives in the controller registry (`src/core/all.rs`). -//! This module is intentionally minimal so callers can fall through to -//! unknown-method handling while older call sites remain compile-compatible. - -/// Dispatches an RPC method to legacy handlers. -/// -/// Returns `None` for all methods; controller-registry dispatch is authoritative. -pub async fn try_dispatch( - _method: &str, - _params: serde_json::Value, -) -> Option> { - None -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::try_dispatch; - - #[tokio::test] - async fn dispatch_returns_none_for_unknown_method() { - let result = try_dispatch("nonexistent.method", json!({})).await; - assert!(result.is_none(), "unknown methods should return None"); - } - - #[tokio::test] - async fn dispatch_security_method_now_falls_through() { - let result = try_dispatch("openhuman.security_policy_info", json!({})).await; - assert!( - result.is_none(), - "security method should be handled by registry path" - ); - } -} diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 0dd9ee8c3c..58ac926cf7 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -10,10 +10,8 @@ use serde::Serialize; use serde_json::json; -mod dispatch; mod structured_error; -pub use dispatch::try_dispatch; pub use structured_error::{StructuredRpcError, STRUCTURED_RPC_ERROR_SENTINEL}; /// Successful RPC handler result: serialized JSON value plus optional log lines. diff --git a/tests/memory_golden_parity_e2e.rs b/tests/memory_golden_parity_e2e.rs index c3dbf21ab1..64158170a5 100644 --- a/tests/memory_golden_parity_e2e.rs +++ b/tests/memory_golden_parity_e2e.rs @@ -261,29 +261,15 @@ async fn golden_workspace_composes_substrate_and_unified_tiers() { tables.contains("mem_tree_chunks") && tables.contains("memory_docs"), "both the crate substrate and the host unified tier must coexist" ); -} -/// Comparator 5 (idempotent re-open) — re-running the production flow over an -/// existing workspace must not add or drop tables (no lazy second-run schema -/// churn / migration storm). -#[tokio::test] -async fn golden_workspace_reopen_is_schema_stable() { - let _lock = env_lock(); - let tmp = tempdir().expect("tempdir"); - let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); - let workspace = tmp.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("mkdir workspace"); - let _ws = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace); - - let first = init_and_scan("golden-parity-reopen", &workspace).await; - let second = init_and_scan("golden-parity-reopen", &workspace).await; + // Comparator 5 (idempotent re-open): keep this in the same test because + // the production memory client is process-global and deliberately binds + // to its first workspace. Separate tests with separate temp workspaces can + // therefore pass or fail depending on test scheduling. + let reopened = init_and_scan("golden-parity-e2e", &workspace).await; assert_eq!( - first, second, + tables, reopened, "re-running the flow changed the workspace table set (schema churn on re-open)" ); - assert!( - !first.is_empty(), - "sanity: the first pass should have created tables" - ); } diff --git a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs index e1ee0bc0fc..fa005b81fc 100644 --- a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs @@ -123,9 +123,9 @@ use openhuman_core::openhuman::memory_sync::traits::{ use openhuman_core::openhuman::memory_tools::tools::{MemoryToolsListTool, MemoryToolsPutTool}; use openhuman_core::openhuman::memory_tools::{ render_tool_memory_rules, tool_memory_namespace, ToolMemoryPriority, ToolMemoryRule, - ToolMemoryRulesSection, ToolMemorySource, ToolMemoryStore, TOOL_MEMORY_HEADING, - TOOL_MEMORY_PROMPT_CAP, + ToolMemoryRulesSection, ToolMemorySource, TOOL_MEMORY_HEADING, TOOL_MEMORY_PROMPT_CAP, }; +use openhuman_core::openhuman::memory_tools::tool_memory_store; use openhuman_core::openhuman::memory_tree::score::embed::Embedder; use openhuman_core::openhuman::memory_tree::score::extract::{ CompositeExtractor, EntityExtractor, EntityKind, ExtractedEntities, ExtractedEntity, @@ -2486,7 +2486,7 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { UnifiedMemory::new(&tool_memory_dir, Arc::new(NoopEmbedding), None) .expect("tool memory backend"), ); - let store = ToolMemoryStore::new(memory.clone()); + let store = tool_memory_store(memory.clone()); assert_eq!(tool_memory_namespace(" Shell "), "tool-shell"); assert!(ToolMemoryPriority::Critical.is_eager()); assert!(ToolMemoryPriority::High.is_eager());