Skip to content

fix(v2): tool naming, auth gates, schema flatten, WASM traps, workspace race - #2209

Merged
henrypark133 merged 34 commits into
stagingfrom
auth-postflight-and-readiness
Apr 10, 2026
Merged

henrypark133 merged 34 commits into
stagingfrom
auth-postflight-and-readiness

Conversation

@ilblackdragon

@ilblackdragon ilblackdragon commented Apr 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Bug-fix bundle discovered while exercising the v2 engine end-to-end against real Notion + Google Drive MCP/WASM tools. Each fix has regression coverage and could ship independently. Supersedes #2227 (incorporated in a8530e8b).

1. Workspace replace_chunks TOCTOU race

Concurrent reindex of the same document hit UNIQUE (document_id, chunk_index) because delete_chunks + insert_chunk were separate transactions with async points between them. New WorkspaceStore::replace_chunks wraps both in one transaction: libsql uses BEGIN IMMEDIATE, postgres serializes via SELECT ... FOR UPDATE on the parent memory_documents row. Embeddings are pre-computed before opening the transaction so nothing async runs between delete and insert.

2. Auth gate display name + OAuth deletion guard + alias-aware activation

Three traps surfaced by the v2 Drive trace:

  • Auth gate displayed google_oauth_token (credential name) instead of google-drive-tool (extension name) and passed it to submit_auth_token which expects an extension name, trapping users in a re-auth loop. Both paths now resolve via a shared resolve_extension_for_action helper.
  • activate_wasm_tool and several capabilities-lookup sites used direct dir.join(name.wasm) instead of alias-aware helpers. A tool installed under the legacy hyphen filename silently failed to activate while the readiness probe reported it as ready.
  • configure() post-activation cleanup unconditionally wiped the OAuth token the caller just provided. Now gated on whether the caller is providing a fresh credential in the same secrets map.
  • starts_with prefix filters for MCP tool listing/removal used the raw (possibly hyphenated) server name while registry keys are normalized to underscores. Now uses mcp_tool_id(name, "") as the prefix at all 3 sites.

3. MCP tool name canonicalization

MCP tools with dashes in names (e.g. Notion's notion-search) were unreachable because the registry key preserved dashes while LLMs normalize to all-underscores. mcp_tool_id now replaces every non-[A-Za-z0-9_] character with _ (handles dashes, dots, colons, slashes, unicode). Collision detection in create_tools emits a warn! when two distinct originals collide on the same normalized id.

4. OpenAI tool schema top-level flatten + Python/Rust action_calls round-trip

  • Schema flatten: OpenAI rejects top-level oneOf/anyOf/allOf/enum/not in tool schemas. normalize_schema_strict now detects this, merges all variant properties into a single flat object (so the LLM keeps structured field hints), and appends the original schema to the description as keyword-aware advisory text. Also handles nullable object types ("type": ["object", "null"]) without unnecessary flattening.
  • Action_calls round-trip: The Python orchestrator stored {name, call_id, params} but json_to_thread_messages tried to deserialize as {action_name, id, parameters}. Introduced PythonActionCall interchange struct as single source of truth. The warn log on parse failure summarizes structure only (no PII from tool params).

5. WASM trap classification + fuel limit

  • Extracted classify_trap_error helper that uses structured wasmtime::Trap downcast (version-proof) instead of string matching. Covers OutOfFuel, StackOverflow, UnreachableCodeReached, and falls back to full error chain for unrecognized traps.
  • Default fuel limit raised from 10M to 100M instructions. 10M was too low for WASM tools making HTTP requests + parsing JSON responses — a single 30KB JSON round-trip can burn 20-50M instructions in serde_json.

6. Incorporated from #2227

  • Factory-level server.name normalization before any branch (including OAuth early-return)
  • new_with_name normalization for callers bypassing the factory
  • Bidirectional resolve_key alias resolution in ToolRegistry (exact → hyphen→underscore → underscore→hyphen)
  • Legacy token secret name fallback for pre-normalization tokens (prevents forced re-auth on upgrade)
  • WASM tool + channel loader filename normalization

7. Live e2e Drive auth gate test

End-to-end smoke test driving prompt → AuthRequired → token paste → resume. #[ignore]d behind live tier, no committed trace fixture (PII risk). Supporting harness: with_no_trace_recording(), multi-turn finish_turns, TestRig::secrets_store() + owner_id() accessors.

8. Test config

Config::for_testing now generates a random 32-byte AES master key (generated fresh per call) so replay-mode tests that touch credentials have a working secrets store out of the box.

Test plan

  • cargo fmt --all -- --check — clean
  • cargo clippy --all --benches --tests --examples --all-features — zero warnings
  • cargo test --workspace --lib — 5387 passed, 0 failed, 5 ignored
  • All commits rebase cleanly onto latest origin/staging
  • Manual: install Notion MCP → tool calls succeed via notion_notion_search
  • Manual: install Google Drive WASM with no credentials → AuthRequired { extension_name: "google-drive-tool" } surfaces, token paste resumes
  • Manual: enable GitHub Copilot MCP → no HTTP 400 schema error
  • Manual: engine v2 tool flow → zero Rewriting orphaned tool_result log lines

Supersedes #2227.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings April 9, 2026 17:35
@github-actions github-actions Bot added scope: tool/mcp MCP client scope: db Database trait / abstraction scope: db/postgres PostgreSQL backend scope: llm LLM integration scope: workspace Persistent memory / workspace scope: extensions Extension management scope: docs Documentation size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 9, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes multiple v2 engine regressions found during live Notion + Google Drive MCP/WASM testing, spanning workspace chunking concurrency, auth-gate UX/routing, MCP tool naming, and LLM/provider boundary robustness.

Changes:

  • Atomically replaces workspace chunks to avoid concurrent reindex races; adds regression test coverage.
  • Fixes auth-gate display/routing and legacy hyphen/underscore alias handling across WASM tools/channels; expands tests and live harness support.
  • Normalizes MCP tool ids for LLM-emitted names, flattens OpenAI-incompatible top-level JSON schemas, and fixes Python↔Rust action_calls round-trip.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/support/test_rig.rs Exposes secrets store + owner id for live auth-gate tests.
tests/support/live_harness.rs Adds multi-turn logging + opt-out trace recording for credentialed live tests.
tests/e2e_live.rs Adds ignored live Drive auth-gate/refresh end-to-end tests.
src/workspace/repository.rs Adds replace_chunks for transactional chunk replacement.
src/workspace/mod.rs Switches write path to precompute embeddings then replace_chunks; adds concurrency regression test.
src/workspace/document.rs Introduces ChunkWrite payload for chunk replacement.
src/tools/mcp/mod.rs Re-exports mcp_tool_id for cross-module consistency.
src/tools/mcp/client.rs Uses mcp_tool_id for registration names; adds tests for dashed tool names + registry round-trip.
src/llm/rig_adapter.rs Adds top-level schema flattening for OpenAI tool API + tests; updates tool conversion to pass mutable description.
src/llm/openai_codex_provider.rs Shares schema normalization/flattening with rig adapter; adds regression test.
src/llm/CLAUDE.md Documents the updated schema normalization behavior.
src/extensions/manager.rs Uses alias-aware lookup for wasm files/capabilities; fixes MCP activation naming; gates OAuth cleanup; adds regression tests.
src/db/postgres.rs Implements WorkspaceStore::replace_chunks via repository.
src/db/mod.rs Extends WorkspaceStore trait with replace_chunks.
src/db/libsql/workspace.rs Implements replace_chunks with BEGIN IMMEDIATE to honor busy_timeout under contention.
src/config/mod.rs Seeds deterministic secrets master key for test config.
src/bridge/router.rs Fixes auth gate display/routing to use provider extension name where applicable.
crates/ironclaw_engine/src/executor/orchestrator.rs Adds PythonActionCall interchange to preserve action/tool linkage across Python boundary; adds tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/workspace/repository.rs
Comment thread src/tools/mcp/client.rs Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces significant improvements across several modules, primarily focusing on enhancing the robustness and user experience of tool interactions and data management. Key changes include standardizing ActionCall interchange between Rust and Python orchestrators, refining authentication gate handling to display user-friendly names and correctly route tokens for WASM tools, and implementing atomic chunk replacement in the workspace store to prevent race conditions during concurrent reindexing. Additionally, the PR addresses issues with extension file lookup by supporting legacy hyphenated names and refines OAuth token management during configuration. The LLM tool schema normalization logic has been updated to handle OpenAI's strict requirements by flattening top-level union constructs and providing advisory hints in the description, and MCP tool names are now canonicalized to prevent registry key mismatches. New end-to-end tests for OAuth flows have been added, and the test harness now supports multi-turn conversations and optional trace recording. Feedback suggests improving the normalize_schema_strict function to retain more structured information for the LLM by merging properties from union variants, and addressing potential key collisions in mcp_tool_id by using structured keys instead of simple string concatenation.

Comment thread src/llm/rig_adapter.rs
Comment thread src/tools/mcp/client.rs
henrypark133
henrypark133 previously approved these changes Apr 9, 2026

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Severity Count
Critical 0
Warning 0
Nit 1

This is a very high-quality PR. All five fixes are well-motivated, clearly documented, and have thorough regression test coverage. The commit messages and PR description are exemplary — each bug is traced from root cause through fix with specific behavior-before-and-after analysis.

Highlights:

  • fix(workspace): The BEGIN IMMEDIATE choice for libSQL is correct — DEFERRED transactions bypass busy_timeout on write contention, which is a non-obvious SQLite gotcha. Pre-computing embeddings before entering the transaction to eliminate async points inside it is a smart design decision.
  • fix(auth): The configure() OAuth token deletion guard (!secrets.contains_key(&auth_cfg.secret_name)) cleanly separates the "submit token" path from the "reconfigure" path.
  • fix(mcp): Canonicalizing at registration via mcp_tool_id while preserving the original dashed name on the inner McpTool for wire calls is the right layering.
  • fix(llm): PythonActionCall interchange type captures the field-naming asymmetry in one place without touching ActionCall's serde attributes (which would invalidate persisted data). The caller-level json_to_thread_messages test is exactly the kind of regression test the codebase's testing rules call for.
  • Schema flatten: Replacing the schema with a permissive envelope and moving the original to advisory description text is the right call. Truncation on char boundary is a nice detail.

Test Coverage Notes

  • All five fixes have dedicated regression tests that exercise the actual bug path
  • Workspace TOCTOU test uses 4 concurrent writers with multi-threaded tokio runtime
  • MCP tool name test includes a caller-level round-trip through ToolRegistry::resolve_name
  • Live tests properly #[ignore]d with with_no_trace_recording() to prevent PII leakage
  • insert_chunk on the trait is still used by src/import/openclaw/memory.rs, so it's correctly retained alongside the new replace_chunks

Comment thread src/bridge/router.rs Outdated
ilblackdragon added a commit that referenced this pull request Apr 9, 2026
Address PR #2209 review: the helper used `serde_json::from_value(...).ok()?`
which is the exact `.ok()` swallow pattern the parent commit set out to
fix. If a future Python orchestrator patch ever drifts the action_calls
shape (extra required field, rename, partial migration), the helper would
silently return None and every subsequent tool result would look orphaned
to `sanitize_tool_messages` again — with no operator-visible signal at all.

Replace with an explicit match that emits a `warn!` (with the parse error
and the offending JSON value) on the failure path so the breadcrumb is
visible the moment any drift happens. The `None` return is preserved so
existing callers and the
`python_json_to_action_calls_rejects_canonical_field_names` test still
hold.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Apr 9, 2026
Address PR #2209 review: the previous fix hardcoded
`0123456789abcdef...0123456789abcdef` as the AES-256-GCM master key
inside `pub fn for_testing`. The function is `pub` (gated only behind
`#[cfg(feature = "libsql")]`, not `#[cfg(test)]`, because integration
tests in `tests/*.rs` are separate crates compiled against the lib's
non-test surface), which meant every developer building with libsql
had a publicly-known master key sitting in their process — and the
constant was now baked into Git history forever.

Replace with `generate_test_master_key()`, a private helper that pulls
32 bytes from `rand::thread_rng()` and hex-encodes them. Each call
returns a fresh key. Tests don't need cross-process determinism: each
test creates its own temp DB and the secrets store is born fresh on
every call anyway. `rand 0.8` is already a direct workspace dependency
so no Cargo changes are needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Apr 9, 2026
Address PR #2209 review: `mcp_tool_id` only handled `-` → `_`, but the
MCP spec doesn't actually constrain tool names to OpenAI's
`^[a-zA-Z0-9_-]{1,64}$` regex — a server could legally return
`notion.search`, `notion:create_issue`, `files/read`, or names with
spaces or non-ASCII characters. The same LLM normalization that bites on
`-` will bite on `.` and `:` too, and `extract_server_name` only strips
`.` from the host portion of a URL, leaving the tool portion of the
prefixed name unprotected.

Replace the single `.replace('-', "_")` with a `chars().map()` pass that
sends every non-`[A-Za-z0-9_]` character to `_`. This handles dashes,
dots, colons, slashes, spaces, and unicode in one shot — and since the
chars iterator yields one Rust char per code point, multi-byte
characters become a single `_` rather than splitting weirdly.

New regression test `test_mcp_tool_id_normalizes_non_identifier_chars`
covers dot, colon, slash, space, and multi-byte unicode inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Apr 9, 2026
Address PR #2209 review: the description hint appended by
`flatten_top_level` was a one-size-fits-all "pick one variant and pass
its fields as a flat object". That's correct for top-level `oneOf` and
`anyOf`, but actively misleading for the other forbidden constructs:

- `allOf` — the LLM should pass fields from ALL variants combined,
  not pick one
- `enum` — the LLM should pass one of the listed literal values,
  not "fields"
- `not` — the LLM should pass any object that does NOT match the
  constraint

Extract `FORBIDDEN_TOP_LEVEL` to a module-level constant (now shared
between `needs_top_level_flatten` and a new `detect_forbidden_top_level`
helper) and add `schema_flatten_hint_intro(detected)` which branches on
the actual keyword that triggered the flatten and returns a precise
intro string. Falls back to a "free-form object" message when the
schema wasn't an object at all (no recognized forbidden keyword, just
the wrong top-level type).

New regression test `test_normalize_schema_strict_hint_is_keyword_aware`
asserts that each of the 5 keywords produces a hint containing the
expected discriminating phrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ilblackdragon

Copy link
Copy Markdown
Member Author

Review fixes

Pushed 4 fixup commits addressing the Critical, High, and Medium issues from the review. Each fix is its own commit so the trail is reviewable in isolation.

# Severity Commit What changed
1 🔴 Critical `9ae48a7c` `python_json_to_action_calls` now logs a `warn!` (with the parse error and the offending JSON value) on the failure path instead of swallowing via `.ok()?`. The whole point of the parent commit was to undo a `.ok()` swallow and the original fix re-introduced the same trap one layer deeper.
2 🟠 High `5a6a3ac0` `Config::for_testing` now generates a fresh random AES-256-GCM master key per call via `generate_test_master_key()` (32 random bytes from `rand::thread_rng()`, hex-encoded). The hardcoded `0123456789abcdef...` constant is gone — every developer building with `--features libsql` no longer has a publicly-known master key in their process. `#[cfg(test)]` wasn't an option because integration tests in `tests/*.rs` are separate crates compiled against the lib's non-test surface.
3 🟡 Medium `18d4ce48` `mcp_tool_id` now replaces every non-`[A-Za-z0-9_]` character with `_`, not just dashes. Handles dots, colons, slashes, spaces, and multi-byte unicode in one pass via `chars().map()`. New regression test `test_mcp_tool_id_normalizes_non_identifier_chars` exercises all five categories.
4 🟡 Medium `264955e8` `flatten_top_level` now picks a description hint that matches the actual keyword that triggered the flatten. `oneOf`/`anyOf` get "pick ONE variant", `allOf` gets "pass fields from ALL variants combined", `enum` gets "pass one of the listed values", `not` gets "any object that does NOT match". Extracted `FORBIDDEN_TOP_LEVEL` to a module constant and added `detect_forbidden_top_level` + `schema_flatten_hint_intro` helpers. New regression test `test_normalize_schema_strict_hint_is_keyword_aware`.

Issues I deliberately did NOT fix

🟠 High: Auth display name TOCTOU. Caching the resolved name on `PendingGate` would touch the persisted struct (`Serialize`/`Deserialize` via `GatePersistence`), require a backward-compat `#[serde(default)]` migration, and add a field to ~6 PendingGate construction sites. The TOCTOU window is small (it only matters if the tool is unregistered between the gate firing and the user pasting a token, in the same conversation), and even when it triggers the failure mode is benign — the second resolution falls back to `credential_name` which is the pre-fix behavior. I noted in my own review it was "lower priority; flag and forget" and I'm sticking to that judgement. Worth a follow-up issue if you want a paper trail.

🟡 Medium: Schema flatten truncation strategy. The 1500-byte byte-budget truncation is genuinely a hack — for a real GitHub Copilot `github` schema the LLM only sees the first ~15 variants. The right fix is a semantic summary ("this tool accepts one of N variants discriminated by `action`: ..., enumerate the discriminator values"), not just bumping the budget. That's significant work and out of scope for this PR — would prefer it as a follow-up issue with proper design rather than a band-aid here.

🟢 Low: `replace_chunks` comment wording and embedding-dimension validation symmetry between Postgres and libsql. Cosmetic / pre-existing risk, no functional impact, leaving alone.

Verification

Same gates as the original PR, all green:

  • `cargo fmt --all -- --check` — clean
  • `cargo clippy --all --benches --tests --examples --all-features` — zero warnings (one `manual_find` lint surfaced and was fixed in commit 4)
  • `cargo test --workspace --lib` — 5371 passed, 0 failed, 5 ignored (was 5370; +2 new tests, -1 from a test count shift in one of the smaller binaries)

🤖 Generated with Claude Code

Comment thread crates/ironclaw_engine/src/executor/orchestrator.rs
Comment thread src/tools/mcp/client.rs
Comment thread crates/ironclaw_engine/src/executor/orchestrator.rs
ilblackdragon added a commit that referenced this pull request Apr 10, 2026
…n parent doc

Address PR #2209 review (Copilot, src/workspace/repository.rs:350):
the libsql `replace_chunks` is fine because `BEGIN IMMEDIATE` acquires
the writer lock at transaction start, but the postgres path used the
default-isolation `BEGIN` which is not equivalent. Two concurrent
reindexers running under separate snapshots can both DELETE (each sees
its own pre-delete state, neither sees the other's), then race to
INSERT chunk_index 0 and hit the `UNIQUE (document_id, chunk_index)`
constraint.

Add `SELECT 1 FROM memory_documents WHERE id = $1 FOR UPDATE` at the
top of the transaction. The `FOR UPDATE` row lock is per-document,
ties to the existing parent row (FK already in place from
`memory_chunks.document_id`), and is released automatically on
commit/rollback. Concurrent reindexers for the same doc now serialize
on the parent row and last-writer-wins cleanly.

Picked `FOR UPDATE` over `pg_advisory_xact_lock` because it's the
row-locking primitive PG operators expect when reading the code, and
it doesn't introduce a hash function dependency for the lock key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Apr 10, 2026
…velope

Address PR #2209 review (gemini-code-assist, src/llm/rig_adapter.rs:296):
after flattening a top-level oneOf/anyOf/allOf, the LLM was left with
`properties: {}` and could only read variant fields from the description
hint. That works but it's lossy — the LLM can't do schema-based
reasoning about which fields exist, and the description hint is
truncated to 1500 bytes so deeply-nested schemas are unreadable.

Add `merge_top_level_variant_properties` which walks the union variants,
collects every property they declare, and returns a single map. The
flatten envelope now uses that map instead of empty `{}`, so the LLM
sees structured field hints. `additionalProperties: true` and
`required: []` are preserved, so strict-mode validation stays disabled
and the LLM is free to mix fields across variants — the upstream MCP
server enforces the actual constraints on its end.

First-write wins on conflicting types: if two variants declare the
same field with different schemas, the first variant's schema is kept.
The full original schema still goes into the description hint, so the
ambiguous case is recoverable from there.

Two new regression tests:
- `test_normalize_schema_strict_merges_variant_properties` exercises a
  GitHub-Copilot-shaped tool with two variants that share a
  discriminator and asserts every field from every variant survives.
- `test_normalize_schema_strict_merge_first_write_wins_on_conflict`
  pins the documented conflict-resolution behaviour.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Apr 10, 2026
… sites

Address PR #2209 review (henrypark133, src/bridge/router.rs:1606): the
`provider_extension_for_tool + unwrap_or_else(credential_name)` pattern
was implemented in three places — once via the
`resolve_auth_gate_display_name` helper at line ~64, and twice inlined
in `resolve_gate` (line ~1603) and `await_thread_outcome` (line ~2779).
The inline sites couldn't use the helper because they'd already
destructured `credential_name` from the `ResumeKind` match and needed
the result for `submit_auth_token`, not just display.

Extract the core into `async fn resolve_extension_for_action(tools,
action_name, credential_fallback) -> String`. Make
`resolve_auth_gate_display_name` a thin wrapper that handles the
non-Authentication ResumeKind variants. Both inline sites now call the
helper directly with the destructured `credential_name`. The two
inline-site comment blocks that explained the rationale are collapsed
into shorter "see helper for full rationale" pointers since the doc
on `resolve_extension_for_action` carries the full explanation now.

Three sites collapse to one implementation. The auth display + routing
logic now has a single source of truth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Apr 10, 2026
…e_tools

Address PR #2209 review (serrrfirat, src/tools/mcp/client.rs:682):
after the broader `mcp_tool_id` char normalization (commit 18d4ce4),
two MCP tools whose names differ only by `-` vs `_` (e.g. `search-all`
and `search_all`) collide on the same registry key. The second
`ToolRegistry::register` call silently shadows the first with no
signal at all — operators debugging an unreachable tool would have
zero breadcrumb to discover the collision.

Add collision detection in `McpClient::create_tools` itself, where we
still have both the original tool name and the normalized id. Build a
`HashMap<normalized_id, original_name>` while iterating, and emit a
`tracing::warn!` when two distinct originals collide on the same id.
The warn carries the normalized id, both colliding original names,
and the server name, so an operator can immediately see which upstream
tools to rename. Behaviour is unchanged — the second tool still wins
on register, matching what the LLM would emit anyway since it
normalizes both names to the same string.

The collision detection is scoped to a single MCP server's tool list
because cross-server collisions have different registry-key prefixes
(`server_a_foo` vs `server_b_foo`) and can't actually shadow each
other. This is the right level — `ToolRegistry::register` itself
doesn't have access to the pre-normalization name and couldn't emit
this signal even if we wanted it there.

New regression test
`test_create_tools_handles_post_normalization_collision` drives a
MockTransport that lists `search-all` and `search_all`, asserts both
wrappers are produced with the same `Tool::name()`, registers them in
a real `ToolRegistry`, and asserts last-write-wins on shadow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Apr 10, 2026
…ure instead of injecting null

Address PR #2209 review (serrrfirat,
crates/ironclaw_engine/src/executor/orchestrator.rs:1920): the previous
helper used `unwrap_or_else(|_| Value::Null)` which silently corrupts
the array when serialization fails. The Python orchestrator
(`default.py`) accesses `c.get("name")` / `c.get("call_id")` /
`c.get("params")` on each entry, so a `null` would crash with a Python
`AttributeError` and lose the entire LLM step — and the fallback
contradicts this PR's own stated goal of not silently swallowing
errors.

Replace with `filter_map` so a failed entry is dropped from the output
rather than corrupting it. The warn log on the failure path is
preserved (and now also includes `action_name` for easier
correlation). Python's tool-result loop iterates
`range(len(results))` against the same shortened call list so a
missing entry is benign.

Note: the failure path is essentially unreachable for the
`PythonActionCall` shape (`String + String + Value` all
infallible-to-serialize) but the contract should still be safe — the
helper will be touched again when the Python interchange shape
evolves and we don't want a future maintainer to discover this trap
the hard way.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ilblackdragon and others added 8 commits April 11, 2026 02:26
… normalizer misses

The schema_validator module already knew the "array items must be an
object" rule (Rule 8, line 218), had a test for it
(test_array_missing_items_fails, line 315), and would have caught the
google_docs_tool 400 — but it was only wired into CI tests against
built-in tools, never applied to WASM/MCP tool schemas or to the
output of normalize_schema_strict.

The root cause pattern: we're playing whack-a-mole with OpenAI's
undocumented strict-mode rules, adding fixes one at a time when a new
tool exposes a schema shape the normalizer doesn't handle. Each time,
the fix is a runtime 400 in production that takes a PR cycle to fix.

The structural fix: run validate_strict_schema as a debug-level
post-check after normalization. If the normalizer missed something,
the diagnostic appears in local logs immediately (before the schema
even reaches the LLM provider), giving developers a local breadcrumb
instead of a runtime 400 from OpenAI. The schema still goes through
(the tool remains usable), and the LLM provider surfaces the 400 if
OpenAI actually rejects it — but now the cause is instantly visible
in `RUST_LOG=ironclaw::llm=debug` output.

This also means that any future normalizer rule we add gets automatic
regression coverage: if the normalizer introduces a bug that violates
a rule the validator knows about, the debug log fires on every tool
call in dev mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…action_calls warn

Two runtime issues from the latest deploy:

1. The flatten path in `normalize_schema_strict` short-circuited with
   `return schema`, skipping BOTH the recursive normalizer AND the
   post-normalization validator. The merged properties copied from
   union variants were raw — a `Vec<serde_json::Value>` field like
   google_docs_tool's `requests` kept its bare `{"type": "array"}`
   without `items`, and OpenAI rejected it with "array schema items
   is not an object" on every tool-using call.

   Fix: the flatten path now normalizes each merged property
   individually via `normalize_schema_recursive(prop_schema)` before
   returning the envelope. The top-level envelope stays permissive
   (`additionalProperties: true`, `required: []`) so the LLM can
   mix fields across variants, but each property's internal schema
   gets the full treatment (array items, nested objects, etc.).

   The post-normalization validator also runs on both paths now
   (no early return before it).

   Regression test:
   `test_normalize_schema_strict_flatten_normalizes_merged_array_items`
   mimics the google_docs_tool shape (tagged enum with `oneOf`, one
   variant containing an items-less array) and asserts the merged
   `requests` property has `items` as an object after normalization.

2. `python_json_to_action_calls` warn log fired on every text-only
   assistant message with "invalid type: null, expected a sequence"
   because Python's `action_calls: null` (legitimate "no tool calls"
   signal) was passed to the parser. Added a `.filter(|v| !v.is_null())`
   before the parser call in `json_to_thread_messages` so null is
   treated the same as "key absent" — no parse attempt, no false
   alarm. The warn only fires for genuinely malformed data now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address PR #2209 Copilot review (rig_adapter.rs:347 x2, :406):

1. `count_json_nodes` doc claimed "returns early once it exceeds the
   caller's budget" but always fully traversed. The approach also
   missed the case a reviewer flagged: few-node schemas with multi-MB
   string values would pass the node check but still allocate
   proportionally during `serde_json::to_string`.

Replace both the node counter and the `to_string` + truncate pattern
with `serialize_json_capped(value, max_bytes)`: a `serde_json::to_writer`
call through a `CappedWriter` that silently discards bytes past the
budget. This bounds the actual heap allocation to `max_bytes` regardless
of schema shape — many-node deep recursion AND multi-MB string values
are both capped. The writer returns `Ok(data.len())` after the cap so
serde_json thinks all bytes were consumed and continues (minimal
remaining work since the output is being discarded). The output is
guaranteed valid UTF-8 because serde_json only emits ASCII structural
characters and JSON-escaped unicode.

The `count_json_nodes` function and `MAX_SCHEMA_NODES` constant are
removed — the capped serializer subsumes them entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ctionCall

The bootstrap context builder (`build_orchestrator_inputs`) serialized
`m.action_calls` directly via the canonical `ActionCall` serde format
(`{action_name, id, parameters}`), but the Python orchestrator passes
these back verbatim in `working_messages` on the next `__llm_complete__`
call, where `python_json_to_action_calls` expects the interchange format
(`{name, call_id, params}`). The mismatch surfaced as "missing field
\`name\`" on every thread resume after a gate pause (approval, auth),
orphaning all subsequent tool results.

This is the SECOND code path (after `handle_llm_complete`) that feeds
action_calls into the Python working transcript. Both must use the same
shape — `action_calls_to_python_json` is the single source of truth.

Triggered by: user approves `tool_upgrade` → thread resumes → bootstrap
rebuilds context from `internal_messages` (which stores canonical
`ActionCall`s from the DB) → Python reads `{action_name, id, parameters}`
→ echoes them back on next LLM call → `python_json_to_action_calls`
fails → assistant message loses tool_call linkage → all tool results
orphaned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ure action_calls serialization drift

The gate-resume bug (08e4720) happened because `build_orchestrator_inputs`
serialized `action_calls` with canonical `ActionCall` field names instead
of the `PythonActionCall` interchange format. The existing round-trip
test only covered the `__llm_complete__` path (within a single Python
orchestrator run), not the bootstrap path (thread resume after gate
pause). Anyone adding a THIRD serialization path in the future would
have no test guardrail.

Two new tests:

1. `bootstrap_context_action_calls_round_trip_through_python_interchange`:
   Builds a `ThreadMessage` with `action_calls` in canonical format
   (the shape stored in the DB), serializes through the EXACT pattern
   `build_orchestrator_inputs` uses, parses back through
   `json_to_thread_messages`, and asserts the calls survive. This is
   the test that would have caught the gate-resume bug on the first
   attempt.

2. `canonical_action_call_field_names_do_not_round_trip`:
   Negative test that verifies canonical names (`{action_name, id,
   parameters}`) are REJECTED by the parser. Documents the current
   contract: if this test ever passes, the `PythonActionCall`
   interchange type can be removed because the formats unified. Serves
   as a tripwire for anyone who adds `#[serde(rename)]` to `ActionCall`
   or changes the parser to accept both formats.

Together these two tests cover every known serialization boundary into
the Python transcript and make the failure mode instantly visible in
`cargo test` rather than as a runtime warn log after a gate pause.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e false-positive noise

The post-normalization validator (added in ce96c2a as a safety net)
was firing on every flattened schema with "additionalProperties should
be false" — a false positive because the flatten envelope deliberately
uses `additionalProperties: true` so the LLM can mix variant fields.
With 12 flattened tools loaded, this produced 12 debug log lines PER
LLM CALL, drowning real signals.

The flatten path's output is intentionally non-strict — running a
strict-mode validator on it is semantically wrong. Move the validator
behind the non-flatten branch and add an early `return schema` for the
flatten path (after normalizing individual properties). The validator
still catches issues on normal strict-mode schemas (the non-flatten
path), which is where it has value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…, and MCP naming

Systematic test audit of all 27 PR commits identified gaps where
production bugs had no hermetic regression test or where important
code paths had only helper-level (not caller-level) coverage.

New tests:

1. **test_realistic_wasm_schema_survives_normalize_flatten_pipeline**
   (rig_adapter.rs) — End-to-end test using the google_docs_tool's
   actual schema shape: tagged enum with 4 variants, one containing
   `requests: Vec<Value>` (bare array, no items), one with a nested
   object (text_style). Drives through normalize_schema_strict AND
   convert_tools. Asserts oneOf flattened, all variant properties
   merged, array items is object, nested objects get strict-mode.
   This single test would have caught BOTH production bugs (flatten
   path short-circuit + array items unreachable on flatten path).

2. **test_normalize_schema_strict_fixes_deeply_nested_array_items**
   (rig_adapter.rs) — 3-level nesting: object → array → object →
   array → object → array. Verifies the recursive normalizer walks
   the full depth and fixes every array items at every level.

3. **json_to_thread_messages_handles_null_action_calls_gracefully**
   + **handles_absent_action_calls** + **handles_empty_action_calls_array**
   (orchestrator.rs) — Three edge cases for the Python ↔ Rust
   message round-trip: null (was a false alarm), absent (baseline),
   and empty array (valid, produces Some(vec![])). The null case
   would have caught the "invalid type: null, expected a sequence"
   false alarm before it hit production.

4. **latent_provider_actions_normalize_hyphenated_server_names**
   (manager.rs) — Registers an MCP server with hyphenated name
   (`my-mcp-server`) and two tools (one with dashes, one without).
   Asserts latent action_names use all-underscore form
   (`my_mcp_server_search_all`) and the old hyphenated form doesn't
   survive. Exercises the mcp_tool_id normalization at the
   ExtensionManager layer.

5. **test_serialize_json_capped_boundary_conditions** +
   **test_serialize_json_capped_large_string_values** (rig_adapter.rs)
   — Size-capped serializer edge cases: under cap (full output),
   exactly at cap, over cap (truncated), zero cap (empty), and the
   multi-MB-string-in-few-nodes case the old node counter missed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ck, MCP config alias, legacy token request path, stale reindex guard

1. **serialize_json_capped UTF-8 safety** (Copilot, rig_adapter.rs:399):
   serde_json v1 emits raw UTF-8 for non-ASCII chars (e.g. CJK in
   property descriptions), so byte-capped truncation can cut
   mid-codepoint. `String::from_utf8` now falls back to
   `e.valid_up_to()` to trim to the last complete codepoint instead
   of dropping the entire hint on a UTF-8 error.

2. **FOR UPDATE row count check** (Copilot x2, repository.rs:377):
   `SELECT 1 ... FOR UPDATE` returns 0 rows if the document doesn't
   exist, silently acquiring no lock. Now checks the row count and
   returns a clear `ChunkingFailed` error when it's 0.

3. **MCP config lookup alias-aware** (serrrfirat, factory.rs:40):
   `get_mcp_server` now tries exact name → hyphen alias
   (underscores→hyphens) → underscore alias (hyphens→underscores).
   After factory normalizes `server.name` to underscores,
   `provider_extension_for_tool` returns `my_server`, but the
   persisted config is keyed as `my-server`. Without alias lookup,
   `activate_mcp("my_server")` failed with `ServerNotFound`.

4. **Legacy token request-time fallback** (serrrfirat, auth.rs:1208):
   `get_access_token` now falls back to the legacy (pre-normalization)
   secret name when the canonical name returns no token. Without this,
   `is_authenticated` reported true (it has its own fallback) but the
   actual MCP request sent no Authorization header — the server
   appeared ready but tool execution 401'd until re-auth.

5. **Stale reindex guard** (serrrfirat, workspace/mod.rs:2181):
   `reindex_document_with_metadata` now captures the content hash at
   read time and re-checks it after computing embeddings. If another
   writer updated the document content during the embedding window,
   the reindexer skips chunk replacement — the other writer's reindex
   call will produce correct chunks for the new content. This closes
   the content-vs-chunks skew where writer B wins the document UPDATE
   but writer A wins the later replace_chunks transaction.

6. **Log summary PII concern** (Copilot, orchestrator.rs:2020): false
   positive — the keys logged are JSON Schema property names from
   the PythonActionCall interchange dict, not user tool parameters.
   Reply-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ilblackdragon
ilblackdragon force-pushed the auth-postflight-and-readiness branch from 454012a to d897cb1 Compare April 10, 2026 17:38
Address Copilot review (workspace/mod.rs:2208): the optimistic
concurrency check caught all `Err(_)` as "document deleted" which
would silently swallow real DB errors (transient connection issues),
leaving chunks stale with no signal. Now only catches
`DocumentNotFound` for the deleted case; other errors propagate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 10, 2026 17:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 26 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

tests/support/test_rig.rs:866

  • TestRigBuilder now always uses a temp skills_dir under the rig tempdir, and the previous skills_dir_override path selection was removed. That makes tests that intentionally exercise committed repo skills (via .with_skills_dir(...)) impossible without manually copying files into the tempdir, and currently breaks tests/e2e_live_personas.rs. Either reintroduce a with_skills_dir-style override, or update those tests to populate temp_dir/path().join("skills") with the desired SKILL.md content before build.
        // 2. Build Config.
        let has_config_override = config_override.is_some();
        let skills_dir = temp_dir.path().join("skills");
        let installed_skills_dir = temp_dir.path().join("installed_skills");
        let _ = std::fs::create_dir_all(&skills_dir);
        let _ = std::fs::create_dir_all(&installed_skills_dir);
        let mut config = if let Some(mut cfg) = config_override {
            // Override database to use temp libSQL, but preserve agent/llm settings.
            cfg.database.backend = ironclaw::config::DatabaseBackend::LibSql;
            cfg.database.libsql_path = Some(db_path);
            cfg.skills.local_dir = skills_dir;
            cfg.skills.installed_dir = installed_skills_dir;
            cfg
        } else {
            Config::for_testing(db_path, skills_dir, installed_skills_dir)
        };
        config.agent.max_tool_iterations = max_tool_iterations;
        config.safety.injection_check_enabled = injection_check;
        config.skills.enabled = enable_skills;
        if let Some(v) = auto_approve_tools {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/support/live_harness.rs
Comment thread tests/support/live_harness.rs
Comment thread tests/support/test_rig.rs
…ismatch

The file_history snapshot/restore tests were failing on macOS because
`/var` is a symlink to `/private/var`. `snapshot()` stored the
original path (`/var/folders/.../code.rs`), but `execute()` called
`validate_path()` which canonicalizes to `/private/var/folders/...`.
The path comparison in `restore_latest` mismatched, returning "No
file history found" even though the snapshot existed.

Fix: canonicalize paths consistently at both the storage boundary
(`snapshot()`) and the lookup boundary (`latest_snapshot_for()`,
`snapshots_for()`, `restore_latest()`). A shared `canonical()` helper
handles the non-existent-file case (write_file's "new file" path)
by canonicalizing the parent directory and joining the filename —
the parent always exists even when the file itself doesn't yet.

This was a pre-existing staging failure from PR #2025 that affected
all macOS developers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the scope: tool/builtin Built-in tools label Apr 10, 2026
The PR changed `live_harness.rs` and `test_rig.rs` APIs without updating
`e2e_live_personas.rs`, causing Clippy compilation failures across all
feature sets. Also replaces `.expect()` in `rig_adapter.rs` with
`from_utf8_unchecked` (sound per `valid_up_to` invariant) to fix the
no-panics CI check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 10, 2026 20:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 28 out of 28 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/support/live_harness.rs
Comment thread src/tools/builtin/file_history.rs Outdated
Replace inline canonicalization logic in `snapshot()` with a call to the
existing `Self::canonical()` helper to eliminate duplication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: channel/wasm WASM channel runtime scope: db/postgres PostgreSQL backend scope: db Database trait / abstraction scope: docs Documentation scope: extensions Extension management scope: llm LLM integration scope: tool/builtin Built-in tools scope: tool/mcp MCP client scope: tool/wasm WASM tool sandbox scope: tool Tool infrastructure scope: workspace Persistent memory / workspace size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants