diff --git a/CHANGELOG.md b/CHANGELOG.md index 75bd8e45..5c335fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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/). +## yoagent-rutis Unreleased + +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. + +- **pi adapter: per-extension grants a host can ask about.** Asked for by yobuild (its M3b plugin grants). + - `exec` / `execFor[file]` take `"gate"`: each `pi.exec` call goes to the host's `host_approval.judge([command, ...args], cwd?)` (`options.cwd` resolved against the adapter's `cwd` — and run there, so what runs is what was judged; left out without one, and the call then runs in the adapter's `cwd`). `{free: true}` runs it; otherwise `host_approval.ask(question, details | null, file)` with the judge's question and details, and only `"allow"` runs it. Any other answer, a host error, a cancel or no service is the usual refusal (code 126, reason in `stderr`, warned once per extension, command and kind of refusal; a cancel only at debug). What runs is exactly what was judged: the command, arguments and directory are copied when the call is made (an extension changing its array while you are asked changes nothing), and an `options.cwd` that is not a path string is refused unjudged. `judge` is bounded at 30 s; the question is the host's to time out, and a cancel does not withdraw it (an answer after the cancel is only logged). + - `projectTrustedFor: {file: boolean}` over `projectTrusted`: what `ctx.isProjectTrusted()` tells that extension's handlers and tools. + - `wakeFor: {file: boolean}` (default none): that extension's `pi.sendUserMessage(content)`, and `pi.sendMessage` with `triggerTurn` or `deliverAs`, become `host_session.wake(text, file)` (text blocks joined by line breaks, other blocks named; one at a time in the order sent, each after the previous answered or timed out; the message is still recorded). pi's calls return nothing, so a refusal is a `warn` line (once per extension and kind — no service, timeout, host error — repeats at debug), not an error the extension sees; a wake with no answer in 30 s is reported as such (it may still arrive) and later wakes go on. As in pi, a wake from an extension's factory (while extensions load) throws, and so does one once the adapter starts unloading (`session_shutdown` handlers included). + - Keys of every per-file setting resolve like `extensions`; one naming no loaded extension, or two spellings of one file, refuses the load. `ask` and `wake` name the extension's resolved file last (`judge` gets the argv and cwd only). + - The services reach the adapter through two new rows, `plugins/pi/host-approval.ts` and `plugins/pi/host-session.ts`, which re-provide `host_approval` / `host_session` as `pi-host-approval` / `pi-host-session` (as `host-ui.ts` does `ui`): the adapter looks them up per call, so rows without these grants load without them. + - Fixed-answer log lines name the asking extension: `[pi] fixed answer (): → `, once per extension, question and answer. + ## 0.25.11 (2026-10-20) A fix; no API change. diff --git a/integrations/yoagent-rutis/CLAUDE.md b/integrations/yoagent-rutis/CLAUDE.md index af72b474..1a39adc4 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: gated}`, not a Proxy: a Proxy's target leaks the real `exec` through `getOwnPropertyDescriptor`) whose `exec` is gated by config `exec` (`false` default / `true` / exact command list — the command only, not args/cwd, so `git`/`sh`/`node` allow anything; refused → resolves `{code: 126, stdout: '', stderr: reason}`, `warn` once per extension+command; 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) overrides `exec` per extension (`gateExec(grantFor)`); 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(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 question and answer (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) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. 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), `/.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 5c716985..033d9aa6 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -549,9 +549,10 @@ onto one handler, following pi 1.1.0's own runner and agent loop: |---|---| | `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false, in the `setActiveTools` allowlist if one was set; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them, and `execute` runs only with the arguments the policies left — a call another handler rewrote afterwards is not run. A throw or `isError` is an error result; text and image blocks pass through (pi's shape is yoagent's) | | `ctx.executeTool` | another pi tool, the way pi runs a nested call: prepared, validated, judged by `tool_call` (with `parentToolCallId`), its result through `tool_result`; never rejects (an unknown tool, a block or a throw is `isError`). yoagent's own tools are not reachable from it; `ctx.tools` lists the callable ones. The host narrows it with `nestedCalls` (`own-extension`, `off`) | -| `pi.exec` | **closed by default**: a command runs only when the host's `exec` setting allows it (`true`, or a list of commands matched exactly; per extension with `execFor` — `"git"` allows `git`, not `/usr/bin/git` or `./git`). Only the command is checked: its arguments and `cwd` are the extension's, so allowing a command that can run others (`git`, `sh`, `node`, `npx`) allows those too. A refused call resolves as a failed command (code 126, empty `stdout`, the reason in `stderr`; never a throw, as pi's own `exec` resolves a failed command), so an extension that ignores the exit code reads nothing; it is a `warn` line in the host's log naming the extension file, once per extension and command. This gates pi's API only: an extension may start processes itself. Every load (whatever `exec` is) imports each extension through a small wrapper module the adapter writes to a per-user directory under the temp directory (`yoagent-pi-wrappers-`, kept and reused; refused unless it is a real directory owned by that user that no one else can write — on Windows, only that it is a directory) | +| `pi.exec` | **closed by default**: a command runs only when the host's `exec` setting allows it (`true`, or a list of commands matched exactly; per extension with `execFor` — `"git"` allows `git`, not `/usr/bin/git` or `./git`), or, with `"gate"`, when the host approves the call: `host_approval.judge([command, ...args], cwd?)` (`options.cwd` resolved against the adapter's `cwd`, which is also where it then runs; left out when the extension passed none, and the call then runs in the adapter's `cwd`) answering `{free: true}` runs it; otherwise `host_approval.ask(question, details \| null, file)` — the judge's question and details, the extension's file — and only `"allow"` runs it (any other answer, a host error, a cancel, or no service: refused). What runs is what was judged: command, arguments and directory are copied when the call is made, and an `options.cwd` that is not a path string is refused. A cancel does not withdraw the host's question. The host's `host_approval` reaches the adapter through a `plugins/pi/host-approval.ts` row (it re-provides the service as `pi-host-approval`, as `host-ui.ts` does `ui`). Otherwise only the command is checked: its arguments and `cwd` are the extension's, so allowing a command that can run others (`git`, `sh`, `node`, `npx`) allows those too. A refused call resolves as a failed command (code 126, empty `stdout`, the reason in `stderr`; never a throw, as pi's own `exec` resolves a failed command), so an extension that ignores the exit code reads nothing; it is a `warn` line in the host's log naming the extension file, once per extension, command and kind of refusal (a cancel only at debug). This gates pi's API only: an extension may start processes itself. Every load (whatever `exec` is) imports each extension through a small wrapper module the adapter writes to a per-user directory under the temp directory (`yoagent-pi-wrappers-`, kept and reused; refused unless it is a real directory owned by that user that no one else can write — on Windows, only that it is a directory) | | `ctx.model`, `ctx.modelRegistry` | the host's: `model` (`{provider, id, name?, contextWindow?, maxTokens?, reasoning?, input?}`; default the model id of the run's latest request under provider `yoagent` (an observer event carries its run's, `agent_end` included) — before a run's first request (`input`) or outside a run (`session_start`), any run's latest request; `undefined` before the first request — in pi's `Model` shape (`api` `yoagent`, no costs, no credentials), and a **read-only** registry over `models` (default `[model]`, so empty without `model`): `getAll`, `getAvailable`, `find`, `getProviderDisplayName`, `getRegisteredProviderIds`, the `*OfType` lookups (every model is a chat model), `refresh` (does nothing); `hasConfiguredAuth` / `isUsingOAuth` false; it cannot be written to; every other method — credentials, model calls, registering providers — throws "not available in yoagent" (logged once). Both logged as fixed answers | -| `ctx.isProjectTrusted()`, `ctx.getContextUsage()`, print-mode `ctx.ui` dialogs | fixed answers: `projectTrusted` (default `false`; pi's own default is `true`), `undefined`, and no user to ask (`confirm` false, the others nothing). Each is an `info` line in the host's log the first time it is asked (once per question and answer in a runtime), tagged `[pi] fixed answer: → `, so a plugin that does nothing can be traced to what it was told | +| `ctx.isProjectTrusted()`, `ctx.getContextUsage()`, print-mode `ctx.ui` dialogs | fixed answers: `projectTrusted` (default `false`; pi's own default is `true`; per extension file with `projectTrustedFor`, for its handlers and tools), `undefined`, and no user to ask (`confirm` false, the others nothing). Each is an `info` line in the host's log the first time an extension asks it (once per extension, question and answer in a runtime), tagged `[pi] fixed answer (): → `, so a plugin that does nothing can be traced to what it was told | +| `pi.sendUserMessage`, `pi.sendMessage` with `triggerTurn` or `deliverAs` | for an extension granted `wakeFor`: `host_session.wake(text, file)` — text blocks joined by line breaks, other blocks named (`[image block]`), the extension's file last — through a `plugins/pi/host-session.ts` row (re-provided as `pi-host-session`); wakes are sent one at a time in the order sent, each after the previous answered or went 30 s without an answer. pi's calls return nothing, so a refusal (a host error, no answer in 30 s, or no service) cannot reach the extension: a `warn` line, once per extension and kind. As in pi, a wake from an extension's factory throws (runtime actions are not live while extensions load), and so does one once the adapter starts unloading. A `sendMessage` is still recorded (below). Without the grant, `sendUserMessage` throws "not available in yoagent" and `sendMessage` is recorded only | | `pi.setActiveTools` | an allowlist over the tools pi knows — yoagent's built-ins under pi's names and the extensions' tools: those outside it are not offered and their calls are denied. Tools pi does not know (MCP tools, sub-agents, the host's own) are not affected, as in pi. As in pi, a tool named in it is activated even when `defaultActive` is false (not when hidden), and a tool registered later that pi would activate joins it. `getActiveTools` / `getAllTools` answer from the same view | | `on("tool_call")` | `before_tool` for every call. yoagent's built-ins are judged under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob` / `ignoreCase` (yoagent's search is case-insensitive unless asked, pi's grep the reverse), `list_files`→`find`; config `toolNames` adds more), with a relative `path` resolved against `cwd` first so the tool acts where the policy looked (yoagent's built-ins only: another tool's `path` may be a repository's). `{ block }` denies (`terminate: true` also stops the run at its next model request — stricter than pi, which ends only a batch whose results all set it); in-place changes to `event.input` rewrite the arguments, and one the yoagent tool cannot take (a second edit, a `timeout` on `bash`) denies the call; a throwing handler blocks, as in pi | | `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps its text and image blocks). A throwing handler **withholds the result** — unlike pi, which skips it: a failed redaction must not let the raw output through | @@ -590,8 +591,8 @@ adapter's in-memory session — readable through `ctx.sessionManager`, a displayed message also logged — so a tool that records or shows its result this way (pi video tools do, after the paid work) does not fail; a message never starts a turn or reaches the model (yoagent's history is written only -by its host). The session lives as long as the adapter: in a long-running -host it grows until the plugin reloads. Other runtime actions (`pi.sendUserMessage`, `pi.setModel`, +by its host), unless the extension is granted `wakeFor` (above). The session lives as long as the adapter: in a long-running +host it grows until the plugin reloads. Other runtime actions (`pi.sendUserMessage` without `wakeFor`, `pi.setModel`, ...) throw "not available in yoagent". There is no UI: `ctx.hasUI` is false and `ctx.ui` behaves as in pi's print mode (`confirm` answers false), so a policy that would ask the user denies. Commands, dialogs and session @@ -615,10 +616,13 @@ Config: `extensions` (files, or directories with an `index.ts` / `index.js`, loaded in order; relative paths resolve against `cwd`), `cwd` (the project the extensions see; default the runtime's), `name`, `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`, `exec` (default -`false`; see `pi.exec` above), `execFor` (per extension file, as in -`extensions` — resolved against `cwd` — its own grant over `exec`; a key -naming no loaded extension refuses the load), `projectTrusted` (default -`false`), `model` / `models` (see `ctx.model` above), `nestedCalls` +`false`; `"gate"` asks the host; see `pi.exec` above), `execFor` (per +extension file, as in `extensions` — resolved against `cwd` — its own grant +over `exec`; a key naming no loaded extension refuses the load, as for the +other per-file settings), `projectTrusted` (default `false`) and +`projectTrustedFor` (per extension file), `wakeFor` (per extension file, +default none; see `pi.sendUserMessage` above), `model` / `models` (see +`ctx.model` above), `nestedCalls` (`"any"` by default, as pi; `"own-extension"`: a pi tool's `ctx.executeTool` reaches only tools whose winning registration (the first in the list) is its own extension's; `"off"`; a refused call is an error result to the @@ -703,7 +707,7 @@ tool wins, with a warning) rather than refusing (4). 6. **Keep plugin state local, don't fail work.** A plugin that records its own state (a session entry, a result card) gets an adapter-local store rather than an error, so a tool does not fail after its work is done; - what would inject input or start a turn still throws. + what would inject input or start a turn still throws, unless the host grants it (`wakeFor`). 7. **No UI means "no".** With no one to ask, a dialog answers as a headless run does — a confirmation is declined — so a policy that would ask denies. diff --git a/integrations/yoagent-rutis/plugins/pi/host-approval.ts b/integrations/yoagent-rutis/plugins/pi/host-approval.ts new file mode 100644 index 00000000..e2cc5a81 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/host-approval.ts @@ -0,0 +1,19 @@ +// Load next to the pi extensions adapter, in the same Node runtime, when the +// host provides a `host_approval` service and grants an extension `exec: +// "gate"` (or `execFor[file] = "gate"`): each `pi.exec` call is then judged +// by the host (`judge(argv, cwd?)`, `ask(question, details | null, file)`). +// +// rutis shows a host service only to plugins that inject it, and starts a +// plugin only once everything it injects exists, so the adapter cannot +// declare it (hosts without one would never start it): this plugin injects +// it and re-provides it as `pi-host-approval`, which the adapter looks up +// when a gated call is made. As with `host-ui.ts`, load it in one runtime only. + +import { definePlugin } from '@arcships/rutis' + +export default definePlugin({ + inject: ['host_approval'], + apply(ctx) { + ctx.effect(ctx.provide('pi-host-approval', ctx.use('host_approval'))) + }, +}) diff --git a/integrations/yoagent-rutis/plugins/pi/host-session.ts b/integrations/yoagent-rutis/plugins/pi/host-session.ts new file mode 100644 index 00000000..8c7af3ee --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/host-session.ts @@ -0,0 +1,19 @@ +// Load next to the pi extensions adapter, in the same Node runtime, when the +// host provides a `host_session` service and grants extensions `wakeFor`: their +// `pi.sendUserMessage`, and `pi.sendMessage` with `triggerTurn` or `deliverAs`, +// then reach the host as `wake(text, file)`. +// +// rutis shows a host service only to plugins that inject it, and starts a +// plugin only once everything it injects exists, so the adapter cannot +// declare it (hosts without one would never start it): this plugin injects +// it and re-provides it as `pi-host-session`, which the adapter looks up when +// an extension wakes the session. As with `host-ui.ts`, load it in one runtime only. + +import { definePlugin } from '@arcships/rutis' + +export default definePlugin({ + inject: ['host_session'], + apply(ctx) { + ctx.effect(ctx.provide('pi-host-session', ctx.use('host_session'))) + }, +}) diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index 5f44d499..0d866c83 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -139,16 +139,23 @@ // go to the host's logs (`yoagent.log`, else stderr). `pi.appendEntry` and // `pi.sendMessage` are recorded in the adapter's in-memory session (readable // through `ctx.sessionManager`; a displayed message is also logged; a message -// never starts a turn); other runtime actions (`pi.sendUserMessage`, -// `pi.setModel`, ...) throw "not available in yoagent". `pi.exec` runs only -// what the host's `exec` setting allows (default nothing; a refusal is a -// failed command, code 126, empty stdout): each factory gets a `pi` whose -// `exec` is gated, so extensions are imported through pi's loader by a -// generated wrapper and run with its `loadExtensionFromFactory` (`loadGated`; -// every load, whatever `exec` is). Fixed answers — `ctx.isProjectTrusted()` -// (the host's `projectTrusted`, default false), `ctx.model`, -// `ctx.modelRegistry`, `ctx.getContextUsage()`, print-mode dialogs — are -// logged once per question and answer as `[pi] fixed answer: …`. `ctx.hasUI` is false and `ctx.ui` behaves as in +// never starts a turn, unless the host grants the extension `wakeFor`); other +// runtime actions (`pi.sendUserMessage` without that grant, `pi.setModel`, +// ...) throw "not available in yoagent". `pi.exec` runs only what the host's +// `exec` / `execFor` setting allows (default nothing; `"gate"` asks the host's +// `host_approval` per call; a refusal is a failed command, code 126, empty +// stdout): each factory gets its own `pi`, with `exec` gated and, with +// `wakeFor`, `sendUserMessage` / `sendMessage` waking the host's session +// (`host_session.wake(text, file)`), so extensions are imported through pi's +// loader by a generated wrapper and run with its `loadExtensionFromFactory` +// (`loadGated`; every load, whatever the grants are). Host services reach the +// adapter re-provided by `host-approval.ts` / `host-session.ts`, the way +// `host-ui.ts` brings `ui`; `ask` and `wake` name the extension's file last (`judge` gets the +// argv and cwd only). +// Fixed answers — `ctx.isProjectTrusted()` (`projectTrustedFor`, else the +// host's `projectTrusted`, default false), `ctx.model`, `ctx.modelRegistry`, +// `ctx.getContextUsage()`, print-mode dialogs — are logged once per +// extension, question and answer as `[pi] fixed answer (): …`. `ctx.hasUI` is false and `ctx.ui` behaves as in // pi's print mode: `confirm` answers false, `select` and `input` nothing, so // a policy that would ask the user denies instead. When the host provides a // `ui` service (yoagent-frontend, experimental) and loads `host-ui.ts` next @@ -189,18 +196,41 @@ export interface Config { * (found on `PATH`), not `/usr/bin/git` or `./git`. Only the command is checked; its arguments * and `options.cwd` are the extension's, so allowing a command that can run others (`git`, `sh`, * `node`, `npx`) allows those too. A refused call resolves as a failed command (code 126, empty - * `stdout`, the reason in `stderr`) and is reported at warn, once per extension and command. + * `stdout`, the reason in `stderr`) and is reported at warn, once per extension, command and + * kind of refusal (a cancel only at debug). * This gates pi's API only: an extension is code the host runs and may start processes itself. + * + * `"gate"` asks the host about each call: `host_approval.judge([command, ...args], cwd?)` + * (`cwd` is `options.cwd` resolved against the adapter's, left out when the extension passed + * none — the call then runs in the adapter's `cwd`) answering `{free: true}` runs it; otherwise + * `host_approval.ask(question, details | null, file)` with the judge's `question` and `details` + * and the extension's file, and only `"allow"` runs it. Any other answer, a host error, a cancel + * or no `host_approval` service (load `host-approval.ts`) is a refusal, as above; so is an + * `options.cwd` that is not a path string, unjudged. What runs is what was judged (copied when + * the call is made). `judge` is bounded at 30 s; `ask` is the host's to time out. */ - exec?: boolean | string[] + exec?: Grant /** * Grants for single extensions, over `exec`: an extension file (as in `extensions`, resolved - * against `cwd`) → its own `true` / `false` / command list. A key naming no loaded extension - * refuses the load (a typo would otherwise grant nothing, silently). + * against `cwd`) → its own `true` / `false` / command list / `"gate"`. A key naming no loaded + * extension refuses the load (a typo would otherwise grant nothing, silently); so for the other + * `…For` settings. */ - execFor?: Record + execFor?: Record /** What `ctx.isProjectTrusted()` answers (default `false`; pi's own default is `true`). */ projectTrusted?: boolean + /** `projectTrusted` for single extensions, keyed like `execFor`: what a handler or tool of that file is told. */ + projectTrustedFor?: Record + /** + * Extensions whose `pi.sendUserMessage(content)`, and `pi.sendMessage` with `triggerTurn` or + * `deliverAs`, wake the host's session: `host_session.wake(text, file)` (the content as text; + * load `host-session.ts`). Keyed like `execFor`; default none. pi's calls return nothing, so a + * refusal (a host error, no answer in 30 s, or no service) cannot reach the extension: it is + * reported at warn, once per extension and kind (repeats at debug). A wake while extensions + * load, or once the adapter starts unloading, throws. Without the grant, `sendUserMessage` + * throws "not available in yoagent" and `sendMessage` is recorded only. + */ + wakeFor?: Record /** * What `ctx.model` is: the model the agent runs. Default: the run's model id (from yoagent's * request), under provider `yoagent`; before the first request, `undefined`. @@ -214,6 +244,32 @@ export interface Config { models?: ModelSpec[] } +/** What `pi.exec` may run: nothing, anything, these commands, or what the host approves per call. */ +export type Grant = boolean | string[] | 'gate' + +/** The host's `host_approval` service (yobuild's), re-provided as `pi-host-approval` by `host-approval.ts`. */ +interface HostApproval { + judge(argv: string[], cwd?: string): Promise<{ free?: boolean; judged?: boolean; question?: string; details?: string } | undefined> + ask(question: string, details: string | null, file: string): Promise +} + +/** The host's `host_session` service, re-provided as `pi-host-session` by `host-session.ts`. */ +interface HostSession { + wake(text: string, file: string): Promise +} + +/** How long the adapter waits for `host_approval.judge` and `host_session.wake` (asking the user is not bounded here). */ +const HOST_CALL_MS = 30_000 + +/** `promise`, or a rejection naming `what` after `ms`. */ +function bounded(what: string, promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + const late = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${what} took longer than ${ms / 1000} s`)), ms) + }) + return Promise.race([promise, late]).finally(() => clearTimeout(timer)) +} + /** A model as the host describes it to pi extensions. */ export interface ModelSpec { provider: string @@ -463,28 +519,28 @@ let report = (level: 'error' | 'warn' | 'info' | 'debug', message: string) => { /** * A question pi's API asks that this adapter answers with a fixed value: said - * once per question and answer (per runtime, shared by every adapter in it), at info, under one tag hosts can search for, so a plugin - * that does nothing can be traced to the answer it got. + * once per extension, question and answer (per runtime, shared by every adapter in it), at info, under one tag hosts can search for, so a plugin + * that does nothing can be traced to the answer it got. `file` is the asking extension's, when one asked. */ const answered = new Set() -function fixedAnswer(question: string, answer: T, shown: string = JSON.stringify(answer) ?? 'undefined'): T { +function fixedAnswer(question: string, answer: T, shown: string = JSON.stringify(answer) ?? 'undefined', file?: string): T { // Keyed by the answer too: two adapters in one runtime may answer differently. - const key = `${question}\0${shown}` + const key = `${file ?? ''}\0${question}\0${shown}` if (!answered.has(key)) { answered.add(key) - report('info', `[pi] fixed answer: ${question} → ${shown}`) + report('info', `[pi] fixed answer${file ? ` (${file})` : ''}: ${question} → ${shown}`) } return answer } -/** pi's print-mode UI (its `noOpUIContext`): nothing to show, no one to ask. */ -const NO_UI = new Proxy( +/** pi's print-mode UI (its `noOpUIContext`): nothing to show, no one to ask. `file`: the extension asking. */ +const noUi = (file?: string) => new Proxy( { - select: async () => fixedAnswer('ctx.ui.select() (no user to ask)', undefined), - confirm: async () => fixedAnswer('ctx.ui.confirm() (no user to ask)', false), - input: async () => fixedAnswer('ctx.ui.input() (no user to ask)', undefined), - editor: async () => fixedAnswer('ctx.ui.editor() (no user to ask)', undefined), - custom: async () => fixedAnswer('ctx.ui.custom() (no user to ask)', undefined), + select: async () => fixedAnswer('ctx.ui.select() (no user to ask)', undefined, undefined, file), + confirm: async () => fixedAnswer('ctx.ui.confirm() (no user to ask)', false, undefined, file), + input: async () => fixedAnswer('ctx.ui.input() (no user to ask)', undefined, undefined, file), + editor: async () => fixedAnswer('ctx.ui.editor() (no user to ask)', undefined, undefined, file), + custom: async () => fixedAnswer('ctx.ui.custom() (no user to ask)', undefined, undefined, file), notify: (message: string, level?: string) => report(level === 'error' ? 'error' : level === 'warning' ? 'warn' : 'info', `[pi ${level ?? 'info'}] ${message}`), onTerminalInput: () => () => {}, @@ -501,6 +557,7 @@ const NO_UI = new Proxy( get: (target, key) => (key in target ? target[key as string] : key === 'then' ? undefined : () => undefined), }, ) +const NO_UI = noUi() /** The host's `ui` service (yoagent-frontend), when it provides one. */ interface HostUi { @@ -513,7 +570,7 @@ interface HostUi { * frontend answers first; a failure, no frontend or no answer in time gives * print mode's answer. */ -const hostUi = (ui: HostUi, contextSignal?: AbortSignal) => { +const hostUi = (ui: HostUi, contextSignal?: AbortSignal, file?: string) => { const ask = async (request: Record, opts?: { timeout?: number; signal?: AbortSignal }) => { // When the hook is cancelled (the run stopped, the policy timed out) the // question is withdrawn: frontends close its dialog. rutis's JavaScript runtime (as of 0.9) cannot @@ -546,7 +603,7 @@ const hostUi = (ui: HostUi, contextSignal?: AbortSignal) => { const text = (value: unknown) => (typeof value === 'string' ? value : undefined) return new Proxy( { - ...(NO_UI as Record), + ...(noUi(file) as Record), select: async (title: unknown, options: unknown[], opts?: { timeout?: number; signal?: AbortSignal }) => text(await ask({ kind: 'select', title: String(title), options: (options ?? []).map(String) }, opts)), confirm: async (title: unknown, body?: unknown, opts?: { timeout?: number; signal?: AbortSignal }) => @@ -614,6 +671,7 @@ interface PiApi { [member: string]: unknown } type ExecResult = { stdout: string; stderr: string; code: number; killed: boolean } +type ExecOptions = { signal?: AbortSignal; timeout?: number; cwd?: string } type Factory = (pi: PiApi) => unknown type Loaded = { extensions: PiExtension[] @@ -761,30 +819,181 @@ async function writeWrapper(dir: string, resolved: string): Promise { return wrapper } +/** A host service, or why it could not be had (not provided, or the lookup failed). */ +type Lookup = { service: T } | { missing: string } + +/** What the per-extension gate needs from the adapter, looked up when a call is made. */ +interface Gates { + /** The grant for the extension at `path`: its own in `execFor`, else the row-wide `exec`. */ + execFor(path: string): Grant + /** Whether the extension at `path` may wake the host's session (`wakeFor`). */ + wakes(path: string): boolean + /** The adapter's `cwd`, which a relative `options.cwd` resolves against, and where a call without one runs. */ + cwd: string + /** Whether pi's runtime actions are live: not while extensions load, nor once unloading starts (`session_shutdown` included). */ + active(): boolean + approval(): Lookup + session(): Lookup +} + +/** What `orCancel` resolves when the call is cancelled first (never a host's answer). */ +const CANCELLED = Symbol('cancelled') + /** - * `pi.exec` as the host's grant for the extension at `path` allows it (`grantFor`: its own in - * `execFor`, else the row-wide `exec`); everything else on pi's API as it is. + * Each extension's own `pi`: `exec` as its grant allows (`Gates.execFor`), and, with `wakeFor`, + * `sendUserMessage` / a turn-starting `sendMessage` waking the host's session; everything else on + * pi's API as it is. */ -function gateExec(grantFor: (path: string) => boolean | string[]) { - const refusedOnce = new Set() +function gatePi(gates: Gates) { + const warned = new Set() + /** A warning once per key (extension, command and kind of trouble): a new kind is said again. */ + const once = (key: string, text: string) => { + if (warned.has(key)) return report('debug', text) + warned.add(key) + report('warn', text) + } + /** + * Wakes go to the host one at a time, in the order extensions sent them, each after the previous + * one answered or went 30 s without an answer (so a late one may land after the next). + */ + let wakes: Promise = Promise.resolve() return (pi: PiApi, path: string): PiApi => { - const exec = grantFor(path) - if (exec === true) return pi - const allowed = (command: string) => Array.isArray(exec) && exec.includes(command) - const gated = (command: string, args: string[], options?: unknown): Promise => { - if (allowed(command)) return pi.exec(command, args, options) - const why = `pi.exec("${command}") is not allowed by the host (the adapter's \`exec\` / \`execFor\` setting)` - const key = `${path}\0${command}` - if (!refusedOnce.has(key)) { - refusedOnce.add(key) - report('warn', `[pi] ${path}: refused ${why}`) + const grant = gates.execFor(path) + /** A refused call (126); said once per extension, command and `kind`, a cancel only at debug. */ + const refuse = (command: string, kind: string, why: string): ExecResult => { + if (kind === 'cancelled') report('debug', `[pi] ${path}: refused ${why}`) + else once(`exec\0${path}\0${command}\0${kind}`, `[pi] ${path}: refused ${why}`) + return { stdout: '', stderr: why, code: 126, killed: false } + } + /** + * Asks the host (`"gate"`): runs the call only when the judge frees it or the user allows it. + * What runs is exactly what was judged: the command, arguments and directory are copied on + * entry (the extension's own array and options are not read again), and the directory is + * resolved against the adapter's `cwd`. + */ + const approved = async (command: unknown, args: unknown, options?: ExecOptions): Promise => { + // `Array.from`, not `map`: a hole would be skipped by `map` and spread back as `undefined`. + const argv = [String(command), ...(Array.isArray(args) ? Array.from(args, (a) => String(a)) : [])] + const what = `pi.exec("${argv[0]}")` + const given = options?.cwd + if (given !== undefined && typeof given !== 'string') { + return refuse(argv[0], 'cwd', `${what}: options.cwd must be a path string to be judged, not ${typeof given}`) + } + const cwd = given === undefined ? undefined : resolve(gates.cwd, given) + // Each option read once (a getter could answer differently the second time). + const givenSignal = options?.signal + const givenTimeout = options?.timeout + const signal = givenSignal instanceof AbortSignal ? givenSignal : undefined + const timeout = typeof givenTimeout === 'number' ? givenTimeout : undefined + const run = () => pi.exec(argv[0], argv.slice(1), { signal, timeout, cwd: cwd ?? gates.cwd }) + const approval = gates.approval() + if ('missing' in approval) { + return refuse(argv[0], 'no-service', `${what} needs the host's approval (\`exec\` "gate"): ${approval.missing}`) + } + const host = approval.service + const cancelled = () => refuse(argv[0], 'cancelled', `${what} was cancelled while the host was asked`) + if (signal?.aborted) return cancelled() + /** `promise`, or CANCELLED at the call's own cancel; the listener is removed either way. */ + const orCancel = async (promise: Promise): Promise => { + if (!signal) return promise + // Aborted between two host calls: no listener would fire. + if (signal.aborted) return CANCELLED + let onAbort: (() => void) | undefined + try { + return await Promise.race([promise, new Promise((done) => { + onAbort = () => done(CANCELLED) + signal.addEventListener('abort', onAbort, { once: true }) + })]) + } finally { + if (onAbort) signal.removeEventListener('abort', onAbort) + } + } + let verdict: Awaited> | typeof CANCELLED + try { + verdict = await orCancel( + bounded('host_approval.judge', Promise.resolve().then(() => (cwd === undefined ? host.judge(argv) : host.judge(argv, cwd))), HOST_CALL_MS), + ) + } catch (error) { + return refuse(argv[0], 'judge-failed', `${what}: the host could not judge it: ${message(error)}`) } - return Promise.resolve({ stdout: '', stderr: why, code: 126, killed: false }) + if (verdict === CANCELLED) return cancelled() + if (verdict?.free === true) return run() + if (typeof verdict?.free !== 'boolean') { + report('debug', `[pi] ${path}: host_approval.judge answered ${JSON.stringify(verdict)} (no boolean \`free\`); asking`) + } + const question = typeof verdict?.question === 'string' && verdict.question ? verdict.question : `Run ${argv.join(' ')}?` + const details = typeof verdict?.details === 'string' ? verdict.details : null + // Not bounded here: the host's own question times out (an "unanswered"). A cancel does not + // withdraw it: the question stays until then, and a later answer is only logged. + const asked = Promise.resolve().then(() => host.ask(question, details, path)) + let answer: unknown + try { + answer = await orCancel(asked) + } catch (error) { + return refuse(argv[0], 'ask-failed', `${what}: the host's approval failed: ${message(error)}`) + } + if (answer === CANCELLED) { + asked.then( + (late) => report('debug', `[pi] ${path}: ${what} was answered ${JSON.stringify(late)} after it was cancelled; not run`), + () => {}, + ) + return cancelled() + } + if (answer === 'allow') return run() + const said = typeof answer === 'string' ? answer : JSON.stringify(answer) ?? 'no answer' + // Keyed by the answer for the ones a host gives (a free-form one would key every refusal anew). + const kind = answer === 'block' || answer === 'unanswered' ? `declined:${answer}` : 'declined' + return refuse(argv[0], kind, `${what} was not approved by the host (${said})`) + } + const gated = (command: string, args: string[], options?: ExecOptions): Promise => { + if (grant === 'gate') return approved(command, args, options) + if (Array.isArray(grant) && grant.includes(command)) return pi.exec(command, args, options) + return Promise.resolve( + refuse(String(command), 'not-allowed', `pi.exec("${String(command)}") is not allowed by the host (the adapter's \`exec\` / \`execFor\` setting)`), + ) } // A copy, not a Proxy: a Proxy's target still hands the real `exec` out // through `Object.getOwnPropertyDescriptor`. pi's API methods are // closures that never use `this`, and `events` is shared by reference. - return { ...pi, exec: gated } + const own: PiApi = { ...pi } + if (grant !== true) own.exec = gated + if (gates.wakes(path)) { + /** + * The content as text to the host's session. pi's calls return nothing, so a refusal is + * reported: once per extension and reason, repeats at debug. + */ + const wake = (content: unknown) => { + // As pi's own runtime refuses actions while extensions load (and after unloading). + if (!gates.active()) { + throw new Error('Extension runtime not initialized. Action methods cannot be called during extension loading.') + } + const said = typeof content === 'string' ? content : Array.isArray(content) ? text(content as Block[]) : JSON.stringify(content) + /** Said once per extension and kind (`no-service`, `timeout`, `error`): a host's error text may vary. */ + const failed = (kind: string, why: string) => once(`wake\0${path}\0${kind}`, `[pi] ${path}: wake refused: ${why}`) + const session = gates.session() + if ('missing' in session) return failed('no-service', session.missing) + const host = session.service + wakes = wakes.then(() => + bounded('host_session.wake', Promise.resolve().then(() => host.wake(said ?? '', path)), HOST_CALL_MS).catch((error) => + /took longer than/.test(message(error)) + ? failed('timeout', `no answer within ${HOST_CALL_MS / 1000} s (it may still be delivered; later wakes go on)`) + : failed('error', message(error)), + ), + ) + } + own.sendUserMessage = (content: unknown) => wake(content) + own.sendMessage = (msg: { content?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: string }) => { + const turn = !!(options?.triggerTurn || options?.deliverAs) + // Checked before recording, so a call that throws records nothing. + if (turn && !gates.active()) { + throw new Error('Extension runtime not initialized. Action methods cannot be called during extension loading.') + } + // Recorded as before (pi's own runtime stores it); the turn it asks for is the host's to start. + ;(pi.sendMessage as (m: unknown, o?: unknown) => void)(msg) + if (turn) wake(msg?.content ?? '') + } + } + return own } } @@ -879,12 +1088,14 @@ export default definePlugin({ allowUnmapped: { type: 'array', items: { type: 'string' } }, strict: { type: 'boolean' }, nestedCalls: { type: 'string', enum: ['any', 'own-extension', 'off'] }, - exec: { anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }] }, + exec: { anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }, { const: 'gate' }] }, execFor: { type: 'object', - additionalProperties: { anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }] }, + additionalProperties: { anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }, { const: 'gate' }] }, }, projectTrusted: { type: 'boolean' }, + projectTrustedFor: { type: 'object', additionalProperties: { type: 'boolean' } }, + wakeFor: { type: 'object', additionalProperties: { type: 'boolean' } }, model: { type: 'object' }, models: { type: 'array', items: { type: 'object' } }, }, @@ -911,30 +1122,45 @@ export default definePlugin({ const { SessionManager } = await import('@earendil-works/pi-coding-agent') const { validateToolArguments } = await import('@earendil-works/pi-ai') - const isGrant = (g: unknown): g is boolean | string[] => - typeof g === 'boolean' || (Array.isArray(g) && g.every((c) => typeof c === 'string')) + const isGrant = (g: unknown): g is Grant => + typeof g === 'boolean' || g === 'gate' || (Array.isArray(g) && g.every((c) => typeof c === 'string')) const exec = config.exec ?? false - if (!isGrant(exec)) throw new Error(`exec must be true, false or a list of commands, not ${JSON.stringify(exec)}`) - const shown = (g: boolean | string[]) => (g === true ? 'anything' : g === false || g.length === 0 ? 'nothing' : g.join(', ')) + if (!isGrant(exec)) throw new Error(`exec must be true, false, "gate" or a list of commands, not ${JSON.stringify(exec)}`) + const shown = (g: Grant) => + g === true ? 'anything' : g === 'gate' ? 'what the host approves' : g === false || g.length === 0 ? 'nothing' : g.join(', ') report('debug', `[pi] pi.exec allowed: ${shown(exec)}`) - // Per-extension grants, keyed by the resolved file as `loadGated` resolves `extensions`. + if (typeof config.projectTrusted !== 'undefined' && typeof config.projectTrusted !== 'boolean') { + throw new Error(`projectTrusted must be true or false, not ${JSON.stringify(config.projectTrusted)}`) + } + // Per-extension settings, keyed by the resolved file as `loadGated` resolves `extensions`. const { resolvePath } = await piModule<{ resolvePath(input: string, base: string, options?: unknown): string }>( './utils/paths.js', ) const resolveExt = (path: string) => resolvePath(path, cwd, { normalizeUnicodeSpaces: true }) - const execFor = new Map() - for (const [path, grant] of Object.entries(config.execFor ?? {})) { - if (!isGrant(grant)) { - throw new Error(`execFor["${path}"] must be true, false or a list of commands, not ${JSON.stringify(grant)}`) - } - execFor.set(resolveExt(path), grant) - report('debug', `[pi] pi.exec allowed for ${path}: ${shown(grant)}`) - } const listed = new Set(config.extensions.map(resolveExt)) - const unknown = [...execFor.keys()].filter((path) => !listed.has(path)) - if (unknown.length > 0) { - throw new Error(`execFor names extensions that are not loaded: ${unknown.join(', ')}`) + /** A `…For` setting as a map from resolved files; a bad value or a key naming no loaded extension refuses the load. */ + const keyed = (setting: string, entries: Record | undefined, valid: (v: unknown) => v is T, expected: string) => { + const map = new Map() + if (entries !== undefined && (typeof entries !== 'object' || entries === null || Array.isArray(entries))) { + throw new Error(`${setting} must map extension files to settings, not ${JSON.stringify(entries)}`) + } + for (const [path, value] of Object.entries(entries ?? {})) { + if (!valid(value)) throw new Error(`${setting}["${path}"] must be ${expected}, not ${JSON.stringify(value)}`) + const file = resolveExt(path) + if (map.has(file)) throw new Error(`${setting} names ${file} twice (as "${path}" and another spelling)`) + map.set(file, value) + } + const unknown = [...map.keys()].filter((path) => !listed.has(path)) + if (unknown.length > 0) throw new Error(`${setting} names extensions that are not loaded: ${unknown.join(', ')}`) + return map } + const isBool = (v: unknown): v is boolean => typeof v === 'boolean' + const execFor = keyed('execFor', config.execFor, isGrant, 'true, false, "gate" or a list of commands') + for (const [path, grant] of execFor) report('debug', `[pi] pi.exec allowed for ${path}: ${shown(grant)}`) + const projectTrustedFor = keyed('projectTrustedFor', config.projectTrustedFor, isBool, 'true or false') + const wakeFor = keyed('wakeFor', config.wakeFor, isBool, 'true or false') + /** What `ctx.isProjectTrusted()` answers for a handler or tool of the extension at `file`. */ + const trusted = (file?: string) => (file !== undefined ? projectTrustedFor.get(file) : undefined) ?? config.projectTrusted ?? false // Models extensions see (`ctx.model`, `ctx.modelRegistry`). for (const [what, spec] of [['model', config.model] as const, ...(config.models ?? []).map((m, i) => [`models[${i}]`, m] as const)]) { if (spec !== undefined && !isModelSpec(spec)) { @@ -947,7 +1173,31 @@ export default definePlugin({ const runModels = new Map() /** The latest request's model of any run: for contexts outside a run (`session_start`, ...). */ let latestModel: string | undefined - const loaded = await loadGated(config.extensions, cwd, gateExec((path) => execFor.get(path) ?? exec)) + // Host services for the grants: looked up when a call is made, never injected (as `ui`). + /** A re-provided host service; why not, when it cannot be had (the error's own text). */ + const lookup = (hostName: string, plugin: string): Lookup => { + const missing = `the host provides no ${hostName} service (load ${plugin})` + try { + const service = ctx.use(`pi-${hostName.replace('_', '-')}`) + return service ? { service } : { missing } + } catch (error) { + return { missing: `${missing}: ${message(error)}` } + } + } + /** pi's runtime actions are live: after load (set below, with the actions) until unloading. */ + let actionsLive = false + const loaded = await loadGated( + config.extensions, + cwd, + gatePi({ + execFor: (path) => execFor.get(path) ?? exec, + wakes: (path) => wakeFor.get(path) === true, + cwd, + active: () => actionsLive, + approval: () => lookup('host_approval', 'host-approval.ts'), + session: () => lookup('host_session', 'host-session.ts'), + }), + ) if (loaded.errors.length > 0) { throw new Error(`pi extensions failed to load: ${loaded.errors.map((e) => `${e.path}: ${e.error}`).join('; ')}`) } @@ -987,29 +1237,39 @@ export default definePlugin({ } /** The model id `ctx.model` names for a run: its latest request's, else any run's latest. */ const modelOf = (runId?: string) => (runId !== undefined ? runModels.get(runId) : undefined) ?? latestModel - const context = (signal?: AbortSignal, runId?: string, systemPrompt = '', modelId = modelOf(runId)) => { + /** + * A pi `ctx` for a handler or tool of the extension at `file` (its fixed answers name it; its + * `isProjectTrusted()` is its own), in run `runId`. + */ + const context = ( + file: string | undefined, + signal?: AbortSignal, + runId?: string, + systemPrompt = '', + modelId = modelOf(runId), + ) => { const hostUiService = lookupUi() return { - ui: hostUiService ? hostUi(hostUiService, signal) : NO_UI, + ui: hostUiService ? hostUi(hostUiService, signal, file) : noUi(file), mode: hostUiService ? 'rpc' : 'print', hasUI: hostUiService !== undefined, cwd, sessionManager, get modelRegistry() { - return fixedAnswer('ctx.modelRegistry', registry, `a read-only list of ${registry.getAll().length} model(s)`) + return fixedAnswer('ctx.modelRegistry', registry, `a read-only list of ${registry.getAll().length} model(s)`, file) }, get model() { const model = hostModel ?? (modelId ? piModel({ provider: 'yoagent', id: modelId }) : undefined) - return fixedAnswer('ctx.model', model, model ? `${model.provider}/${model.id}` : 'undefined') + return fixedAnswer('ctx.model', model, model ? `${model.provider}/${model.id}` : 'undefined', file) }, scopedModels: [], signal, isIdle: () => signal === undefined, // pi's own default is true; this adapter loads only the files it is given, never a project's // `.pi`, and a host opts in (`projectTrusted`) to what extensions do in a trusted project. - isProjectTrusted: () => fixedAnswer('ctx.isProjectTrusted()', config.projectTrusted ?? false), + isProjectTrusted: () => fixedAnswer('ctx.isProjectTrusted()', trusted(file), undefined, file), hasPendingMessages: () => false, - getContextUsage: () => fixedAnswer('ctx.getContextUsage()', undefined), + getContextUsage: () => fixedAnswer('ctx.getContextUsage()', undefined, undefined, file), getSystemPrompt: () => systemPrompt, abort: () => { throw new Error('ctx.abort() is not available in yoagent') @@ -1095,7 +1355,7 @@ export default definePlugin({ const event = { type: 'tool_call', toolCallId: id, parentToolCallId: parentId, toolName: name, input } let result: { block?: boolean; reason?: string; terminate?: boolean } | undefined try { - result = (await fn(event, context(signal, runId))) as typeof result + result = (await fn(event, context(ext.path, signal, runId))) as typeof result } catch (error) { return failed(`pi extension ${short(ext.path)} failed: ${message(error)}`, input) } @@ -1126,7 +1386,7 @@ export default definePlugin({ } for (const { ext, fn } of handlers('tool_result')) { try { - const edit = (await fn(event, context(signal, runId))) as { content?: Block[]; details?: unknown; isError?: boolean } | undefined + const edit = (await fn(event, context(ext.path, signal, runId))) as { content?: Block[]; details?: unknown; isError?: boolean } | undefined if (edit?.content !== undefined) event.content = edit.content if (edit?.details !== undefined) event.details = edit.details if (edit?.isError !== undefined) event.isError = edit.isError @@ -1148,7 +1408,7 @@ export default definePlugin({ let nested = 0 // Added onto the context, not spread: a spread reads every getter, which would log // fixed answers (`ctx.model`, ...) no extension asked for. - return Object.defineProperties(context(signal, runId), Object.getOwnPropertyDescriptors({ + return Object.defineProperties(context(caller, signal, runId), Object.getOwnPropertyDescriptors({ get tools() { // The run's frozen copies (a tool cannot swap another's `execute` through them), only // those `nestedCalls` lets this tool call. @@ -1282,6 +1542,7 @@ export default definePlugin({ } } runtime.setModel = () => Promise.reject(new Error('pi.setModel() is not available in yoagent')) + actionsLive = true // What does not map: reported once each; deciding events refuse. const reported = new Set() @@ -1388,7 +1649,7 @@ export default definePlugin({ const before = canon(copy) let result: { message?: unknown } | undefined try { - result = (await fn({ type: 'message_end', message: copy }, context(signal, runId))) as typeof result + result = (await fn({ type: 'message_end', message: copy }, context(ext.path, signal, runId))) as typeof result } catch (error) { // Cut short: not judged, so not seen; the next check runs it again. if (signal?.aborted) return unfinished('message_end') @@ -1413,7 +1674,7 @@ export default definePlugin({ const before = canon(copy) let result: { messages?: unknown } | undefined try { - result = (await fn({ type: 'context', messages: copy }, context(signal, runId))) as typeof result + result = (await fn({ type: 'context', messages: copy }, context(ext.path, signal, runId))) as typeof result } catch (error) { if (signal?.aborted) return unfinished('context') report('warn', `[pi] ${short(ext.path)} context: ${message(error)}`) @@ -1470,7 +1731,7 @@ export default definePlugin({ }) const event = { type: 'before_agent_start', prompt, systemPrompt, systemPromptOptions: options } try { - const result = (await fn(event, context(signal, runId, systemPrompt))) as + const result = (await fn(event, context(ext.path, signal, runId, systemPrompt))) as | { systemPrompt?: unknown; message?: unknown } | undefined if (result?.message !== undefined) { @@ -1495,7 +1756,7 @@ export default definePlugin({ const started: string[] = [] for (const { ext, fn } of handlers('session_start')) { try { - await fn({ type: 'session_start', reason: 'startup' }, context()) + await fn({ type: 'session_start', reason: 'startup' }, context(ext.path)) } catch (error) { started.push(`${short(ext.path)}: ${message(error)}`) } @@ -1505,10 +1766,11 @@ export default definePlugin({ let drainObservers: () => Promise = async () => {} // Registered first, so a refusal below still shuts the extensions down. ctx.effect(async () => { + actionsLive = false await drainObservers() for (const { ext, fn } of handlers('session_shutdown')) { try { - await fn({ type: 'session_shutdown', reason: 'quit' }, context()) + await fn({ type: 'session_shutdown', reason: 'quit' }, context(ext.path)) } catch (error) { report('warn', `[pi] ${short(ext.path)} session_shutdown: ${message(error)}`) } @@ -1674,7 +1936,7 @@ export default definePlugin({ for (const { ext, fn } of handlers(pi.type as string)) { let timer: ReturnType | undefined try { - const call = Promise.resolve().then(() => fn(structuredClone(pi), context(undefined, runId, '', model))) + const call = Promise.resolve().then(() => fn(structuredClone(pi), context(ext.path, undefined, runId, '', model))) const budget = new Promise<'late'>((resolve) => { timer = setTimeout(() => resolve('late'), OBSERVER_HANDLER_BUDGET_MS) }) @@ -1869,7 +2131,7 @@ export default definePlugin({ const event = { type: 'tool_call', toolCallId: call.call_id, toolName, input } let result: { block?: boolean; reason?: string; terminate?: boolean } | undefined try { - result = (await fn(event, context(call.signal, call.run_id))) as typeof result + result = (await fn(event, context(ext.path, call.signal, call.run_id))) as typeof result } catch (error) { // As in pi: a failing tool_call handler blocks the call. return { deny: `pi extension ${short(ext.path)} failed: ${message(error)}` } @@ -1923,7 +2185,7 @@ export default definePlugin({ const failed: string[] = [] for (const { ext, fn } of editors) { try { - const result = (await fn(event, context(call.signal, call.run_id))) as + const result = (await fn(event, context(ext.path, call.signal, call.run_id))) as | { content?: Block[]; details?: unknown; isError?: boolean } | undefined if (result?.content !== undefined) { @@ -1961,7 +2223,7 @@ export default definePlugin({ const event = { type: 'input', text: input.text, source: 'interactive' } let result: { action?: string } | undefined try { - result = (await fn(event, context(input.signal, input.run_id))) as typeof result + result = (await fn(event, context(ext.path, input.signal, input.run_id))) as typeof result } catch (error) { // Unlike pi (which goes on): an input check that failed rejects. return { reject: `pi extension ${short(ext.path)} failed: ${message(error)}` } diff --git a/integrations/yoagent-rutis/src/adapters.rs b/integrations/yoagent-rutis/src/adapters.rs index acd09dc5..e5a4ec9b 100644 --- a/integrations/yoagent-rutis/src/adapters.rs +++ b/integrations/yoagent-rutis/src/adapters.rs @@ -24,7 +24,9 @@ pub struct Adapters { /// `plugins/`: everything below. pub root: PathBuf, /// `plugins/pi`: the pi extensions adapter (`pi-extensions-adapter.ts`), - /// `host-ui.ts`, `resolve-package.mjs`, and their `package.json` and lock. + /// the host-service re-providers `host-ui.ts`, `host-approval.ts` and + /// `host-session.ts`, `resolve-package.mjs`, and their `package.json` and + /// lock. pub pi: PathBuf, /// `plugins/dsh`: the DSH tools adapter (`dsh-tools-adapter.ts`), the /// host services a headless DSH host loads (`host-services.json`), the diff --git a/integrations/yoagent-rutis/tests/pi_log_test.rs b/integrations/yoagent-rutis/tests/pi_log_test.rs index 9ad24bbd..1325ba14 100644 --- a/integrations/yoagent-rutis/tests/pi_log_test.rs +++ b/integrations/yoagent-rutis/tests/pi_log_test.rs @@ -81,11 +81,19 @@ async fn a_plugin_warning_reaches_the_hosts_logs() { let loader = plugin.handle(); root.plugin(plugin).await.unwrap(); root.plugin(RuntimeRowsPlugin::new(resolver)); - // The fixture registers a command: the adapter reports it as not available. + // The fixture registers a command: the adapter reports it as not available. A second + // extension asks a fixed question at load, which the log line names it for. + let dir = tempfile::tempdir().unwrap(); + let asks = dir.path().join("asks.ts"); + std::fs::write( + &asks, + "export default function (pi) { pi.on('session_start', (_e, ctx) => { ctx.isProjectTrusted() }) }", + ) + .unwrap(); let patches: Vec = serde_json::from_value(json!([{ "insert": [{ "id": "pi", "name": pi_dir().join("pi-extensions-adapter.ts"), - "config": { "extensions": [pi_dir().join("fixture-extension.ts")] }, + "config": { "extensions": [pi_dir().join("fixture-extension.ts"), &asks] }, }] }])) .unwrap(); let report = loader @@ -120,6 +128,17 @@ async fn a_plugin_warning_reaches_the_hosts_logs() { ) }); assert!(found.contains("not available in yoagent"), "{found}"); + let named = format!( + "fixed answer ({}): ctx.isProjectTrusted() → false", + asks.display() + ); + tokio::time::timeout(Duration::from_secs(10), async { + while !logs.0.lock().unwrap().iter().any(|l| l.contains(&named)) { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("no `{named}` in {:?}", logs.0.lock().unwrap())); let _ = bridge; root.shutdown().await.unwrap(); } diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index 84810bb8..08039152 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -19,7 +19,9 @@ use std::time::Duration; use common::*; use rutis::{Ctx, FiberState}; use rutis_bridge::runtime::LocalRuntime; -use rutis_bridge::session::{host_key, HostDispatch, Reply, Value as BridgeValue}; +use rutis_bridge::session::{ + host_key, Error as BridgeError, HostDispatch, Reply, Value as BridgeValue, +}; use rutis_loader::{ Chain, EntryStatus, Layer, Loader, LoaderOptions, LoaderPlugin, Patch, RuntimeResolver, RuntimeRowsPlugin, ServiceCatalog, @@ -103,13 +105,18 @@ async fn host(runtime: &Path) -> Host { /// [`host`], with a host `ui` service when given. async fn host_with_ui(runtime: &Path, ui: Option>) -> Host { + host_with(runtime, ui.map(|ui| ("ui", ui)).into_iter().collect()).await +} + +/// [`host`], with these host services (shared by name). +async fn host_with(runtime: &Path, services: Vec<(&str, Arc)>) -> Host { let root = Ctx::root().unwrap(); let bridge = RutisBridge::install(&root).unwrap(); let mut catalog = ServiceCatalog::new(); catalog.register_shared("yoagent"); - if let Some(ui) = ui { - catalog.register_shared("ui"); - root.provide_as::(host_key("ui"), ui) + for (name, service) in services { + catalog.register_shared(name); + root.provide_as::(host_key(name), service) .unwrap(); } let node = LocalRuntime::node(runtime, pi_dir().join("package.json")); @@ -2489,3 +2496,602 @@ export default function (pi) {{ ); host.root.shutdown().await.unwrap(); } + +/// yobuild's `host_approval`, scripted by the call's first argument: `free` +/// is free, `yes` / `no` are asked (and answered "allow" / "block"), `boom` +/// cannot be judged. Every call is recorded with its arguments. +#[derive(Default)] +struct FakeApproval(Mutex>); + +impl HostDispatch for FakeApproval { + fn invoke(&self, method: &str, args: BridgeValue) -> Reply { + let args: Vec = args + .list()? + .into_iter() + .map(|a| a.json().unwrap_or(Value::Null)) + .collect(); + self.0 + .lock() + .unwrap() + .push((method.into(), Value::Array(args.clone()))); + match method { + "judge" => match args[0][1].as_str() { + _ if args[0][0] == "pwd" => Ok(BridgeValue::Data(json!({ "free": true }))), + Some("free") => Ok(BridgeValue::Data(json!({ "free": true, "judged": true }))), + Some("boom") => Err(BridgeError::Value("the gate broke".into())), + _ => Ok(BridgeValue::Data( + json!({ "free": false, "judged": true, "question": "Run echo?", "details": "argv" }), + )), + }, + "ask" => Ok(BridgeValue::Data(json!( + if args[0] == "Run echo?" && args[1] == "argv" { + // Which call is asked about is not in `ask`'s arguments: the last judged one. + let judged = self + .0 + .lock() + .unwrap() + .iter() + .rev() + .find(|(m, _)| m == "judge") + .cloned(); + match judged + .and_then(|(_, a)| a[0][1].as_str().map(str::to_owned)) + .as_deref() + { + Some("yes") => json!("allow"), + Some("unans") => json!("unanswered"), + // Truthy, but not "allow". + Some("truthy") => json!(true), + Some("askboom") => { + return Err(BridgeError::Value("the question broke".into())) + } + _ => json!("block"), + } + } else { + json!("unanswered") + } + ))), + _ => Ok(BridgeValue::Undefined), + } + } + + fn methods(&self) -> Option { + Some(json!({ "judge": "async", "ask": "async" })) + } +} + +/// `exec: "gate"` per extension: each `pi.exec` is judged by the host, a call +/// it frees runs, one it asks about runs only on "allow", and a block, a +/// judge that fails or a host without `host_approval` (no `host-approval.ts` +/// row) refuse it (126). `judge` gets the argv and `options.cwd` resolved; +/// `ask` gets the judge's question and details and the extension's file. An +/// extension without the grant is refused without asking. +#[tokio::test(flavor = "multi_thread")] +async fn exec_gate_asks_the_host() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("sub")).unwrap(); + let source = |tool: &str| { + format!( + "export default function (pi) {{ pi.registerTool({{ name: '{tool}', label: '{tool}', description: 'x', \ + parameters: {{ type: 'object', properties: {{ arg: {{ type: 'string' }}, cwd: {{ type: 'string' }} }} }}, \ + async execute(_id, p) {{ const r = await pi.exec('echo', [p.arg], p.cwd ? {{ cwd: p.cwd }} : undefined); \ + return {{ content: [{{ type: 'text', text: `code=${{r.code}} out=${{r.stdout.trim()}} err=${{r.stderr}}` }}], details: {{}} }} }} }}) }}" + ) + }; + let gated = extension(dir.path(), "gated.ts", &source("gated_exec")); + let other = extension(dir.path(), "other.ts", &source("other_exec")); + let row = || { + json!({ + "extensions": [&gated, &other], + "cwd": dir.path(), + "execFor": { gated.to_str().unwrap(): "gate" }, + }) + }; + for with_service in [true, false] { + let approval = Arc::new(FakeApproval::default()); + let host = host_with( + &runtime, + vec![("host_approval", approval.clone() as Arc)], + ) + .await; + let mut rows = adapter_row(row()); + if with_service { + rows.as_array_mut() + .unwrap() + .push(json!({ "id": "host-approval", "name": pi_dir().join("host-approval.ts") })); + } + host.load(rows).await; + let (agent, _) = agent(vec![ + calls(&[ + ("gated_exec", json!({ "arg": "free", "cwd": "sub" })), + ("gated_exec", json!({ "arg": "yes" })), + ("gated_exec", json!({ "arg": "no" })), + ("gated_exec", json!({ "arg": "boom" })), + ("gated_exec", json!({ "arg": "unans" })), + ("gated_exec", json!({ "arg": "truthy" })), + ("gated_exec", json!({ "arg": "askboom" })), + ("other_exec", json!({ "arg": "free" })), + ]), + text("done"), + ]); + let mut agent = agent + .with_tool_execution(yoagent::ToolExecutionStrategy::Sequential) + .with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + let out: Vec<&str> = results.iter().map(|(_, o, _)| o.as_str()).collect(); + if with_service { + assert!(out[0].starts_with("code=0 out=free"), "{out:?}"); + assert!(out[1].starts_with("code=0 out=yes"), "{out:?}"); + assert!( + out[2].starts_with("code=126") + && out[2].contains("not approved by the host (block)"), + "{out:?}" + ); + assert!( + out[3].starts_with("code=126") + && out[3].contains("could not judge it") + && out[3].contains("the gate broke"), + "{out:?}" + ); + // Only "allow" runs: an unanswered question, a truthy answer and a failed one refuse. + for (o, want) in [ + (out[4], "(unanswered)"), + (out[5], "(true)"), + (out[6], "the question broke"), + ] { + assert!(o.starts_with("code=126") && o.contains(want), "{out:?}"); + } + assert!( + out[7].starts_with("code=126") && out[7].contains("is not allowed by the host"), + "{out:?}" + ); + let seen = approval.0.lock().unwrap().clone(); + let sub = dir.path().join("sub"); + let resolved_sub = std::fs::canonicalize(&sub).unwrap(); + assert_eq!(seen[0].0, "judge"); + assert_eq!(seen[0].1[0], json!(["echo", "free"])); + let cwd = seen[0].1[1].as_str().unwrap(); + assert!( + Path::new(cwd) == sub || Path::new(cwd) == resolved_sub, + "{cwd}" + ); + // A call without `options.cwd` sends none. + assert_eq!(seen[1], ("judge".into(), json!([["echo", "yes"]]))); + assert_eq!(seen[2].0, "ask"); + assert_eq!(seen[2].1[0], "Run echo?"); + assert_eq!(seen[2].1[1], "argv"); + assert!( + seen[2].1[2].as_str().unwrap().ends_with("gated.ts"), + "the file is the last argument: {seen:?}" + ); + // `free` and `boom` are never asked; `other` never judged. + let methods: Vec<&str> = seen.iter().map(|(m, _)| m.as_str()).collect(); + assert_eq!( + methods, + [ + "judge", "judge", "ask", "judge", "ask", "judge", "judge", "ask", "judge", + "ask", "judge", "ask" + ] + ); + } else { + for o in &out[..7] { + assert!( + o.starts_with("code=126") && o.contains("no host_approval service"), + "{out:?}" + ); + } + assert!(approval.0.lock().unwrap().is_empty()); + } + host.root.shutdown().await.unwrap(); + } + + // A bad grant refuses the load. + let host = host(&runtime).await; + let failures = host + .try_load(adapter_row( + json!({ "extensions": [&gated], "exec": "ask" }), + )) + .await; + assert!(failures.contains("exec must be true, false"), "{failures}"); + host.root.shutdown().await.unwrap(); +} + +/// yobuild's `host_session`: wakes recorded with their arguments; a text +/// containing `refuse` is refused. +#[derive(Default)] +struct WakeLog(Mutex>); + +impl HostDispatch for WakeLog { + fn invoke(&self, method: &str, args: BridgeValue) -> Reply { + let args: Vec = args + .list()? + .into_iter() + .map(|a| a.json().unwrap_or(Value::Null)) + .collect(); + if method == "wake" { + self.0.lock().unwrap().push(Value::Array(args.clone())); + if args[0].as_str().is_some_and(|t| t.contains("refuse")) { + return Err(BridgeError::Value("not now".into())); + } + } + Ok(BridgeValue::Undefined) + } + + fn methods(&self) -> Option { + Some(json!({ "wake": "async", "info": "async" })) + } +} + +/// `projectTrustedFor` and `wakeFor` per extension: a granted extension's +/// `pi.sendUserMessage` and turn-starting `pi.sendMessage` wake the host's +/// session (`wake(text, file)`, blocks as text; a refusal does not reach the +/// extension); its neighbour's `sendUserMessage` still throws, and each sees +/// its own `ctx.isProjectTrusted()`. Unknown keys and bad values refuse the +/// load. +#[tokio::test(flavor = "multi_thread")] +async fn trust_and_wake_per_extension() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let late = dir.path().join("late.log"); + let source = |tool: &str| { + format!( + "import {{ appendFileSync }} from 'node:fs'\nexport default function (pi) {{ pi.registerTool({{ name: '{tool}', label: '{tool}', description: 'x', \ + parameters: {{ type: 'object', properties: {{}} }}, \ + async execute(_id, _p, _s, _u, ctx) {{ let sent = 'sent'; \ + try {{ pi.sendUserMessage('hello\\nworld'); pi.sendUserMessage('please refuse'); \ + pi.sendMessage({{ customType: 'note', content: [{{ type: 'text', text: 'm1' }}, {{ type: 'text', text: 'm2' }}] }}, {{ triggerTurn: true }}); \ + pi.sendMessage({{ customType: 'note', content: 'quiet' }}); \ + pi.sendMessage({{ customType: 'note', content: 'd' }}, {{ deliverAs: 'steer' }}); \ + pi.sendUserMessage('end') }} catch (e) {{ sent = e.message }} \ + return {{ content: [{{ type: 'text', text: `${{ctx.isProjectTrusted()}}|${{sent}}` }}], details: {{}} }} }} }}); \ + pi.on('session_shutdown', () => {{ try {{ pi.sendUserMessage('late') }} catch (e) {{ \ + appendFileSync({late:?}, `{tool}: ${{e.message}}\n`) }} }}) }}" + ) + }; + let woken = extension(dir.path(), "woken.ts", &source("woken_tool")); + let quiet = extension(dir.path(), "quiet.ts", &source("quiet_tool")); + let session = Arc::new(WakeLog::default()); + let host = host_with( + &runtime, + vec![("host_session", session.clone() as Arc)], + ) + .await; + let mut rows = adapter_row(json!({ + "extensions": [&woken, &quiet], + "projectTrusted": false, + "projectTrustedFor": { woken.to_str().unwrap(): true }, + "wakeFor": { woken.to_str().unwrap(): true }, + })); + rows.as_array_mut() + .unwrap() + .push(json!({ "id": "host-session", "name": pi_dir().join("host-session.ts") })); + host.load(rows).await; + let (agent, _) = agent(vec![ + calls(&[("woken_tool", json!({})), ("quiet_tool", json!({}))]), + text("done"), + ]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + assert_eq!(results[0].1, "true|sent", "{results:?}"); + assert_eq!( + results[1].1, "false|pi.sendUserMessage() is not available in yoagent", + "{results:?}" + ); + let file = woken.to_str().unwrap(); + // Wakes are sent one at a time: once "end" is in, every earlier one has been decided. + let wakes = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let seen = session.0.lock().unwrap().clone(); + if seen.iter().any(|w| w[0] == "end") { + return seen; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("the wakes"); + let texts: Vec<&str> = wakes.iter().map(|w| w[0].as_str().unwrap()).collect(); + // The quiet `sendMessage` (no turn) wakes nothing; `deliverAs` alone does. + assert_eq!( + texts, + ["hello\nworld", "please refuse", "m1\nm2", "d", "end"], + "{wakes:?}" + ); + for w in &wakes { + assert!( + w[1].as_str().unwrap().ends_with("woken.ts"), + "{w:?} vs {file}" + ); + } + host.root.shutdown().await.unwrap(); + // Unloading: a wake from `session_shutdown` throws, as pi's own runtime once it is gone; + // the neighbour throws "not available" as ever. + let said = wait_file(&late, |t| t.lines().count() >= 2).await; + assert!( + said.contains("woken_tool: Extension runtime not initialized"), + "{said}" + ); + assert!( + said.contains("quiet_tool: pi.sendUserMessage() is not available"), + "{said}" + ); + assert!(!session.0.lock().unwrap().iter().any(|w| w[0] == "late")); + + for (bad, want) in [ + ( + json!({ "wakeFor": { dir.path().join("typo.ts").to_str().unwrap(): true } }), + "not loaded", + ), + ( + json!({ "projectTrustedFor": { dir.path().join("typo.ts").to_str().unwrap(): true } }), + "not loaded", + ), + ( + json!({ "projectTrustedFor": { woken.to_str().unwrap(): "yes" } }), + "must be true or false", + ), + // One file under two spellings. + ( + json!({ "wakeFor": { + woken.to_str().unwrap(): true, + dir.path().join(".").join("woken.ts").to_str().unwrap(): false, + } }), + "twice", + ), + (json!({ "wakeFor": true }), "wakeFor"), + ] { + let host = crate::host(&runtime).await; + let mut row = json!({ "extensions": [&woken] }); + row.as_object_mut() + .unwrap() + .extend(bad.as_object().unwrap().clone()); + let failures = host.try_load(adapter_row(row)).await; + assert!(failures.contains(want), "{bad}: {failures}"); + host.root.shutdown().await.unwrap(); + } +} + +/// The gate runs exactly what it judged: an extension that changes its +/// argument array while the host is asked still runs the judged command, a +/// `cwd` that is not a path string is refused unjudged, and a granted wake +/// from an extension's factory (while extensions load) throws as pi's own +/// runtime does, reaching no host. +#[tokio::test(flavor = "multi_thread")] +async fn the_gate_runs_what_it_judged() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("sub")).unwrap(); + let ext = extension( + dir.path(), + "tricky.ts", + r#" +let atLoad = 'no error' +export default function (pi) { + try { pi.sendUserMessage('from the factory') } catch (e) { atLoad = e.message } + const tool = (name, run) => pi.registerTool({ + name, label: name, description: 'x', parameters: { type: 'object', properties: {} }, + async execute() { return { content: [{ type: 'text', text: await run() }], details: {} } }, + }) + tool('swap_args', async () => { + const args = ['yes'] + const pending = pi.exec('echo', args) + args[0] = 'swapped' + const r = await pending + return `code=${r.code} out=${r.stdout.trim()}` + }) + tool('url_cwd', async () => { + const r = await pi.exec('echo', ['free'], { cwd: new URL('file:///') }) + return `code=${r.code} err=${r.stderr}` + }) + tool('at_load', async () => atLoad) + tool('where_sub', async () => (await pi.exec('pwd', [], { cwd: 'sub' })).stdout.trim()) + tool('where_default', async () => (await pi.exec('pwd', [])).stdout.trim()) +} +"#, + ); + let approval = Arc::new(FakeApproval::default()); + let session = Arc::new(WakeLog::default()); + let host = host_with( + &runtime, + vec![ + ("host_approval", approval.clone() as Arc), + ("host_session", session.clone() as Arc), + ], + ) + .await; + let mut rows = adapter_row(json!({ + "extensions": [&ext], + "cwd": dir.path(), + "exec": "gate", + "wakeFor": { ext.to_str().unwrap(): true }, + })); + rows.as_array_mut().unwrap().extend([ + json!({ "id": "host-approval", "name": pi_dir().join("host-approval.ts") }), + json!({ "id": "host-session", "name": pi_dir().join("host-session.ts") }), + ]); + host.load(rows).await; + let (agent, _) = agent(vec![ + calls(&[ + ("swap_args", json!({})), + ("url_cwd", json!({})), + ("at_load", json!({})), + ("where_sub", json!({})), + ("where_default", json!({})), + ]), + text("done"), + ]); + let mut agent = agent + .with_tool_execution(yoagent::ToolExecutionStrategy::Sequential) + .with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + assert_eq!(results[0].1, "code=0 out=yes", "{results:?}"); + assert!( + results[1].1.starts_with("code=126") && results[1].1.contains("must be a path string"), + "{results:?}" + ); + assert!( + results[2].1.contains("Extension runtime not initialized"), + "{results:?}" + ); + // A gated call runs where it was judged: `options.cwd` resolved against the adapter's + // `cwd`, and without one the adapter's `cwd` itself. + let canonical = |p: &str| std::fs::canonicalize(p).unwrap(); + assert_eq!( + canonical(&results[3].1), + canonical(dir.path().join("sub").to_str().unwrap()) + ); + assert_eq!( + canonical(&results[4].1), + canonical(dir.path().to_str().unwrap()) + ); + // `swap_args` was judged (and asked about) as `echo yes`; `url_cwd` never reached the host. + let seen = approval.0.lock().unwrap().clone(); + assert_eq!(seen[0], ("judge".into(), json!([["echo", "yes"]]))); + assert_eq!(seen[1].0, "ask"); + assert_eq!(seen.len(), 4, "{seen:?}"); + assert!(session.0.lock().unwrap().is_empty()); + host.root.shutdown().await.unwrap(); +} + +/// The host's `host_approval` with an `ask` that answers only when the test +/// says so (and "allow" then): for a cancel while the user is asked. +struct LateApproval(Mutex>>); + +impl HostDispatch for LateApproval { + fn invoke(&self, method: &str, _args: BridgeValue) -> Reply { + match method { + "judge" => Ok(BridgeValue::Data(json!({ "free": false, "question": "q" }))), + "ask" => { + let answer = self.0.lock().unwrap().take(); + Ok(BridgeValue::future(async move { + if let Some(answer) = answer { + let _ = answer.await; + } + Ok(BridgeValue::Data(json!("allow"))) + })) + } + _ => Ok(BridgeValue::Undefined), + } + } + + fn methods(&self) -> Option { + Some(json!({ "judge": "async", "ask": "async" })) + } +} + +/// A gated call fails closed on its own cancel: aborted before the host is +/// asked, it is not judged; aborted while the user is asked, it is refused at +/// once and a later "allow" runs nothing. +#[tokio::test(flavor = "multi_thread")] +async fn a_cancelled_gated_call_never_runs() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join("ran"); + let ext = extension( + dir.path(), + "cancel.ts", + &format!( + r#" +export default function (pi) {{ + const tool = (name, run) => pi.registerTool({{ + name, label: name, description: 'x', parameters: {{ type: 'object', properties: {{}} }}, + async execute() {{ const r = await run(); return {{ content: [{{ type: 'text', text: `code=${{r.code}} err=${{r.stderr}}` }}], details: {{}} }} }}, + }}) + tool('pre_aborted', () => {{ const ac = new AbortController(); ac.abort(); return pi.exec('touch', [{marker:?}], {{ signal: ac.signal }}) }}) + tool('abort_in_ask', () => {{ + const ac = new AbortController() + setTimeout(() => ac.abort(), 300) + return pi.exec('touch', [{marker:?}], {{ signal: ac.signal }}) + }}) +}} +"# + ), + ); + let (answer, later) = tokio::sync::oneshot::channel(); + let approval = Arc::new(LateApproval(Mutex::new(Some(later)))); + let host = host_with( + &runtime, + vec![("host_approval", approval.clone() as Arc)], + ) + .await; + let mut rows = adapter_row(json!({ "extensions": [&ext], "exec": "gate" })); + rows.as_array_mut() + .unwrap() + .push(json!({ "id": "host-approval", "name": pi_dir().join("host-approval.ts") })); + host.load(rows).await; + let (agent, _) = agent(vec![ + calls(&[("pre_aborted", json!({})), ("abort_in_ask", json!({}))]), + text("done"), + ]); + let mut agent = agent + .with_tool_execution(yoagent::ToolExecutionStrategy::Sequential) + .with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + for (_, out, _) in &results { + assert!( + out.starts_with("code=126") && out.contains("cancelled while the host was asked"), + "{results:?}" + ); + } + // The user allows it now: nothing runs. + answer.send(()).unwrap(); + tokio::time::sleep(Duration::from_millis(500)).await; + assert!(!marker.exists(), "a cancelled call ran after a late allow"); + host.root.shutdown().await.unwrap(); +} + +/// `projectTrustedFor: false` over a row-wide `true` reaches the extension's +/// handlers too: its `tool_call` policy and its `session_start`. +#[tokio::test(flavor = "multi_thread")] +async fn untrusted_for_one_extension_reaches_its_handlers() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("trust.log"); + let wary = extension( + dir.path(), + "wary.ts", + &format!( + r#" +import {{ appendFileSync }} from 'node:fs' +export default function (pi) {{ + pi.on('session_start', (_e, ctx) => {{ appendFileSync({log:?}, `start=${{ctx.isProjectTrusted()}}\n`) }}) + pi.on('tool_call', (e, ctx) => e.toolName === 'probe' ? {{ block: true, reason: `trusted=${{ctx.isProjectTrusted()}}` }} : undefined) +}} +"# + ), + ); + let probe = extension( + dir.path(), + "probe.ts", + "export default function (pi) { pi.registerTool({ name: 'probe', label: 'p', description: 'x', \ + parameters: { type: 'object', properties: {} }, \ + async execute() { return { content: [{ type: 'text', text: 'ran' }], details: {} } } }) }", + ); + let host = host(&runtime).await; + host.load(adapter_row(json!({ + "extensions": [&wary, &probe], + "projectTrusted": true, + "projectTrustedFor": { wary.to_str().unwrap(): false }, + }))) + .await; + let (agent, _) = agent(vec![call("probe", json!({})), text("done")]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + assert!( + results[0].2 && results[0].1.contains("trusted=false"), + "{results:?}" + ); + assert_eq!(wait_file(&log, |t| !t.is_empty()).await, "start=false\n"); + host.root.shutdown().await.unwrap(); +}