From 394fee3e1e0d8b46745a3a89121dfecfbb41eb9a Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Wed, 21 Oct 2026 07:52:27 +0200 Subject: [PATCH 1/2] feat: provider::api_key_variables(); probe runtimes take environment variables - yoagent: `provider::api_key_variables()` lists every environment variable yoagent reads a credential from (each provider's, Bedrock's AWS_*, the YOAGENT_API_KEY/API_KEY fallback, the decision presets'). `resolve_api_key` reads the same table, so they cannot drift; a test pins the decision names to their constants. - yoagent-rutis: `probe::pi_with` / `probe::dsh_with` with `ProbeOptions` (`env`, OsString, empty allowed), set over the inherited environment; the DSH probe's own DSH_HOME / DSH_AGENTS_HOME win. `pi` / `dsh` unchanged. Asked for by yobuild (M3b step 4: plugins without the provider keys). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 10 ++ CLAUDE.md | 2 +- docs/providers/overview.md | 4 + integrations/yoagent-rutis/CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 6 +- integrations/yoagent-rutis/src/probe.rs | 95 ++++++++++-- .../yoagent-rutis/tests/probe_test.rs | 78 ++++++++++ src/decision/mod.rs | 2 +- src/provider/mod.rs | 2 +- src/provider/registry.rs | 137 +++++++++++++++--- 10 files changed, 300 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 069474b7..e3a90526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to `yoagent` are documented here. The format loosely follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +- **`provider::api_key_variables()`**: every environment variable yoagent reads a credential from — each provider's key variables as `resolve_api_key` reads them (Bedrock's `AWS_*` included), the `YOAGENT_API_KEY` / `API_KEY` fallback, and the decision presets' keys (`TYPESAFE_API_KEY`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_AUTH_TOKEN`) — for hosts that blank credentials in processes they start (plugin runtimes, MCP servers). `resolve_api_key` now reads the same table, so the two cannot drift. Additive. Asked for by yobuild. + +## yoagent-rutis Unreleased + +yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.8.0. Additive. + +- **`probe::pi_with` / `probe::dsh_with` and `ProbeOptions`**: the probe's Node runtime can be given environment variables (`ProbeOptions::default().env(name, value)`, `OsString`s, an empty value included), set over the inherited environment; the DSH probe's private `DSH_HOME` / `DSH_AGENTS_HOME` are set after them and win. `probe::pi` / `dsh` are unchanged. For hosts that blank provider keys in every plugin runtime (yobuild's `check`, whose probes were the one runtime still inheriting them). Asked for by yobuild. + ## yoagent-rutis 0.8.0 (2026-10-20) yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.7.1. A minor bump: new values and settings for the pi adapter's grants. diff --git a/CLAUDE.md b/CLAUDE.md index 22f973a7..8ba04cc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,7 +150,7 @@ The generic hooks it rides on need no feature: `ToolCallRequest::messages` / `ru ### Construction API -The primary constructor is `Agent::from_config(ModelConfig)`: it selects the built-in provider from `config.api`, sets the model id from `config.id`, and resolves the API key from the provider-conventional env var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`, …; see `provider::resolve_api_key`). This removes the provider↔config pairing footgun and the doubly-specified model id. +The primary constructor is `Agent::from_config(ModelConfig)`: it selects the built-in provider from `config.api`, sets the model id from `config.id`, and resolves the API key from the provider-conventional env var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`, …; see `provider::resolve_api_key`, which reads the private `PROVIDER_KEY_VARIABLES` table in `registry.rs`; `provider::api_key_variables()` lists that table, Bedrock's `AWS_*`, the `YOAGENT_API_KEY`/`API_KEY` fallback and the decision presets' keys — a test pins the decision ones to `decision::systemone`'s constants). This removes the provider↔config pairing footgun and the doubly-specified model id. ```rust // provider auto-selected, key from XAI_API_KEY diff --git a/docs/providers/overview.md b/docs/providers/overview.md index dcff0c7b..14dd941e 100644 --- a/docs/providers/overview.md +++ b/docs/providers/overview.md @@ -124,6 +124,10 @@ resolves the key from the environment variable named for `ModelConfig.provider` | `local`, `ollama` | none needed (empty key) | | anything else | `YOAGENT_API_KEY`, `API_KEY` | +`provider::api_key_variables()` lists every one of these (and the decision +presets' keys) once: a host that keeps credentials from processes it starts — +plugin runtimes, MCP servers — can blank each of them there. + **wasm32 (Cloudflare Workers):** there are no environment variables, so none of the above is found. Pass every key with `with_api_key` (see [WebAssembly & Cloudflare Workers](../guides/wasm-workers.md)). diff --git a/integrations/yoagent-rutis/CLAUDE.md b/integrations/yoagent-rutis/CLAUDE.md index 1a39adc4..7b6424a8 100644 --- a/integrations/yoagent-rutis/CLAUDE.md +++ b/integrations/yoagent-rutis/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-rutis/`. The repo ## rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.12"` (0.x caret = 0.12.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9+ host sends call origins a 0.8 runtime rejects, 0.11+ sends integers beyond ±(2^53−1) as big integers that a 0.10-or-older peer refuses, and since 0.12 undeclared `ctx.provide`d services reach rutis (a name clash fails the later row); rutis 0.9–0.12 need Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.12"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.12.0 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.12.0) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nil; the call's context also ends when the hook returns (Go's `defer cancel`), so "never cancelled after completing" is not observable from Go; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks the tool + an `after_tool` redaction, that Go's denial / returned error / malformed answer each deny, that a policy timeout and a run cancel cancel Go's context, and that unloading the row removes the handler (disposer `Effect`), after which `bash` runs; a failed `go build` with Go present fails, never skips (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.12 a dev-dependency, and an optional one behind `probe`): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones — or ones with no JSON form (a `Date`; `Json::Null`, so later arguments keep their places) — empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis (since 0.9) closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **DSH host** (`plugins/dsh/dsh-host.ts`, a plain Cordis plugin — DSH's `createScope` needs a Cordis ctx; rows: `adapters::dsh_host_rows(dsh, sessions)` = host-services.json with same-id rows replaced by `dsh-host.json`'s (rutis-loader `desired.rs` keeps the FIRST row of an id, skips later — appending dsh-host.json after host-services.json leaves the storage-less persistence row, whose class has no `create`), then the rest; `.ts` names made absolute under `dsh`, jsonl `config.root` = `sessions`; unit test in adapters.rs). Injects agents, sessions, sessionPersistence, host_session {info → {id, generation} (validated), wake(text)}, host_workspace {cwd() → absolute path}, yoagent; every host call through `bounded` (HOST_CALL_MS 30 s, + the call's signal; agentFor's whole wait incl. the `serial` queue bounded by WAIT_MS and the signal); provides `yoagent-dsh-host` {noteRun(runId, parentRunId) — from the adapter's `tools(run)` and `before_model(turn)` hooks (inherited tree extensions skip `tools` — `ActiveExtensions::tools` filters `!inherited` — but reach `before_model`), so lineage is known without a DSH call for every run carrying the bridge; agentFor(runId, parentRunId, signal); endRun(runId, end, message)}, looked up per call by the tools adapter (passes `agent` to `tools.execute`; `finish` → endRun(outcome.end, failed ? outcome.error (+extension) : outcome.reason); an agentFor failure → error result + warn unless cancelled). One Generation per `${id}#${generation}`: session `sessions.prepare('yoagent-')` (DSH's default `session-` restarts per process and jsonl `create` refuses an id it already stores) + `enter` (detach) + `announce`; stand-in target marked `Symbol.for('yoagent-rutis.dsh.stand-in')` (host-dialogs' `isStandIn`), ctx = createScope(ctx, target).ctx, `await agents.register(target)` (thenable disposer); start failure unwinds what was done (undo list) before rethrowing; inbox writes/cancel/non-waking send warn once per generation and kind, runMaintenance runs its task at once (info once); steer/followup/waking send/inject → wake (warn on refusal). Turns per top-level run: `rootOf` maps a run to its root via parent_run_id (`rootFor`; filled by noteRun and agentFor, emptied by endRun; delegated runs join at any depth); owner = root; endRun closes without flushing and queues the flush (`finish` is bounded at 5 s); a different root finding a turn open closes it `aborted` {hook} with a warn; endRun closes only the owner's: cancelled → aborted {user}, failed → error {message, code UNKNOWN}, else completed; generation end/unload → aborted {disposed} (DSH's `TurnEndReason` requires the cause). Session log: `persistence.create(header)` once — the jsonl backend then routes every `session/event` to the open handle (manual appends duplicate → seq mismatch); checkpoint = `sessions.flush(session)` (false → warn once). host-dialogs: `tools/pre-execute` keeps a stand-in call's arguments by callId (MAX_PENDING_ARGS 256; dsh-tools asks approval AFTER the pre-execute waterfall) for the `approval/request` answerer (prepended; a `select` of 'Allow once'/'Deny' — not a confirm, whose host default for no answer is `false` (yobuild `default_answer`) — Allow once → allowed-once, Deny → rejected, null/no answer or host failure → unavailable, aborted → cancelled), and answers stand-in `user-questions/request`. Tests: `a_stand_in_agent_gives_dsh_plugins_a_workspace_turns_and_approvals` (approval outcomes by text; `read_logs` reads the jsonl logs back in a Node process: turns + audit pairs), `delegated_runs_join_the_top_level_run_s_dsh_turn` (grandchild under a child with no DSH call; fails without noteRun), `a_stand_in_needs_an_absolute_workspace` (fixture `fixture-needs-agent.ts`: fixture_turns returns {starts, ends}), `dsh_univer_office_works_through_the_stand_in_agent` (`--ignored`: univer 0.3.7 into a copy of plugins/dsh, free gateway port). DSH stays 0.2.0-rc.2 (published plugins peer `^0.2.0-rc.1`, which excludes 0.2.1 prereleases; 0.2.1-rc.2 probed working, needs the interface packages dsh-fs/dsh-attachment/dsh-session-persistence/dsh-home-paths). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.12.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1, and the **host services** (`plugins/dsh/host-services.json`: `dsh-fs-local`, `dsh-attachment-local`, `dsh-credentials-local`, `dsh-storage`, `dsh-session-persistence`, `dsh-skill`, `dsh-skill-filesystem` — the `-local` packages provide `fs`/`attachments`/`credentials` themselves; loading the interface package beside one fails "service registered"; attachments, `.credentials.yaml` (must be 0600, DSH refuses otherwise) and skills live under `DSH_HOME` (else `~/.dsh`), skills also under `DSH_AGENTS_HOME` (else `~/.agents`), so hosts set both (probe: a temp dir; yobuild's frontend: `DshHome` — `YOAGENT_DSH_HOME`, else a private 0700 dir per run; its examples only with `--dsh-services`); NOT covered: the inherited process env (credentials-local ranks it first), `/.env`, the project's `.dsh/skills` / `.agents/skills`, and the filesystem (`dsh-fs-local`, no sandbox); `dsh-storage` / `dsh-session-persistence` are hubs with no backend loaded (they make the services exist; nothing is stored); `settings` needs DSH app boot (configEditor, profileContext, Cordis loader) and `subagents`/`llm`/`agents` DSH's model layer — not provided; test `dsh_host_services_start_headless_in_their_own_dsh_home`: all six services exist, a plugin reads a key from the given `DSH_HOME`'s `.credentials.yaml` (source `file`), nothing is created under `HOME`); `plugins/dsh/fixture-needs-services.ts` (injects all six, registers `fixture_services` reading `YOAGENT_TEST_KEY` via `credentials`) + test `a_dsh_plugin_needing_host_services_runs_only_with_them` (with the services: the tool answers from DSH_HOME; without: the plugin never starts, the tool is not offered); the matrix summary counts failed/not-installed packages too; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); a dsh call with an integer beyond ±(2^53−1) in its arguments is refused by dsh ("losslessly JSON-serializable") before `tools/pre-execute`, so the approval dialog never sees a `bigint` (pinned in `dsh_tools_reach…`); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (features `node`, `loader`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.12.0; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions through pi's own loader, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`); `loadGated` writes one wrapper module per extension (`writeWrapper`: in `wrapperDir()` = `/yoagent-pi-wrappers-`, kept so jiti's transpile cache is reused (a fresh name per load leaked one `$TMPDIR/jiti` file each time), refused unless a real dir of this uid with no group/other write (Windows: only that it is a directory); named by sha256 of the resolved path, written atomically (temp + rename)) that pi's `loadExtensions` imports — so pi's jiti resolution and aliases apply — and that only hands the extension's default export over (`pi.events.emit(FACTORY_EVENT, {path, factory})` on the load's own event bus, which pi calls synchronously — no global state, so concurrent loads of one file each get their own; a file listed twice loads twice, as in pi; errors matched in path order; warnings passed through); all modules are imported first, then the factories run back to back (no I/O between the last factory and the load-time checks: `fixture-late-refusal.ts`'s `setTimeout(0)` relies on it); each factory is run by pi's exported `loadExtensionFromFactory` under the extension's **resolved** path (so `ext.path` = `ext.resolvedPath`), with a **copy** of `pi` (`{...pi}`, `exec` replaced unless the grant is `true`, plus `sendUserMessage` / `sendMessage` under `wakeFor`; not a Proxy: a Proxy's target leaks the real `exec` through `getOwnPropertyDescriptor`) whose `exec` follows the grant (`false` default / `true` / exact command list / `'gate'` — the command only, not args/cwd, so `git`/`sh`/`node` allow anything; refused → resolves `{code: 126, stdout: '', stderr: reason}`, `warn` once per extension+command+kind; pi's API only — an extension can still spawn processes itself); config `projectTrusted` (default false) answers `ctx.isProjectTrusted()`; config `execFor` (extension file → grant, keys resolved with pi's `resolvePath` like `extensions`, unknown key refuses the load — the `keyed` helper, shared by `projectTrustedFor` / `wakeFor`) overrides `exec` per extension (`gatePi(Gates)`: one copy of `pi` per extension; grant `'gate'` → `host_approval.judge(argv, cwd?)` bounded 30 s (`bounded`, `HOST_CALL_MS`), `{free:true}` runs, else `ask(question, details|null, file)`, only `'allow'` runs; argv / signal / timeout / cwd copied on entry and the call run from the copy (`pi.exec(argv[0], argv.slice(1), {signal, timeout, cwd: cwd ?? gates.cwd})` — the extension's array can change while asked), a non-string `options.cwd` refused; `orCancel` races the signal with a `CANCELLED` Symbol and removes its listener; refusals warned once per path+command+kind (`once`), cancels at debug; `wakeFor` → own `sendUserMessage` / `sendMessage` (records via pi's, then wakes on `triggerTurn`/`deliverAs`) → `host_session.wake(text, file)` on one ordered `wakes` chain, failure warned once per extension+reason; `Gates.active()` (`actionsLive`, set with the runtime actions, cleared at unload) makes a wake while loading throw pi's not-initialized error; `lookup(hostName, plugin)` → `Lookup` keeps the lookup error's text; `keyed` refuses a non-object setting and two spellings of one file; services looked up per call as `pi-host-approval` / `pi-host-session`, re-provided by `host-approval.ts` / `host-session.ts`); `projectTrustedFor` → `trusted(file)`; `context(file, signal?, runId?, systemPrompt?, modelId?)` takes the handler's/tool's extension file (tools: `sourceIn` caller), used by `isProjectTrusted` and every `fixedAnswer(question, answer, shown?, file?)` (`noUi(file)`, logged once per file+question+answer); tests `exec_gate_asks_the_host` (`FakeApproval`), `trust_and_wake_per_extension` (`WakeLog`), `the_gate_runs_what_it_judged`, `host_with(runtime, services)`; config `model` / `models` (`ModelSpec`, validated by `isModelSpec`) → `ctx.model` (`piModel`: frozen, `api: 'yoagent'`, zero cost; default `{provider: 'yoagent', id}` with the id from `before_model`'s `Turn.model`, per run (`runModels`, deleted in `finish`; `context(file, signal, runId)` — `modelOf(runId)`, contexts outside a run fall back to `latestModel`; observers take the model when `observe` builds the event, `agentEnd`'s kept past `finish` in `ending`)) and `ctx.modelRegistry` (`readOnlyRegistry`: getAll/getAvailable/find/getError/getProviderDisplayName/hasConfiguredAuth false/isUsingOAuth false/getRegisteredProviderIds, `*OfType` for `'chat'` only, `refresh` a no-op; `then`/`toJSON` undefined; `set`/`defineProperty`/`deleteProperty`/`setPrototypeOf` refused; any other member a function that throws, reported once at info); tests `exec_grants_per_extension`, `extensions_see_the_host_s_models_read_only`; `fixedAnswer` logs `[pi] fixed answer (): → ` at info once per file+question+answer (no `()` when no extension asked) (isProjectTrusted, ctx.model, ctx.modelRegistry, getContextUsage, print-mode ui dialogs; `model`/`modelRegistry` are getters, so a tool's ctx gets `tools`/`executeTool` added with `Object.defineProperties`, never a spread, which would read them); tests `pi_exec_runs_only_what_the_host_allows` (incl. the descriptor path), `extensions_load_through_the_wrappers_as_pi_loads_them` (dir + CJS, error names the extension not the wrapper, bad `exec` refused), `two_adapters_load_one_extension_at_once`, then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Observers (`OBSERVED_EVENTS`: agent_start/end, turn_start/end, message_start/update, tool_execution_start/update/end) are fired from the bridge's `on_event` (`observe` builds the pi event at once via `piEvent` — the run's tools still known — and queues `runHandlers` on one adapter-wide promise `chain`, returning immediately: awaiting handlers hit the bridge's 5 s per-event timeout, which switches the run's on_event off and fails a required run; per-handler `OBSERVER_HANDLER_BUDGET_MS` 30 s race (late → queue moves on, warn once per run); `.catch` on the chain so one rejection cannot stop it; `MAX_OBSERVER_BACKLOG` 1000 for `STREAMING_EVENTS`, 2000 for lifecycle, drops counted and reported per burst; `drainObservers` (declared before the `session_shutdown` effect, assigned later; sets `closed`, waits ≤ `OBSERVER_DRAIN_MS` 5 s) runs before the shutdown handlers; `message()` safe for errors whose toString throws), subscribed (`options.events`) only to the yoagent types the handlers present at load (after session_start) need (turn_start/turn_end both need turnStart+turnEnd: the index advances at every turn end); in order, each handler gets a `structuredClone`; tool calls mapped by `piCall` in tool events and inside messages (`observedMessage`); `turn_end` skipped for a non-assistant message (stop/cancel markers), as pi; `turns`/`running` cleared in `finish`; the run's offered tools kept in `ending` past `finish` for `agentEnd` (delivered after it) when subscribed; failure keys per run, cleared after `agent_end`, results ignored (`turn_end` continue/entries reported once at info), throws reported once per extension+event at warn and never fail the run; per run `turns` (index) and `running` (tool call → pi name/args via `piCall`, from the run's `snapshots` and `ARGS`); turn_end gets `messageEntryId` `/turn-`, empty `context` preview, `outcome` from stopReason; `message_end` stays on the checked path (never fired here); tests `pi_observer_events_fire_in_order`, `turn_start_alone_still_counts_turns`, `a_slow_observer_does_not_fail_the_run_or_drop_events` (6 s handler, required bridge), `queued_observer_events_come_before_session_shutdown`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn — unless the extension has `wakeFor` (see `gatePi`)) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws (`sendUserMessage` only without `wakeFor`). Tools = `registered()` (per name, the extension earliest in the `extensions` list wins — list order, not time) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run in `tools(run)` and **kept per run** in `snapshots` (`{offered, callable, sources}`, frozen shallow copies; cleared in `finish`): `before_tool`, `call_tool`, `after_tool` use `offeredIn(run_id)`, `executeNested` and `ctx.tools` use `callableIn(run_id)`, never a fresh lookup; config `nestedCalls` (`any` default / `own-extension` / `off`, unknown → load refused) narrows nested calls by the calling tool's `sourceIn(run_id, name)` (snapshot `sources` = `registeredBy()`), refusal = `isError` outcome + `report('info')`, checked before the lookup (under `off`) and before any foreign code, a call after the run's `finish` → "run … has ended", `ctx.tools` filtered the same; owner = the winning (first-in-list) registration (`pi_test` `nested_calls_follow_the_host_s_scope`, `own_extension_follows_the_winning_registration`), so a name registered mid-run by an earlier extension never takes over a call while `offered_by.source` names another file (yobuild's security review; `pi_test` `a_name_registered_mid_run_never_takes_over_the_offered_tool`). `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits and a `bigint` as `n` (rutis 0.11 sends integers beyond ±(2^53−1) as `bigint`, which `JSON.stringify` refuses) (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` = config `projectTrusted` (default false), `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (features `node`, `loader`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.12.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.12.0), `requirements.txt` (`rutis==0.12.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.12 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 22 — Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. +Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.12"` (0.x caret = 0.12.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9+ host sends call origins a 0.8 runtime rejects, 0.11+ sends integers beyond ±(2^53−1) as big integers that a 0.10-or-older peer refuses, and since 0.12 undeclared `ctx.provide`d services reach rutis (a name clash fails the later row); rutis 0.9–0.12 need Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.12"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.12.0 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.12.0) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nil; the call's context also ends when the hook returns (Go's `defer cancel`), so "never cancelled after completing" is not observable from Go; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks the tool + an `after_tool` redaction, that Go's denial / returned error / malformed answer each deny, that a policy timeout and a run cancel cancel Go's context, and that unloading the row removes the handler (disposer `Effect`), after which `bash` runs; a failed `go build` with Go present fails, never skips (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.12 a dev-dependency, and an optional one behind `probe`): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones — or ones with no JSON form (a `Date`; `Json::Null`, so later arguments keep their places) — empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis (since 0.9) closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **DSH host** (`plugins/dsh/dsh-host.ts`, a plain Cordis plugin — DSH's `createScope` needs a Cordis ctx; rows: `adapters::dsh_host_rows(dsh, sessions)` = host-services.json with same-id rows replaced by `dsh-host.json`'s (rutis-loader `desired.rs` keeps the FIRST row of an id, skips later — appending dsh-host.json after host-services.json leaves the storage-less persistence row, whose class has no `create`), then the rest; `.ts` names made absolute under `dsh`, jsonl `config.root` = `sessions`; unit test in adapters.rs). Injects agents, sessions, sessionPersistence, host_session {info → {id, generation} (validated), wake(text)}, host_workspace {cwd() → absolute path}, yoagent; every host call through `bounded` (HOST_CALL_MS 30 s, + the call's signal; agentFor's whole wait incl. the `serial` queue bounded by WAIT_MS and the signal); provides `yoagent-dsh-host` {noteRun(runId, parentRunId) — from the adapter's `tools(run)` and `before_model(turn)` hooks (inherited tree extensions skip `tools` — `ActiveExtensions::tools` filters `!inherited` — but reach `before_model`), so lineage is known without a DSH call for every run carrying the bridge; agentFor(runId, parentRunId, signal); endRun(runId, end, message)}, looked up per call by the tools adapter (passes `agent` to `tools.execute`; `finish` → endRun(outcome.end, failed ? outcome.error (+extension) : outcome.reason); an agentFor failure → error result + warn unless cancelled). One Generation per `${id}#${generation}`: session `sessions.prepare('yoagent-')` (DSH's default `session-` restarts per process and jsonl `create` refuses an id it already stores) + `enter` (detach) + `announce`; stand-in target marked `Symbol.for('yoagent-rutis.dsh.stand-in')` (host-dialogs' `isStandIn`), ctx = createScope(ctx, target).ctx, `await agents.register(target)` (thenable disposer); start failure unwinds what was done (undo list) before rethrowing; inbox writes/cancel/non-waking send warn once per generation and kind, runMaintenance runs its task at once (info once); steer/followup/waking send/inject → wake (warn on refusal). Turns per top-level run: `rootOf` maps a run to its root via parent_run_id (`rootFor`; filled by noteRun and agentFor, emptied by endRun; delegated runs join at any depth); owner = root; endRun closes without flushing and queues the flush (`finish` is bounded at 5 s); a different root finding a turn open closes it `aborted` {hook} with a warn; endRun closes only the owner's: cancelled → aborted {user}, failed → error {message, code UNKNOWN}, else completed; generation end/unload → aborted {disposed} (DSH's `TurnEndReason` requires the cause). Session log: `persistence.create(header)` once — the jsonl backend then routes every `session/event` to the open handle (manual appends duplicate → seq mismatch); checkpoint = `sessions.flush(session)` (false → warn once). host-dialogs: `tools/pre-execute` keeps a stand-in call's arguments by callId (MAX_PENDING_ARGS 256; dsh-tools asks approval AFTER the pre-execute waterfall) for the `approval/request` answerer (prepended; a `select` of 'Allow once'/'Deny' — not a confirm, whose host default for no answer is `false` (yobuild `default_answer`) — Allow once → allowed-once, Deny → rejected, null/no answer or host failure → unavailable, aborted → cancelled), and answers stand-in `user-questions/request`. Tests: `a_stand_in_agent_gives_dsh_plugins_a_workspace_turns_and_approvals` (approval outcomes by text; `read_logs` reads the jsonl logs back in a Node process: turns + audit pairs), `delegated_runs_join_the_top_level_run_s_dsh_turn` (grandchild under a child with no DSH call; fails without noteRun), `a_stand_in_needs_an_absolute_workspace` (fixture `fixture-needs-agent.ts`: fixture_turns returns {starts, ends}), `dsh_univer_office_works_through_the_stand_in_agent` (`--ignored`: univer 0.3.7 into a copy of plugins/dsh, free gateway port). DSH stays 0.2.0-rc.2 (published plugins peer `^0.2.0-rc.1`, which excludes 0.2.1 prereleases; 0.2.1-rc.2 probed working, needs the interface packages dsh-fs/dsh-attachment/dsh-session-persistence/dsh-home-paths). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.12.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1, and the **host services** (`plugins/dsh/host-services.json`: `dsh-fs-local`, `dsh-attachment-local`, `dsh-credentials-local`, `dsh-storage`, `dsh-session-persistence`, `dsh-skill`, `dsh-skill-filesystem` — the `-local` packages provide `fs`/`attachments`/`credentials` themselves; loading the interface package beside one fails "service registered"; attachments, `.credentials.yaml` (must be 0600, DSH refuses otherwise) and skills live under `DSH_HOME` (else `~/.dsh`), skills also under `DSH_AGENTS_HOME` (else `~/.agents`), so hosts set both (probe: a temp dir; yobuild's frontend: `DshHome` — `YOAGENT_DSH_HOME`, else a private 0700 dir per run; its examples only with `--dsh-services`); NOT covered: the inherited process env (credentials-local ranks it first; the probe's `ProbeOptions { env }` via `probe::pi_with` / `dsh_with` sets variables over it — `host(runtime, package_json, &[(OsString, OsString)])`, the caller's first, then the probe's `DSH_HOME` / `DSH_AGENTS_HOME`, which win — test `probe_options_set_the_runtime_s_environment`), `/.env`, the project's `.dsh/skills` / `.agents/skills`, and the filesystem (`dsh-fs-local`, no sandbox); `dsh-storage` / `dsh-session-persistence` are hubs with no backend loaded (they make the services exist; nothing is stored); `settings` needs DSH app boot (configEditor, profileContext, Cordis loader) and `subagents`/`llm`/`agents` DSH's model layer — not provided; test `dsh_host_services_start_headless_in_their_own_dsh_home`: all six services exist, a plugin reads a key from the given `DSH_HOME`'s `.credentials.yaml` (source `file`), nothing is created under `HOME`); `plugins/dsh/fixture-needs-services.ts` (injects all six, registers `fixture_services` reading `YOAGENT_TEST_KEY` via `credentials`) + test `a_dsh_plugin_needing_host_services_runs_only_with_them` (with the services: the tool answers from DSH_HOME; without: the plugin never starts, the tool is not offered); the matrix summary counts failed/not-installed packages too; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); a dsh call with an integer beyond ±(2^53−1) in its arguments is refused by dsh ("losslessly JSON-serializable") before `tools/pre-execute`, so the approval dialog never sees a `bigint` (pinned in `dsh_tools_reach…`); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (features `node`, `loader`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.12.0; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions through pi's own loader, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`); `loadGated` writes one wrapper module per extension (`writeWrapper`: in `wrapperDir()` = `/yoagent-pi-wrappers-`, kept so jiti's transpile cache is reused (a fresh name per load leaked one `$TMPDIR/jiti` file each time), refused unless a real dir of this uid with no group/other write (Windows: only that it is a directory); named by sha256 of the resolved path, written atomically (temp + rename)) that pi's `loadExtensions` imports — so pi's jiti resolution and aliases apply — and that only hands the extension's default export over (`pi.events.emit(FACTORY_EVENT, {path, factory})` on the load's own event bus, which pi calls synchronously — no global state, so concurrent loads of one file each get their own; a file listed twice loads twice, as in pi; errors matched in path order; warnings passed through); all modules are imported first, then the factories run back to back (no I/O between the last factory and the load-time checks: `fixture-late-refusal.ts`'s `setTimeout(0)` relies on it); each factory is run by pi's exported `loadExtensionFromFactory` under the extension's **resolved** path (so `ext.path` = `ext.resolvedPath`), with a **copy** of `pi` (`{...pi}`, `exec` replaced unless the grant is `true`, plus `sendUserMessage` / `sendMessage` under `wakeFor`; not a Proxy: a Proxy's target leaks the real `exec` through `getOwnPropertyDescriptor`) whose `exec` follows the grant (`false` default / `true` / exact command list / `'gate'` — the command only, not args/cwd, so `git`/`sh`/`node` allow anything; refused → resolves `{code: 126, stdout: '', stderr: reason}`, `warn` once per extension+command+kind; pi's API only — an extension can still spawn processes itself); config `projectTrusted` (default false) answers `ctx.isProjectTrusted()`; config `execFor` (extension file → grant, keys resolved with pi's `resolvePath` like `extensions`, unknown key refuses the load — the `keyed` helper, shared by `projectTrustedFor` / `wakeFor`) overrides `exec` per extension (`gatePi(Gates)`: one copy of `pi` per extension; grant `'gate'` → `host_approval.judge(argv, cwd?)` bounded 30 s (`bounded`, `HOST_CALL_MS`), `{free:true}` runs, else `ask(question, details|null, file)`, only `'allow'` runs; argv / signal / timeout / cwd copied on entry and the call run from the copy (`pi.exec(argv[0], argv.slice(1), {signal, timeout, cwd: cwd ?? gates.cwd})` — the extension's array can change while asked), a non-string `options.cwd` refused; `orCancel` races the signal with a `CANCELLED` Symbol and removes its listener; refusals warned once per path+command+kind (`once`), cancels at debug; `wakeFor` → own `sendUserMessage` / `sendMessage` (records via pi's, then wakes on `triggerTurn`/`deliverAs`) → `host_session.wake(text, file)` on one ordered `wakes` chain, failure warned once per extension+reason; `Gates.active()` (`actionsLive`, set with the runtime actions, cleared at unload) makes a wake while loading throw pi's not-initialized error; `lookup(hostName, plugin)` → `Lookup` keeps the lookup error's text; `keyed` refuses a non-object setting and two spellings of one file; services looked up per call as `pi-host-approval` / `pi-host-session`, re-provided by `host-approval.ts` / `host-session.ts`); `projectTrustedFor` → `trusted(file)`; `context(file, signal?, runId?, systemPrompt?, modelId?)` takes the handler's/tool's extension file (tools: `sourceIn` caller), used by `isProjectTrusted` and every `fixedAnswer(question, answer, shown?, file?)` (`noUi(file)`, logged once per file+question+answer); tests `exec_gate_asks_the_host` (`FakeApproval`), `trust_and_wake_per_extension` (`WakeLog`), `the_gate_runs_what_it_judged`, `host_with(runtime, services)`; config `model` / `models` (`ModelSpec`, validated by `isModelSpec`) → `ctx.model` (`piModel`: frozen, `api: 'yoagent'`, zero cost; default `{provider: 'yoagent', id}` with the id from `before_model`'s `Turn.model`, per run (`runModels`, deleted in `finish`; `context(file, signal, runId)` — `modelOf(runId)`, contexts outside a run fall back to `latestModel`; observers take the model when `observe` builds the event, `agentEnd`'s kept past `finish` in `ending`)) and `ctx.modelRegistry` (`readOnlyRegistry`: getAll/getAvailable/find/getError/getProviderDisplayName/hasConfiguredAuth false/isUsingOAuth false/getRegisteredProviderIds, `*OfType` for `'chat'` only, `refresh` a no-op; `then`/`toJSON` undefined; `set`/`defineProperty`/`deleteProperty`/`setPrototypeOf` refused; any other member a function that throws, reported once at info); tests `exec_grants_per_extension`, `extensions_see_the_host_s_models_read_only`; `fixedAnswer` logs `[pi] fixed answer (): → ` at info once per file+question+answer (no `()` when no extension asked) (isProjectTrusted, ctx.model, ctx.modelRegistry, getContextUsage, print-mode ui dialogs; `model`/`modelRegistry` are getters, so a tool's ctx gets `tools`/`executeTool` added with `Object.defineProperties`, never a spread, which would read them); tests `pi_exec_runs_only_what_the_host_allows` (incl. the descriptor path), `extensions_load_through_the_wrappers_as_pi_loads_them` (dir + CJS, error names the extension not the wrapper, bad `exec` refused), `two_adapters_load_one_extension_at_once`, then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Observers (`OBSERVED_EVENTS`: agent_start/end, turn_start/end, message_start/update, tool_execution_start/update/end) are fired from the bridge's `on_event` (`observe` builds the pi event at once via `piEvent` — the run's tools still known — and queues `runHandlers` on one adapter-wide promise `chain`, returning immediately: awaiting handlers hit the bridge's 5 s per-event timeout, which switches the run's on_event off and fails a required run; per-handler `OBSERVER_HANDLER_BUDGET_MS` 30 s race (late → queue moves on, warn once per run); `.catch` on the chain so one rejection cannot stop it; `MAX_OBSERVER_BACKLOG` 1000 for `STREAMING_EVENTS`, 2000 for lifecycle, drops counted and reported per burst; `drainObservers` (declared before the `session_shutdown` effect, assigned later; sets `closed`, waits ≤ `OBSERVER_DRAIN_MS` 5 s) runs before the shutdown handlers; `message()` safe for errors whose toString throws), subscribed (`options.events`) only to the yoagent types the handlers present at load (after session_start) need (turn_start/turn_end both need turnStart+turnEnd: the index advances at every turn end); in order, each handler gets a `structuredClone`; tool calls mapped by `piCall` in tool events and inside messages (`observedMessage`); `turn_end` skipped for a non-assistant message (stop/cancel markers), as pi; `turns`/`running` cleared in `finish`; the run's offered tools kept in `ending` past `finish` for `agentEnd` (delivered after it) when subscribed; failure keys per run, cleared after `agent_end`, results ignored (`turn_end` continue/entries reported once at info), throws reported once per extension+event at warn and never fail the run; per run `turns` (index) and `running` (tool call → pi name/args via `piCall`, from the run's `snapshots` and `ARGS`); turn_end gets `messageEntryId` `/turn-`, empty `context` preview, `outcome` from stopReason; `message_end` stays on the checked path (never fired here); tests `pi_observer_events_fire_in_order`, `turn_start_alone_still_counts_turns`, `a_slow_observer_does_not_fail_the_run_or_drop_events` (6 s handler, required bridge), `queued_observer_events_come_before_session_shutdown`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn — unless the extension has `wakeFor` (see `gatePi`)) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws (`sendUserMessage` only without `wakeFor`). Tools = `registered()` (per name, the extension earliest in the `extensions` list wins — list order, not time) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run in `tools(run)` and **kept per run** in `snapshots` (`{offered, callable, sources}`, frozen shallow copies; cleared in `finish`): `before_tool`, `call_tool`, `after_tool` use `offeredIn(run_id)`, `executeNested` and `ctx.tools` use `callableIn(run_id)`, never a fresh lookup; config `nestedCalls` (`any` default / `own-extension` / `off`, unknown → load refused) narrows nested calls by the calling tool's `sourceIn(run_id, name)` (snapshot `sources` = `registeredBy()`), refusal = `isError` outcome + `report('info')`, checked before the lookup (under `off`) and before any foreign code, a call after the run's `finish` → "run … has ended", `ctx.tools` filtered the same; owner = the winning (first-in-list) registration (`pi_test` `nested_calls_follow_the_host_s_scope`, `own_extension_follows_the_winning_registration`), so a name registered mid-run by an earlier extension never takes over a call while `offered_by.source` names another file (yobuild's security review; `pi_test` `a_name_registered_mid_run_never_takes_over_the_offered_tool`). `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits and a `bigint` as `n` (rutis 0.11 sends integers beyond ±(2^53−1) as `bigint`, which `JSON.stringify` refuses) (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` = config `projectTrusted` (default false), `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (features `node`, `loader`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.12.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.12.0), `requirements.txt` (`rutis==0.12.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.12 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 22 — Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. **Waiting rows** (`src/rows.rs`, feature `loader` = `dep:rutis-loader` + `dep:rutis-bridge`; `probe` includes it): `rows::wait_running(loader, ctx, layers, ids: &[impl AsRef], timeout) -> Result<(), RowsNotRunning { rows: Vec>, note: Option }> }>` polls `loader.get(id)` until each row's fiber is Active. `RowState` (`#[non_exhaustive]` enum, `Display`, `may_run()`; `Other(String)` = unstable diagnostic text): Waiting (Pending) / Loading / Unloading / Inactive keep the wait; Failed / Disposed / Unresolved / Disabled / Stopped / NoSuchRow / Other end it at once, and the error then lists every row not running. `Loader::get` reads Inactive for rows under a disabled or stopped group, an unmounted loader, and every row in an instanced group (copies run per instance) — those cannot be waited for. At the timeout each **Waiting** row's `missing` = `Some(` the `Loader::check(layers)` requirements (bounded by `CHECK_LIMIT` 10 s) not `Supply::Provided` with `Provider::Host` (host catalog `register*`/`readable*`/`export` names, under their own keys) whose name has no `host_key(name)` `dyn HostDispatch` from `ctx` `)`; `note` says why `missing` is `None`/partial (check timed out, refused the row, plugin's declarations unknown, row not in the layers, disabled there). `check` alone is unreliable: with another 0.12 JS row loaded an unprovided name reads `Announced` (only *other* announcing rows count), a name another row only declares reads `Provided(Row)`, and a `register_shared` host name reads Missing/Announced. Used by `examples/dsh_tools.rs`, `examples/pi_extensions.rs` (required-features `node`, `loader`); tests `languages_test` `wait_running_names_the_services_a_waiting_row_lacks`, `wait_running_ends_at_once_or_waits_by_state` (`#[cfg(feature = "loader")]`, fixtures `JS_NEEDS`, `JS_SLOW`). diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 253eb4c7..cf01deac 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -325,7 +325,11 @@ what the plugin logged — without calling a tool or a model (loading runs the package's code, as its own host would). Each report's `problem()` is the verdict: `None` for a clean load, else what kept the package from giving an agent what it offers (a refusal after load included). Probes in -one process run one at a time. +one process run one at a time. The probe's Node process inherits this +process's environment; `probe::pi_with` / `dsh_with` take a `ProbeOptions` +with variables to set on top (`ProbeOptions::default().env(name, value)`; +an empty value blanks one) — a host that keeps provider keys from plugins +sets each of `yoagent::provider::api_key_variables()` to `""` there too. ```rust,no_run # async fn check() { diff --git a/integrations/yoagent-rutis/src/probe.rs b/integrations/yoagent-rutis/src/probe.rs index 9b47701e..831bb7a4 100644 --- a/integrations/yoagent-rutis/src/probe.rs +++ b/integrations/yoagent-rutis/src/probe.rs @@ -25,11 +25,18 @@ //! **A verdict.** Each report's `problem()` says what kept the package from //! giving an agent what it offers, if anything; `None` is a clean load. //! +//! **The runtime's environment.** The probe's Node process inherits this +//! process's environment. [`pi_with`] / [`dsh_with`] take a [`ProbeOptions`] +//! with variables to set on top — a host that keeps credentials from plugins +//! sets each of `yoagent::provider::api_key_variables()` to the empty +//! string, as it does for the runtimes it starts itself. +//! //! **One at a time.** Probes in one process run one after another (a second //! waits): [`Logs`] is one buffer for the process, and each report carries //! the lines of its own load. Loading a package runs its code, as its own //! host would. +use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -240,13 +247,32 @@ struct Host { loader: Loader, } +/// How a probe starts its runtime. `Default` is what [`pi`] and [`dsh`] use. +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct ProbeOptions { + /// Variables set in the probe's Node process, over the environment it + /// inherits; an empty value sets the variable to the empty string (a + /// launcher cannot remove one). The probe's own (the DSH probe's private + /// `DSH_HOME` / `DSH_AGENTS_HOME`) are set after these and win. + pub env: Vec<(OsString, OsString)>, +} + +impl ProbeOptions { + /// These options with `name` set to `value` in the probe's runtime. + pub fn env(mut self, name: impl Into, value: impl Into) -> Self { + self.env.push((name.into(), value.into())); + self + } +} + /// A rutis root with the bridge and one Node runtime (`runtime`, the /// `@arcships/rutis-runtime` package) whose `package.json` is /// `package_json`, where row names resolve; its process given `env`. async fn host( runtime: &Path, package_json: &Path, - env: &[(&str, &Path)], + env: &[(OsString, OsString)], ) -> Result { let root = Ctx::root()?; let bridge = RutisBridge::install(&root)?; @@ -260,7 +286,7 @@ async fn host( let launcher = env .iter() .fold(Launcher::node(runtime), |launcher, (name, value)| { - launcher.env(*name, *value) + launcher.env(name.clone(), value.clone()) }); let node = LocalRuntime::launcher("node", launcher, package_json); let resolver = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog)); @@ -464,6 +490,26 @@ pub async fn pi( extensions: &[String], skill_paths: &[PathBuf], logs: Option<&Logs>, +) -> PiReport { + pi_with( + plugins, + cwd, + extensions, + skill_paths, + logs, + &ProbeOptions::default(), + ) + .await +} + +/// [`pi`], with the runtime started as `options` say (its environment). +pub async fn pi_with( + plugins: &Path, + cwd: &Path, + extensions: &[String], + skill_paths: &[PathBuf], + logs: Option<&Logs>, + options: &ProbeOptions, ) -> PiReport { let _one = ONE_AT_A_TIME.lock().await; let mut report = PiReport { @@ -473,7 +519,15 @@ pub async fn pi( if extensions.is_empty() { return report; } - pi_load(&mut report, &plugins.join("pi"), cwd, extensions, logs).await; + pi_load( + &mut report, + &plugins.join("pi"), + cwd, + extensions, + logs, + options, + ) + .await; report.logs = logs.map(Logs::lines).unwrap_or_default(); report } @@ -484,6 +538,7 @@ async fn pi_load( cwd: &Path, extensions: &[String], logs: Option<&Logs>, + options: &ProbeOptions, ) { let runtime = pi_dir.join("node_modules/@arcships/rutis-runtime"); let mut without: Vec = Vec::new(); @@ -494,7 +549,7 @@ async fn pi_load( logs.clear(); } report.without = without.clone(); - let host = match host(&runtime, &pi_dir.join("package.json"), &[]).await { + let host = match host(&runtime, &pi_dir.join("package.json"), &options.env).await { Ok(host) => host, Err(e) => { report.loaded = Some(false); @@ -623,12 +678,24 @@ pub async fn dsh( rows: &Value, inject: &[String], logs: Option<&Logs>, +) -> DshReport { + dsh_with(plugins, base, rows, inject, logs, &ProbeOptions::default()).await +} + +/// [`dsh`], with the runtime started as `options` say (its environment). +pub async fn dsh_with( + plugins: &Path, + base: &Path, + rows: &Value, + inject: &[String], + logs: Option<&Logs>, + options: &ProbeOptions, ) -> DshReport { let _one = ONE_AT_A_TIME.lock().await; if let Some(logs) = logs { logs.clear(); } - let mut report = match dsh_load(&plugins.join("dsh"), base, rows, inject).await { + let mut report = match dsh_load(&plugins.join("dsh"), base, rows, inject, options).await { Ok(report) => report, Err(e) => DshReport { error: Some(e.to_string()), @@ -644,6 +711,7 @@ async fn dsh_load( base: &Path, rows: &Value, inject: &[String], + options: &ProbeOptions, ) -> Result { let plugin_ids = row_ids(rows)?; let mut report = DshReport::default(); @@ -651,16 +719,13 @@ async fn dsh_load( let dsh_home = tempfile::tempdir()?; let agents_home = dsh_home.path().join("agents"); let base_rows = dsh_base_rows(dsh_dir, base)?; - let host = host( - &runtime, - &base.join("package.json"), - &[ - ("DSH_HOME", dsh_home.path()), - ("DSH_AGENTS_HOME", &agents_home), - ], - ) - .await - .map_err(|e| format!("host: {e}"))?; + // The caller's variables, then the probe's own private homes (which win). + let mut env = options.env.clone(); + env.push(("DSH_HOME".into(), dsh_home.path().into())); + env.push(("DSH_AGENTS_HOME".into(), agents_home.clone().into())); + let host = host(&runtime, &base.join("package.json"), &env) + .await + .map_err(|e| format!("host: {e}"))?; // The base alone first, then the plugin's rows on top: the difference is // what the plugin adds. let base_state = load(&host, vec![("base", base_rows.clone())]).await; diff --git a/integrations/yoagent-rutis/tests/probe_test.rs b/integrations/yoagent-rutis/tests/probe_test.rs index 65c86c70..77976dd5 100644 --- a/integrations/yoagent-rutis/tests/probe_test.rs +++ b/integrations/yoagent-rutis/tests/probe_test.rs @@ -240,3 +240,81 @@ async fn a_base_of_its_own_gets_the_probe_files_copied_in() { assert!(base.path().join("probe-services.ts").is_file()); assert!(base.path().join("dsh-tools-adapter.ts").is_file()); } + +/// `pi_with` / `dsh_with`: the caller's variables reach the probe's runtime, +/// an empty value included (how a host blanks provider keys); the DSH +/// probe's own private `DSH_HOME` wins over a caller's. +#[tokio::test(flavor = "multi_thread")] +async fn probe_options_set_the_runtime_s_environment() { + let pi = adapters().pi; + if !installed(&pi) { + return; + } + let dir = tempfile::tempdir().unwrap(); + let extension = dir.path().join("env.ts"); + std::fs::write( + &extension, + "const said = (v) => v === undefined ? 'unset' : v === '' ? 'empty' : v\n\ + export default function (pi) { pi.registerTool({ \ + name: `seen_${said(process.env.PROBE_VALUE)}_${said(process.env.PROBE_BLANK)}`, \ + label: 'x', description: 'x', parameters: { type: 'object', properties: {} }, \ + async execute() { return { content: [], details: {} } } }) }", + ) + .unwrap(); + let options = probe::ProbeOptions::default() + .env("PROBE_VALUE", "abc") + .env("PROBE_BLANK", ""); + let report = probe::pi_with( + &adapters().root, + dir.path(), + &[extension.display().to_string()], + &[], + None, + &options, + ) + .await; + assert!( + report.tools.iter().any(|t| t == "seen_abc_empty"), + "{report:?}" + ); + // Without options: unset, as before. + let report = probe::pi( + &adapters().root, + dir.path(), + &[extension.display().to_string()], + &[], + None, + ) + .await; + assert!( + report.tools.iter().any(|t| t == "seen_unset_unset"), + "{report:?}" + ); + + let dsh = adapters().dsh; + if !installed(&dsh) { + return; + } + let out = dir.path().join("dsh-env.json"); + let plugin = dir.path().join("env-plugin.ts"); + std::fs::write( + &plugin, + format!( + "import {{ writeFileSync }} from 'node:fs'\n\ + export const name = 'env-probe'\n\ + export function apply() {{ writeFileSync({out:?}, JSON.stringify({{ \ + value: process.env.PROBE_VALUE, home: process.env.DSH_HOME }})) }}" + ), + ) + .unwrap(); + let rows = json!([{ "id": "env", "name": plugin }]); + let options = probe::ProbeOptions::default() + .env("PROBE_VALUE", "abc") + .env("DSH_HOME", "/caller/home"); + let report = probe::dsh_with(&adapters().root, &dsh, &rows, &[], None, &options).await; + assert_eq!(report.error, None, "{report:?}"); + let seen: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap(); + assert_eq!(seen["value"], "abc", "{seen}"); + assert_ne!(seen["home"], "/caller/home", "{seen}"); +} diff --git a/src/decision/mod.rs b/src/decision/mod.rs index 4eb40a97..d3d205b5 100644 --- a/src/decision/mod.rs +++ b/src/decision/mod.rs @@ -101,7 +101,7 @@ mod logprobs; mod mock; mod openai; mod question; -mod systemone; +pub(crate) mod systemone; pub use advisory::Advisory; pub use answer::{ diff --git a/src/provider/mod.rs b/src/provider/mod.rs index d71b19ec..8a8cf63f 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -38,7 +38,7 @@ pub use prices::{ PRICED_PROVIDERS, PRICES_ENV_VAR, PRICE_SCHEMA_VERSION, }; pub(crate) use registry::resolve_api_key_or_warn; -pub use registry::{resolve_api_key, ProviderRegistry}; +pub use registry::{api_key_variables, resolve_api_key, ProviderRegistry}; pub use tool_args::{parse_tool_arguments, unparsed_tool_arguments, UNPARSED_ARGUMENTS_KEY}; pub use traits::*; pub use turn_hook::TurnHookProvider; diff --git a/src/provider/registry.rs b/src/provider/registry.rs index 5a0f3259..f335b8c1 100644 --- a/src/provider/registry.rs +++ b/src/provider/registry.rs @@ -126,6 +126,119 @@ mod tests { ); assert!(registry.has(&ApiProtocol::AnthropicMessages)); } + + #[test] + fn api_key_variables_lists_every_credential_once() { + let names = super::api_key_variables(); + for name in [ + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", + "MODEL_API_KEY", + "OPENCODE_API_KEY", + "AZURE_OPENAI_API_KEY", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "YOAGENT_API_KEY", + "API_KEY", + "TYPESAFE_API_KEY", + "CLOUDFLARE_AUTH_TOKEN", + ] { + assert!(names.contains(&name), "{name} missing from {names:?}"); + } + let mut unique = names.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(unique.len(), names.len(), "a name listed twice: {names:?}"); + // Every provider's own variables come first, in the table's order. + assert_eq!(&names[..2], ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]); + } + + /// The decision presets read these names from their own constants. + #[cfg(feature = "decision")] + #[test] + fn decision_key_variables_match_the_presets() { + use crate::decision::systemone::{ + CLOUDFLARE_API_TOKEN_ENV, CLOUDFLARE_AUTH_TOKEN_ENV, TYPESAFE_API_KEY_ENV, + }; + assert_eq!( + super::DECISION_KEY_VARIABLES, + [ + TYPESAFE_API_KEY_ENV, + CLOUDFLARE_API_TOKEN_ENV, + CLOUDFLARE_AUTH_TOKEN_ENV + ] + ); + } +} + +/// The variables [`resolve_api_key`] reads for each provider name with keys of +/// its own, first match wins (Bedrock's, `vertex` and the keyless locals are +/// handled apart; any other name reads [`FALLBACK_KEY_VARIABLES`]). +const PROVIDER_KEY_VARIABLES: &[(&str, &[&str])] = &[ + ("anthropic", &["ANTHROPIC_API_KEY"]), + ("openai", &["OPENAI_API_KEY"]), + ("google", &["GEMINI_API_KEY", "GOOGLE_API_KEY"]), + ("xai", &["XAI_API_KEY"]), + ("groq", &["GROQ_API_KEY"]), + ("deepseek", &["DEEPSEEK_API_KEY"]), + ("mistral", &["MISTRAL_API_KEY"]), + ("zai", &["ZAI_API_KEY"]), + ("minimax", &["MINIMAX_API_KEY"]), + // Meta's docs name the generic MODEL_API_KEY; prefer the unambiguous + // META_API_KEY, accept the official one. + ("meta", &["META_API_KEY", "MODEL_API_KEY"]), + ("openrouter", &["OPENROUTER_API_KEY"]), + ("cerebras", &["CEREBRAS_API_KEY"]), + ("qwen", &["DASHSCOPE_API_KEY"]), + ("opencode-zen", &["OPENCODE_API_KEY"]), + ("opencode-go", &["OPENCODE_API_KEY"]), + ("azure", &["AZURE_OPENAI_API_KEY"]), +]; + +/// What [`resolve_api_key`] reads for a provider name it does not know. +const FALLBACK_KEY_VARIABLES: &[&str] = &["YOAGENT_API_KEY", "API_KEY"]; + +/// Bedrock's credentials: a Bedrock API key, else an AWS access key pair (and +/// session token). +const BEDROCK_KEY_VARIABLES: &[&str] = &[ + crate::provider::bedrock::BEARER_TOKEN_ENV, + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", +]; + +/// Credentials the decision-model presets read (feature `decision`), beyond +/// the provider keys above: TypeSafe's (`jev()`) and Cloudflare's (`clef*()`). +const DECISION_KEY_VARIABLES: &[&str] = &[ + "TYPESAFE_API_KEY", + "CLOUDFLARE_API_TOKEN", + "CLOUDFLARE_AUTH_TOKEN", +]; + +/// Every environment variable yoagent reads a credential from: each +/// provider's key variables as [`resolve_api_key`] reads them (Bedrock's +/// AWS ones included), its `YOAGENT_API_KEY` / `API_KEY` fallback, and the +/// decision-model presets' keys — each once, in that order. +/// +/// For hosts that keep credentials away from code they run (plugins, MCP +/// servers): set each of these to the empty string in the child's +/// environment when the launcher can only add variables. A key a host +/// passes under another name (a manifest's own variable) is not in it. +pub fn api_key_variables() -> Vec<&'static str> { + let mut names: Vec<&'static str> = Vec::new(); + let all = PROVIDER_KEY_VARIABLES + .iter() + .flat_map(|(_, vars)| vars.iter()) + .chain(BEDROCK_KEY_VARIABLES) + .chain(FALLBACK_KEY_VARIABLES) + .chain(DECISION_KEY_VARIABLES); + for name in all { + if !names.contains(name) { + names.push(name); + } + } + names } /// Resolve an API key from the conventional environment variable(s) for a @@ -158,23 +271,6 @@ pub fn resolve_api_key(provider: &str) -> Option { }) }; match provider { - "anthropic" => first(&["ANTHROPIC_API_KEY"]), - "openai" => first(&["OPENAI_API_KEY"]), - "google" => first(&["GEMINI_API_KEY", "GOOGLE_API_KEY"]), - "xai" => first(&["XAI_API_KEY"]), - "groq" => first(&["GROQ_API_KEY"]), - "deepseek" => first(&["DEEPSEEK_API_KEY"]), - "mistral" => first(&["MISTRAL_API_KEY"]), - "zai" => first(&["ZAI_API_KEY"]), - "minimax" => first(&["MINIMAX_API_KEY"]), - // Meta's docs name the generic MODEL_API_KEY; prefer the unambiguous - // META_API_KEY, accept the official one. - "meta" => first(&["META_API_KEY", "MODEL_API_KEY"]), - "openrouter" => first(&["OPENROUTER_API_KEY"]), - "cerebras" => first(&["CEREBRAS_API_KEY"]), - "qwen" => first(&["DASHSCOPE_API_KEY"]), - "opencode-zen" | "opencode-go" => first(&["OPENCODE_API_KEY"]), - "azure" => first(&["AZURE_OPENAI_API_KEY"]), "bedrock" => { // Empty values count as unset (a blanked-out export). let set = |n: &str| var(n).ok().filter(|v| !v.trim().is_empty()); @@ -195,7 +291,12 @@ pub fn resolve_api_key(provider: &str) -> Option { } "vertex" => None, "local" | "ollama" => Some(String::new()), - _ => first(&["YOAGENT_API_KEY", "API_KEY"]), + other => first( + PROVIDER_KEY_VARIABLES + .iter() + .find(|(name, _)| *name == other) + .map_or(FALLBACK_KEY_VARIABLES, |(_, vars)| vars), + ), } } From 33e19a6276f09154f6ff3e38a458edf82709d04f Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Wed, 21 Oct 2026 07:59:14 +0200 Subject: [PATCH 2/2] fix: review follow-ups for api_key_variables and the probe env test - The Bedrock branch of resolve_api_key reads its names from BEDROCK_KEY_VARIABLES too. - `systemone` stays private; a cfg(test) re-export gives the test its three constants. - The DSH probe test asserts DSH_HOME is set and is the probe's own. - CHANGELOG says precisely what cannot drift. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 2 +- integrations/yoagent-rutis/tests/probe_test.rs | 6 +++++- src/decision/mod.rs | 7 ++++++- src/provider/registry.rs | 11 +++++++---- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a90526..f29ba276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ adheres to [Semantic Versioning](https://semver.org/). ## Unreleased -- **`provider::api_key_variables()`**: every environment variable yoagent reads a credential from — each provider's key variables as `resolve_api_key` reads them (Bedrock's `AWS_*` included), the `YOAGENT_API_KEY` / `API_KEY` fallback, and the decision presets' keys (`TYPESAFE_API_KEY`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_AUTH_TOKEN`) — for hosts that blank credentials in processes they start (plugin runtimes, MCP servers). `resolve_api_key` now reads the same table, so the two cannot drift. Additive. Asked for by yobuild. +- **`provider::api_key_variables()`**: every environment variable yoagent reads a credential from — each provider's key variables as `resolve_api_key` reads them (Bedrock's `AWS_*` included), the `YOAGENT_API_KEY` / `API_KEY` fallback, and the decision presets' keys (`TYPESAFE_API_KEY`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_AUTH_TOKEN`) — for hosts that blank credentials in processes they start (plugin runtimes, MCP servers). `resolve_api_key` now reads the same per-provider table, so a provider's variables cannot drift from the list (Bedrock's and the decision presets' names are pinned by tests). Additive. Asked for by yobuild. ## yoagent-rutis Unreleased diff --git a/integrations/yoagent-rutis/tests/probe_test.rs b/integrations/yoagent-rutis/tests/probe_test.rs index 77976dd5..cc8ff4e1 100644 --- a/integrations/yoagent-rutis/tests/probe_test.rs +++ b/integrations/yoagent-rutis/tests/probe_test.rs @@ -316,5 +316,9 @@ async fn probe_options_set_the_runtime_s_environment() { let seen: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap(); assert_eq!(seen["value"], "abc", "{seen}"); - assert_ne!(seen["home"], "/caller/home", "{seen}"); + // Set, and the probe's own (a caller's does not override it). + assert!( + seen["home"].as_str().is_some_and(|h| h != "/caller/home"), + "{seen}" + ); } diff --git a/src/decision/mod.rs b/src/decision/mod.rs index d3d205b5..e7f7f397 100644 --- a/src/decision/mod.rs +++ b/src/decision/mod.rs @@ -101,7 +101,12 @@ mod logprobs; mod mock; mod openai; mod question; -pub(crate) mod systemone; +mod systemone; +/// For `provider::api_key_variables`' test: the decision presets' key variables. +#[cfg(test)] +pub(crate) use systemone::{ + CLOUDFLARE_API_TOKEN_ENV, CLOUDFLARE_AUTH_TOKEN_ENV, TYPESAFE_API_KEY_ENV, +}; pub use advisory::Advisory; pub use answer::{ diff --git a/src/provider/registry.rs b/src/provider/registry.rs index f335b8c1..8cb9ca5c 100644 --- a/src/provider/registry.rs +++ b/src/provider/registry.rs @@ -158,7 +158,7 @@ mod tests { #[cfg(feature = "decision")] #[test] fn decision_key_variables_match_the_presets() { - use crate::decision::systemone::{ + use crate::decision::{ CLOUDFLARE_API_TOKEN_ENV, CLOUDFLARE_AUTH_TOKEN_ENV, TYPESAFE_API_KEY_ENV, }; assert_eq!( @@ -282,9 +282,12 @@ pub fn resolve_api_key(provider: &str) -> Option { ); return Some(token); } - let access = set("AWS_ACCESS_KEY_ID")?; - let secret = set("AWS_SECRET_ACCESS_KEY")?; - Some(match set("AWS_SESSION_TOKEN") { + let [_, access, secret, token] = BEDROCK_KEY_VARIABLES else { + unreachable!("BEDROCK_KEY_VARIABLES lists four names") + }; + let access = set(access)?; + let secret = set(secret)?; + Some(match set(token) { Some(token) => format!("{}:{}:{}", access, secret, token), None => format!("{}:{}", access, secret), })