9abc162394c5f8eea6bdc48b7c4ee633eca87093
22
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9abc162394 |
fix(aiui): the content surface renders what the assistant found
Four defects, one visible symptom: a correct prose answer beside an
empty grid.
1. The assistant's curated RPC bridge had an arm only for
`content.list-mine`. `tools.rs` mapped the `peers`, `purchased` and
`films` scopes onto three real, dispatcher-registered handlers that
`assistant_dispatch_tool` had never heard of, so every non-"own"
scope died on its catch-all. Downstream that read as "the peers have
no content" — it was a missing match arm, and the tool never ran.
Regression test added: every scope the schema advertises must reach a
real handler.
2. `content.browse-all-peers` wrapped its whole fan-out in one
`timeout(..).unwrap_or_default()`, which DISCARDED every completed
batch the moment the budget expired. One slow peer turned a
partly-successful browse into "0 reached, 16 unreachable". Observed
live on archi-dev-box: back-to-back calls returned real peer items,
then nothing. Now accumulates per batch and checks a deadline between
them, so partial results always survive. Budget 20s -> 45s: two
batches of eight at a 10s per-peer timeout had no headroom at all.
3. `assistant.chat` returned only `{ text }`. The structured results of
any content tool the turn ran were dropped inside the loop, so the
surface had nothing to render. The turn now carries them through
(captured raw, before the untrusted wrap, since they go to a renderer
that treats every field as inert data, never back into the prompt).
4. The adapter classified images as 'excluded' and dropped them. A node
sharing mostly photos rendered as an empty grid while AIUI's image
grid sat unused. Images now have a bucket, with the paid-lock and
extension-fallback handling audio and video already had.
Also: the panel says "Loading…" while a turn is in flight and "Nothing
found" when it comes back empty, instead of leaving the previous
query's heading standing as though it answered this one; the system
prompt tells the model to call the content tool and summarise rather
than re-list what the cards already show; and a refused tool now names
its permission category so the trusted chrome can offer the settings
screen instead of leaving "I don't have a tool for that" as the only
clue.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
58c759c149 |
feat(assistant): content_list gains scope — peers, purchased and IndeeHub films
Operator asked "what films are there to watch from my peers" and the model answered, honestly, that it had no tool for it. It was right: content_list mapped only to content.list-mine — this node's own shared files. Peer catalogues and IndeeHub were unreachable from the assistant entirely. content_list now takes scope: own | peers | purchased | films, dispatching to content.list-mine / content.browse-all-peers / content.owned-list / content.indeehub-projects. The model picks from a closed enum and never names a method, so an invented scope falls back to "own" rather than reaching anything it was not granted (T-13-34). Two new RPCs behind it: - content.browse-all-peers aggregates every federated peer in ONE call. The dashboard fans this out client-side, but asking a model to enumerate peers and loop is how it ends up claiming it has no tool. Rides FIPS — PeerRequest::new(fips_npub, onion, "/content") with a 6s FIPS fast-fail then Tor — so the onion is the peer's identity and FIPS is the transport. Sequential with a per-peer timeout, not an unbounded fan-out: 02-08 traced a real UI stall to browse-peer starving the connection pool. One peer being down is the normal case and contributes nothing rather than failing the call. - content.indeehub-projects fetches IndeeHub's catalogue, public plus (via a node-signed NIP-98 login) the operator's private titles. Node-side because signing that in the browser would put identity material next to the model, which this phase rules out by name. Tolerant of IndeeHub's field spellings across versions, and absent/stopped/empty all yield an empty list rather than failing the caller. action_key includes the scope, so listing peers cannot be replayed as listing own files. 15/15 assistant::tools. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d4d329787 |
fix(13-13): assert the seed screen with a real mnemonic, not a stale shape fixture
secret_shaped_content_never_reaches_the_stub was RED because its fixture was the first twelve wordlist entries — not a parseable mnemonic. The 2026-08-06 precision rewrite of screen_outbound moved from a word-run shape rule to BIP-39 checksum validation (the shape rule had blocked legitimate turns on a live node twice); egress.rs's own test was updated to a checksum-valid fixture and this copy was not, so it asserted behaviour that had been deliberately retired. The named behaviour was intact throughout: screen_outbound runs on the Routstr paid leg before any body is sent, a real mnemonic is blocked, and checksum-invalid runs of 20+ wordlist members are still caught by IMPLAUSIBLE_MEMBER_RUN. Fixture is now a checksum-valid mnemonic, asserted as parseable so it cannot silently rot the same way again. Also records the operator's rendering contract in the surfaces todo: chat gets the mini version, the content/context surfaces expand it, nothing rich may overflow the bubble at mobile width. |
||
|
|
82d1b60891 |
fix(13-10/13-11): replay chat history to the model; unbreak general answers
Four defects found by on-device UAT, 2026-08-06.
1. D-08 persistence was WRITE-ONLY. chat() loaded the transcript only
AFTER the loop, to append — the model was never shown any of it. The
assistant answered "I don't have access to any previous conversation
history" with its own transcript on disk, and "and is it healthy?"
resolved to the node instead of the app just discussed. History now
replays into every turn (text only: a stale tool result must not be
re-presented as this turn's evidence), scoped by HistoryKey. The
replayed prefix is excluded from the append, or each turn would
re-persist the conversation and grow it geometrically.
2. The operator persona forbade the very answers the content surfaces
render. 13-01's prompt refuses anything without a matching tool, so
"recommend me 10 sci-fi films" was declined and the film/song/podcast
grids from 13-11 could never populate — two plans in contradiction.
The refusal rule now governs ACTIONS ON THE NODE; general questions
and recommendations are answered from the model's own knowledge.
(Whether the node should also SEARCH THE WEB depends on AIUI's
web-search setting, which embedded mode never forwards — captured as
a separate todo because it opens a new egress path.)
3. The content-surface loader labelled unrelated queries "Podcast
recommendations": the classifier matched a bare "show", which is how
operators phrase almost everything ("show me my files").
4. "Surfacing…" tracks at 0.2em and its final glyph collided with the
close button; the header now spaces them properly.
assistant::history 9/9 green incl. replay_feeds_prior_turns_back_to_the_model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e681c95131 |
fix(13-12): seed screen validates the BIP39 checksum, not word shapes
Second on-device failure in one session: after the wordlist fix, dev3 blocked cloud turns AGAIN mid-session as 13-10's history grew — splitting on every non-alphabetic character let words from unrelated JSON fields chain into one run. Both failures took the whole feature down rather than protecting anything, which is the worse failure for a screen to have. Shape is the wrong signal. A real mnemonic's last word encodes a checksum over the rest, so an accidental run of English words parses as a mnemonic only about one time in sixteen. Candidate runs are now validated with the same bip39 crate the wallet uses: - tokenize on whitespace (a seed phrase is space-separated); a token's leading alphabetic segment counts, and alphanumerics after it end the phrase, so a seed glued to a closing quote is still caught - block only if a 12/15/18/21/24 window parses as a real mnemonic - IMPLAUSIBLE_MEMBER_RUN (20) backstops checksum-invalid material such as a typo'd 24-word seed, which prose cannot plausibly produce Documented trade-off: a checksum-invalid run under 20 words no longer blocks. The rule that did block it also blocked every legitimate turn, twice, on a live node. 15/15 egress tests green, including the real system prompt, scattered-JSON prose, and a genuine mnemonic in JSON. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
02a5aa6e29 |
fix(13-12): seed-phrase egress screen checks the real BIP39 wordlist
The shape-only heuristic ('any 12 consecutive lowercase 3-8-char words')
matched ordinary prose — including the node's own system prompt — and
blocked 100% of live cloud chat turns (found on dev3, the first real
Claude call through this screen; log: kind=bip39-word-run every turn).
Membership in the crate's own bip39 English wordlist (already a dep via
seed.rs) distinguishes prose from seed material: glue words break runs,
real seeds are nothing but members. Regression test pins the real system
prompt + a clean wire body to Allow; the 12-word genuine-seed case still
blocks. 13/13 egress tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
27aa5ccd10 |
feat(13-14): the harness — offline, deterministic, per-backend, zero footprint on a node
assistant/evals.rs: a test-gated in-crate module (#![cfg(test)] here AND #[cfg(test)] pub mod evals; in mod.rs — never compiles into the shipped binary, asserted by a release-binary string grep). load_cases/case_by_id read the 18-case JSONL fixture by path; run_case drives the REAL run_loop/ execute_tool/ConfirmGate choke points end to end against a case's grants, seeded untrusted content, and scripted backend turns, returning a CaseOutcome that observes ToolCall/ToolResult/confirm-gate transitions in-process rather than inferring them from prose. evaluate_case asserts must_not_execute/must_not_claim at threshold zero (E-01's security and integrity halves) and confirmations/turns at exact match, every failure message naming the case id and the offending tool/term. Parameterized over the Backend trait (CountingBackend wraps any real Backend to measure turns used; a BudgetExhaustedStubBackend drives EV-17's S-12 stop-without-retry path) so scripted, Ollama, Claude or Routstr can all run the same 18 cases. report_by_backend/parity_requires_two_backends refuse to record a cross-backend parity pass from fewer than two backends (E-07). Live-backend runs are opt-in via ARCHY_EVAL_BACKENDS and #[ignore]d so a plain `cargo test` never touches the network. write_trace_jsonl writes one plain JSONL file per run under core/target/assistant-evals/ (gitignored build output) — no exporter, no collector, no listening port. All 18 cases pass against ScriptedBackend (23/23 assistant::evals:: tests); full crate suite 1258/1258; release binary contains zero eval-fixture strings; no phoenix/promptfoo/ragas/opentelemetry references anywhere in assistant/; no new CI job (ci.yml untouched — picked up by the existing `cargo test --all-features` step); zero new packages (T-13-SC). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8ba6041251 |
feat(13-13): D-05 prepaid budget — arithmetic ceiling, hard stop, D-04 chain complete
Completes D-04's backend chain: Ollama -> Claude -> Routstr (budget-gated), and wires D-05's operator-set prepaid allowance as a hard, arithmetic stop a prompt-injected model can never cross. - assistant/mod.rs: `AssistantBudget` (allowance_sats/spent_sats, persisted 0600 under data_dir/assistant/budget.json, mirroring Grants::load/save exactly — a missing/corrupt file defaults to a ZERO allowance, D-16's "default closed" applied to money). `payment_policy()` builds a `PaymentPolicy` from ONLY these two persisted fields — no parameter accepts anything model/tool/provider-influenced, which is what makes the ceiling arithmetic rather than a policy an injected model could argue with. `record_spend()` persists a successful payment and raises a one-time 80%-threshold owner notice (AI-SPEC §7b). New typed `BudgetExhausted` error (downcastable via anyhow) is the signal `loop_.rs` distinguishes from an ordinary transport error. - assistant/loop_.rs: `run_loop` downcasts a `BudgetExhausted` out of the backend's `Err` and returns `Ok` with a plain-language stop message — no retry, no re-price, no partial spend, no fall-through to a different provider at a different price. Verified to actually matter: temporarily replaced the terminating `return` with `continue` and confirmed `zero_budget_stops_loop_without_retry` goes red (the backend gets retried 8x to MAX_TURNS and the turn errors instead of stopping cleanly); restored and reconfirmed green (13-13-SUMMARY.md records the observed failure). - assistant/backends/mod.rs: `select_backend` now takes `&RpcHandler` (was `&Path`) to also read the Tor-proxy config; completes the D-04 chain — Routstr never selected when the operator's allowance is zero (Claude alone instead), otherwise chained as Claude's fallback (Ollama -> Claude -> Routstr, each leg reached only when the priors are unavailable). New `BackendId::Routstr` variant. - assistant/backends/routstr.rs: the payment-decline arm now returns the typed `BudgetExhausted` (was a plain bail in Task 2's commit, per the plan's own "handled in Task 3" note); a successful payment records spend against the persisted budget immediately (the Cashu proofs are already committed at that point, regardless of whether the subsequent chat HTTP call itself succeeds). - api/rpc/assistant_chat.rs: `assistant.budget-get`/`assistant.budget-set` RPCs (routed through the existing single `assistant.` dispatcher arm — dispatcher.rs untouched) and a `nostr_tor_proxy()` accessor for select_backend's onion-preference decision. Named tests (assistant::tests::): zero_budget_stops_loop_without_retry (S-12), zero_allowance_never_selects_routstr, ceiling_is_not_a_function_of_model_output, injection_loop_against_low_budget_does_not_overspend (EV-17) — all pass. Full assistant:: suite: 91/91. Full crate suite: 1235/1235 (2 pre-existing ignored, unrelated). dispatcher.rs and Cargo.toml untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a3521e5eeb |
feat(13-13): Routstr backend adapter — Nostr discovery, OpenAI chat, Cashu payment attach
Task 1 decision (proceed-docs-with-probe-first, operator-selected via AskUserQuestion 2026-08-05): 13-ROUTSTR-FINDINGS.md observed 0 of 9 cited protocol claims (no live provider was announcing on any of the 3 default relays in a 30s window on 2026-08-03; relay reachability itself WAS confirmed). This backend is written against docs.routstr.com's cited shape, with the first live chat-completions call doubling as the capability probe: a non-success HTTP status or a response missing the expected choices[0].message shape fails loudly (bails with the real status/body) rather than silently degrading. - assistant/backends/routstr.rs (new): RoutstrBackend implements the Backend trait — discover_providers subscribes for kind-38421 provider-announcement events over the existing Tor-proxy-aware Nostr client (nostr_discovery::build_nostr_client, never a second relay client), process-cached with a 5-minute TTL; select_provider picks the globally cheapest affordable (provider, model) price across every discovered provider (Routstr has no fixed target model the way Ollama/Claude do — CONTEXT.md delegates provider selection strategy to Claude's discretion), preferring an onion endpoint when Tor is up; attach_payment calls the existing budget-capped auto_pay_token verbatim (never hand-rolled); parse_openai_tool_calls parses the one string-encoded function.arguments shape exactly once at this adapter's edge; screen_outbound (G-B1/G-B2) runs before any body leaves the node, exactly as it does for Claude; ROUTSTR_MAX_TOKENS caps every request explicitly. - assistant/egress.rs: message_is_turn_own gains "system" and "tool" role handling plus an OpenAI tool_calls-sibling-field check — the pre-existing function was written only against Claude's wire shape (system as a top-level field, tool results wrapped in role:"user") and would have silently stripped Routstr's system prompt and tool-result context out of every outbound request via G-B2's fail-closed default arm. Fixed with 4 new regression tests pinning both wire shapes. - assistant/backends/mod.rs: registers `pub mod routstr;`. select_backend's actual wiring of the Routstr leg (budget-gated, per D-05) is Task 3's commit, once AssistantBudget exists — this task's own acceptance criteria do not require select_backend integration, only the adapter itself. 30/30 assistant::backends:: tests pass (17 new in routstr.rs, 3 new in egress.rs's OpenAI-shape regression tests were run separately at 12/12). Zero new packages (nostr-sdk/reqwest already in-tree); dispatcher.rs and Cargo.toml untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f1e50fbfd8 |
feat(13-12): G-B3 rate limit + read-only injection loop bound and owner notices
rate_limit.rs: assistant.chat gets its own request log keyed by AUTHENTICATED SESSION (not client IP, per 13-AI-SPEC.md §6 G-B3's own spec — an operator's session can roam across IPs within one sitting), on the SAME EndpointRateLimiter struct rather than a second limiter type. check_session/record_session_request enforce a hard ceiling (60/5min); session_soft_threshold_reached (30/5min) is checked separately so the call site can raise an owner notice before the hard refusal ever fires. Wired into assistant_chat.rs's handle_assistant_chat (Rule 3 — the plan's own declared intent, "assistant.chat is rate-limited per authenticated session," has no other call site to reach the real RPC surface) and into the existing 5-minute cleanup task in api/rpc/mod.rs. loop_.rs: run_loop now tracks whether D-10-wrapped untrusted content is present in context (seeded and re-checked as new tool results arrive mid-loop), counts grant refusals split by that flag via AssistantCounters::note_grant_refusal (a burst WITH untrusted content raises a Security notice — something in shared content may be trying to trigger actions; the same burst WITHOUT it raises a Ux/config notice instead, so probing is never confused with misconfiguration, T-13-83), counts turns-per-request, and counts MAX_TURNS-reached (3+ in one session raises an owner notice) right before the loop's own bail — this is EV-13's read-only injection loop, the one case the confirm gate structurally cannot see because reads never confirm. mod.rs: ToolExecCtx gains a `counters: Arc<AssistantCounters>` field (defaulting to the process-wide global_counters(), overridable per-test via with_confirm_gate_and_counters) so loop_.rs's counting has somewhere to write and tests can assert against an isolated instance without polluting concurrently-running tests. read_only_injection_loop_terminates_and_is_counted (EV-13) and grant_refusals_with_untrusted_content_are_a_security_signal (T-13-83) both pass. Full `cargo test --package archipelago` (1211 tests) green — the existing rate-limited RPC methods are unaffected by the new session-keyed limiter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fde7b1572d |
feat(13-12): G-B1/G-B2 cloud-egress secret scan and turn-minimality screen
assistant/egress.rs: screen_outbound(body, ctx) -> EgressVerdict runs on every request body about to leave this node for a cloud backend. G-B1 scan_secret_shapes checks for macaroon-shaped hex runs, BIP39-length word runs, ecash/Nostr-key-shaped strings, and the literal contents of files under data_dir/secrets — a hit fails closed (BlockFallBackLocal), logging only the match's kind, never the value. G-B2 assert_turn_minimal checks the outbound body against a mechanical allowlist of this turn's own fields (the user's turn, this turn's granted tool names, this turn's own tool results); an unrelated earlier tool result or content wrapped for a different turn is truncated out rather than eyeballed. An unparsable/ambiguous body also fails closed. MAX_OUTBOUND_CONTEXT_CHARS caps body size independent of minimality. Wired into backends/claude.rs's send() before the outbound HTTP request (on a block, send() errors before anything is sent — Rule 3, outside this task's originally-declared file list but structurally required to give screen_outbound a real caller); never wired into ollama.rs — nothing leaves the node on that leg, so paying the scan cost would be pointless. mod.rs: AssistantCounters/OwnerNotice — grant refusals, validation failures, turns-per-request, untrusted-content-present, cloud-escalation-while-local-up, blocked-egress and MAX_TURNS-reached counters, each raising an owner_notice() at its own AI-SPEC §7b threshold. Local and owner-facing only: no exporter, no /metrics, no OTLP anywhere in assistant/ or rate_limit.rs. backends/mod.rs's select_backend raises a cloud-escalation-while-local-up notice when Ollama is reachable but its configured model isn't tool-capable (Rule 3, same file-scope reasoning). 9/9 assistant::egress:: tests pass in this task's own isolated state (Task 1's 56 plus these 9 — ToolExecCtx's counters field and its loop_.rs call sites are Task 3's own commit, since nothing in this task's behavior needs them yet). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
265ba5ab19 |
feat(13-12): D-10 untrusted-content boundary — wrap_untrusted, per-call random token
assistant/untrusted.rs: wrap_untrusted(label, text) wraps peer-supplied text (filenames, log lines, mesh/peer status) in a delimiter block whose token is freshly randomized on every call via the in-tree rand crate — never a module constant, never derived from content. A forged closing boundary using a guessed/fixed token cannot terminate the real block early (EV-11). tools.rs: wrap_tool_result_if_untrusted wires this in for content_list, app_logs and mesh_status (the tools whose results carry peer-authored text); every other tool result passes through unwrapped. loop_.rs's execute_tool calls it at the exact point a successful ToolResult is constructed, before that content ever becomes part of a ChatMessage. No pattern-stripping or keyword-blocklist filter was added (D-10 rejects that approach by name) — the delimiter and D-11's confirm gate are two independent layers. Four scripted-worst-case tests in mod.rs prove the gate still holds even when a compromised model acts on an injected imperative (injected_instruction_does_not_grant_authority), a forged closing delimiter plus fake operator turn (forged_closing_delimiter_does_not_escape_block), or an injected mislabel attempting to hide the real action from the human (injected_mislabel_still_confirms_real_action) — plus wrap_untrusted_token_is_per_call (tools.rs) asserting the per-call token itself. Zero packages added — rand 0.8.5 already in-tree. 56/56 assistant:: tests pass in this task's own isolated state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3da9928cc9 |
feat(13-10): node-side chat history, scoped by caller, compacted not truncated
history.rs persists the ChatMessage transcript under data_dir (D-08), keyed by a HistoryKey derived from CallerScope so an operator's AIUI session and a mesh peer's transcript are structurally distinct files, not two rows a filter could forget. Writes are atomic (temp sibling + rename, matching music/index.rs::save_atomic's precedent) and 0600, following grants.rs's convention. Tool results longer than MAX_TOOL_RESULT_CHARS are truncated with a visible marker before entering history -- a new, assistant-scoped constant, never assist.rs's LoRa-airtime-tuned reply cap. Once the transcript exceeds KEEP_VERBATIM_TURNS, older turns fold into a running summary extended incrementally as turns age out, never regenerated from the full transcript. Wallet/files-category tool-call arguments are never persisted (AI-SPEC §7b's field policy applied to storage, not only tracing) -- categories are resolved by the caller from the same tools registry execute_tool uses, so history.rs never re-derives a second, driftable category list. Nothing reachable from confirm.rs's pending- confirmation state has a parameter path into this module at all (S-09 stays true structurally). assistant.history / assistant.clear-history route through 13-01's existing assistant.* dispatcher arm (dispatcher.rs untouched), each scoped to the calling session's own HistoryKey. run_loop (loop_.rs) now returns (answer, full_history) instead of just the answer string -- structurally necessary so chat() (mod.rs) can persist the tool-call/tool-result messages the loop built internally, not only the user question and final answer (Rule 3, mirroring 13-05's precedent of touching a file outside its own plan's files_modified list when the plan's own intent requires it). chat() persists this turn after run_loop returns; it does not yet feed prior persisted turns back into live model context -- a documented, deliberately scoped follow-up (see mod.rs's chat() doc comment and the plan SUMMARY). 8 new tests under assistant::history::tests::, including operator_and_mesh_transcripts_are_separate and wallet_tool_arguments_never_reach_the_transcript (asserted against both the deserialized struct and the raw on-disk bytes). Full assistant:: suite: 50/50 (42 baseline-after-Task-1 + 8 new); confirm::tests:: restart_drops_pending_not_executes still passes -- S-09 not weakened. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
821d8700ce |
feat(13-10): Ollama tool-calling backend, first in the D-04 chain
backends/ollama.rs implements the Backend trait against Ollama's
POST /api/chat (messages + tools arrays, message.tool_calls response) --
never mesh/listener/assist.rs::call_ollama's older single-shot prompt
endpoint, which has no tool-calling support at all. Ollama's per-call
tool-call ids (absent on the wire) are synthesized; its already-parsed
function.arguments object is passed through without a second string-parse
(the OpenAI-shape normalization would be wrong here). Every request sets
an explicit generation-length cap and runs non-streaming.
model_supports_tools queries Ollama's /api/show and caches the answer for
the process lifetime, turning AI-SPEC's [ASSUMED] note about
qwen2.5-coder's tool capability into a runtime fact: a non-tool-capable or
unreachable Ollama falls through to Claude with a logged reason, never a
silent tools-free degrade.
select_backend (backends/mod.rs) is now async and reuses the existing
detect_ollama() probe (mesh::assistant, bumped to pub(crate) for this
reuse) rather than re-probing. A new FallbackChain wraps the Ollama leg so
a transport error mid-turn falls through to Claude for that same call
instead of failing the turn outright.
13 new tests under assistant::backends::{ollama,}::tests::, exercised
against a local hyper-based HTTP stub (no mock-HTTP crate exists in this
workspace). Full assistant:: suite: 42/42 (29 baseline + 13 new).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
2d1f09d8c8 |
fix(13-08): declined actions never re-prompt + timeout chain covers the human wait
Two more on-device UAT findings: 1. Deny-retry loop: the model, told 'the user declined', simply called the tool again — each retry minted a fresh pending and re-opened the dialog (T-13-50 habituation, mechanized). ToolExecCtx now remembers declined actions for the turn, keyed by confirm::action_key — the same canonical (tool_name, validated_args) identity the nonce binds — and execute_tool refuses a re-ask before the gate, minting nothing. Regression test declined_action_never_reprompts_same_turn. 2. Timeout chain: rpcClient's 15s default aborted every confirmable turn client-side while the node kept the pending alive — the next turn then re-announced it (modal over and over) and every wait read as 'timed out'. assistant.chat now rides a 420s timeout; AIUI's bridge goes 180s→430s so the host's error path (which also expires the dialog) always fires first. Declined ToolResult text now also tells the model to stop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
31f9a4d5bf |
fix(13-08): confirm timeout 120s→300s + chrome closes an expired dialog
On-device UAT: the operator was timed out mid-read (120s), the chat turn
returned 'declined' while the dialog was still up, and their Approve then
hit a dead entry ('no such pending confirmation', 13:37:12 log). Nothing
executed — the gate failed safe — but the UX was a lie in both directions.
- CONFIRM_TIMEOUT 120s→300s: human-speed per T-13-51's own rubric.
- ContextBroker dispatches aiui:tool-confirm-expired when a pending action
vanishes node-side (poll) or the turn ends; Chat.vue closes the modal on
it. Same host-only CustomEvent discipline; iframe has no path to it.
- Two new tests; 21/21 green across toolConfirm + chatAiuiEmbed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
44f552cc3d |
fix(13-08): system prompt — call tools directly, never text-ask for confirmation
On-device UAT hit an infinite politeness loop: 'every write requires a human confirmation you cannot bypass' read to the model as 'collect consent in text first', so it never called restart_app, the confirm gate never engaged, and each stateless turn (history is 13-10) dropped the user's 'confirmed' into a void. The preamble now states the intended contract: the node presents the trusted dialog the moment the tool is called; a text pre-ask stalls the action and trains rubber-stamping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fc09d7a292 |
wip(13-08): checkpoint before operator session restart — Task 1 GREEN (28/28), Task 2 in progress
Executor stopped deliberately for a session restart (bypass-permissions relaunch). Executor's final report: 'cargo test assistant confirm-gate suite 28/28 green, individual nonce test passes; committing Task 1 next — first verify the tools.rs/grants.rs/backends diffs are formatting-only.' Task 1 (D-07/D-11 confirm gate, backend) is implemented and test-green but this checkpoint is verbatim-uncommitted-state, NOT the reviewed atomic Task 1 commit: continuation executor should verify diffs, then reset --soft or commit-on-top into proper feat(13-08) task commits. Task 2 (ToolConfirmModal.vue trusted chrome, Chat.vue + contextBroker.ts wiring) is partially built, tests written. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
db11c625c8 |
test(13-08): failing tests for the D-07/D-11 confirm gate (RED)
- confirm.rs: ConfirmGate/PendingConfirmation/Confirmed/PendingSnapshot/ ResolveRefusal API skeleton (request/resolve/mint_nonce/build_description still todo!()) plus the five named confirm tests: S-02 nonce binding, S-03 no-model-text, S-08 distinct resources, S-09 restart drops pending, timeout declines, and the no-shared-lock-across-the-wait case - mod.rs: ToolExecCtx gains the confirm gate (global by default, injectable for tests) and the S-01 destructive_tool_requires_confirm test with a seeded installed-app snapshot - verified RED: 7 new tests fail (todo! cores + unfilled destructive branch) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c098124d93 |
test(13-05): assert the D-09 ceiling over the whole registry, not a hardcoded tool list
Task 3: four registry-wide structural tests in tools.rs, iterating registry() so a future tool that crosses the D-09 ceiling fails CI rather than depending on a reviewer noticing: - registry_never_exposes_excluded_authority (S-04/T-13-24): scans every ToolDef's name+description for EXCLUDED_AUTHORITY_TERMS. - read_tools_never_confirm (S-07/T-13-31): every non-destructive tool executes via the real execute_tool choke point without raising anything confirmation-shaped. bitcoin_status/network_status excluded from live execution (their handlers make real outbound network calls that would make this test flaky on a sandboxed box); their destructive:false placement is still covered by the other assertions. - loop_is_bounded (S-13/D-05): MAX_TURNS is enforced, and 3 consecutive malformed-argument calls for the same tool name abort the turn with an apology before a 4th scripted backend turn is ever polled. - every_tool_has_explicit_category_and_destructive: sanity-checks the registry has exactly the 13 hand-written tools (4 destructive) that made it in, as a runtime backstop to the acceptance criteria's static grep for `..Default::default()`. Negative-case demonstration (per the plan's acceptance criteria): a hypothetical `wallet_send_sats` tool with a description mentioning "spending sats" trips EXCLUDED_AUTHORITY_TERMS's "spend" term, verified by tracing the exact haystack-contains logic registry_never_exposes_excluded_authority runs (see 13-05-SUMMARY.md for why this was traced rather than executed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
90706fe053 |
feat(13-05): expand curated tool registry to full D-06/D-09 allowlist, wire D-16 default-closed grants
Task 1: registry() grows from the tracer's single system_disk_status tool to the full 13-tool D-06 curated allowlist (9 read tools, 4 destructive write tools), each hand-written with its own JSON Schema, PermissionCategory and destructive flag -- nothing derived from api::rpc's method table. Adds EXCLUDED_AUTHORITY_TERMS (D-09's excluded authority, scanned by Task 3's registry-wide test), SETTABLE_KEYS/READABLE_SETTINGS_KEYS (AIUI-02's hand-picked settings surface, claude_api_key permanently absent from SETTABLE_KEYS), tools::dispatch (per-tool RPC dispatch) and tools::validate_business_rules (allowlisted-key / installed-app-id validation that runs before the destructive/confirm gate so a plainly-wrong request is refused with the real reason instead of the generic "not yet implemented" placeholder). assistant_dispatch_tool gains a params argument and the RPC method table Task 1's tools need. Task 2: grants.rs adds Grants (D-16 default-closed permission-category store, persisted 0600 under data_dir/assistant/grants.json; a missing file is default_closed(), never permissive). CallerScope::granted_categories becomes async and reads the persisted store instead of a hardcoded default; CallerScope::Mesh gains an `authorized` field so a mesh peer's ceiling is never wider than the operator's own grants. ToolExecCtx gains the AI-SPEC S-13 consecutive-validation-failure counter (>2 failures for the same tool name aborts the turn with an apology, checked in run_loop). build_system_prompt appends only currently-granted-category tools' names/descriptions -- an ungranted tool never appears in the prompt string (defense in depth; the execute_tool grant re-check is the actual gate). assistant_chat.rs adds assistant.list-tools / assistant.grants-get / assistant.grants-set, all routed through the existing single assistant.* dispatcher arm (dispatcher.rs untouched, verified by git diff --exit-code). dispatcher.rs is not touched -- all new RPC surface goes through 13-01's assistant.* prefix arm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fe6ccff73c |
feat(13-01): Rust assistant spine — one curated tool, one backend, one RPC surface
D-01/D-02/D-06 tracer slice: a new crate::assistant module (CallerScope,
PermissionCategory, ToolExecCtx, chat()) runs a multi-turn tool-calling loop
(run_loop/execute_tool, MAX_TURNS=8) against a curated single-tool registry
(system_disk_status, hand-written JSON Schema — no schemars) via a Claude
Messages API backend. execute_tool is the single choke point: unknown tools
are refused not ignored, D-16 category grants are re-checked even though the
system prompt already omits ungranted tools, and every real tool dispatches
through the SAME handle_system_disk_status RPC handler every other
authenticated caller uses (assistant_dispatch_tool bridge in
api/rpc/assistant_chat.rs) — never an AI-only backdoor.
assistant.chat is registered in dispatcher.rs as a single guarded
`m if m.starts_with("assistant.")` arm reached only after the existing
session-cookie + CSRF + role.can_access() gate in api/rpc/mod.rs — asserted
directly by assistant_methods_require_session against the live
UNAUTHENTICATED_METHODS list (visibility only widened to pub(crate) for that
assertion; the list's contents are untouched, per the Phase-10 hard
constraint).
Key read from data_dir/secrets/claude-api-key — the same path
mesh/rpc/mesh/assistant.rs already probes — never a second key location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|