Skip to content

feat(debug-panel): expand Activity tab coverage with CodeAct + warnings - #2850

Merged
ilblackdragon merged 5 commits into
stagingfrom
debug-panel-activity-trace
Apr 24, 2026
Merged

ilblackdragon merged 5 commits into
stagingfrom
debug-panel-activity-trace

Conversation

@ilblackdragon

@ilblackdragon ilblackdragon commented Apr 22, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Emit AppEvent::CodeExecuted (verbose-only) and AppEvent::Warning so the debug panel's Activity tab can surface raw CodeAct code/stdout and WARN/ERROR logs that were previously only visible in server stdout.
  • Backfill params_summary on ActionExecuted/ActionFailed events from the structured and scripting executors so tool args are visible immediately, even on success. summarize_params hardens the http/web_fetch / shell branches and the generic MCP fallback so query-string secrets, signed-URL tokens, inline Authorization: headers, and sensitively-keyed MCP fields are stripped before they reach ToolCompleted.parameters.
  • Redact AppEvent::CodeExecuted code / stdout / return_value through LeakDetector at the bridge boundary before SSE broadcast, so a model-authored Python snippet that prints a bearer token or returns a credential-shaped JSON blob does not reach verbose subscribers verbatim. Covers both LeakAction::Redact and LeakAction::Block patterns (the latter were previously surfaced by scan() without redacting).
  • Byte-bound CodeExecuted payloads: code / stdout tail-truncated to 8 KiB via a UTF-8-boundary-safe helper; return_value strings tail-truncated, structured return_values <= 8 KiB preserved and larger ones dropped to None (truncated JSON would be unparseable on the frontend).
  • Wire the debug panel JS to render 20+ additional event types (code_executed, warning, gate lifecycle, plan updates, thread/child/mission spawning, onboarding, suggestions, sandbox jobs) through a shared on() wrapper that dedupes ~25 copies of the JSON-parse + reconnect-counter housekeeping.
  • i18n strings (en/ko/zh-CN) and CSS icon colors for the new activity types.
  • Multi-tenant safety: the warning bridge is disabled when multi_tenant_mode is true. The tracing layer captures log context at the global subscriber scope, not at request scope, so scoping the whole bridge to a single owner_id in multi-tenant mode would deliver tenant A's warnings to the admin/owner account instead of tenant A. Re-enabling the bridge in multi-tenant deployments is gated on per-request provenance being threaded through every warn! / error! call site — out of scope for this PR. Single-user gateways get the bridge unchanged.

Test plan

  • cargo fmt --check
  • cargo clippy --all --benches --tests --examples --all-features — clean
  • cargo test --lib — 5474 pass; one pre-existing MCP flake (extensions::manager::tests::inject_mcp_client_partitions_cache_by_user) reproducible on clean origin/staging, unrelated to this PR
  • Open the web debug panel, trigger a CodeAct run, a tool call, and an approval gate; verify each shows up in the Activity tab with parameters visible and no leaked secrets
  • Emit a tracing::warn! from a non-verbose code path; confirm it lands in the Activity tab only for verbose subscribers in single-user mode, and does not appear at all when multi_tenant_mode is on
  • Confirm a CodeAct snippet that prints a token or returns a large JSON blob renders with [REDACTED] / dropped return value rather than verbatim secrets or bloated payloads

🤖 Generated with Claude Code

The Activity tab was missing most event types: CodeAct runs showed only
lossy chat summaries, WARN/ERROR logs only landed in server stdout, and
tool entries hid their parameters on success.

- Emit AppEvent::CodeExecuted (verbose-only) with raw code, stdout, and
  return value from the engine orchestrator so observers see what the
  model actually wrote.
- Bridge WARN/ERROR tracing into AppEvent::Warning via
  spawn_warning_bridge, scoped by owner_id in multi-tenant mode to
  prevent cross-tenant log bleed.
- Backfill params_summary on ActionExecuted/ActionFailed events from
  structured + scripting executors so the Activity tab shows tool args
  immediately (not just on failure) without waiting for tool_completed.
- Wire debug-panel.js to render code_executed, warning, gate_required,
  gate_resolved, approval_needed, skill_activated, plan_update,
  thread_state_changed, child/mission_thread_spawned, onboarding_state,
  image_generated, suggestions, and the full sandbox-job event family.
- Extract shared on(name, handler) wrapper to dedupe ~25 copies of the
  JSON-parse + reconnect-counter housekeeping and keep lastEventTime
  bookkeeping consistent across listeners.
- Add i18n strings (en/ko/zh-CN) and CSS icon colors for the new
  activity types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 22, 2026 12:38
@github-actions github-actions Bot added scope: channel/web Web gateway channel size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 22, 2026

@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 significantly enhances the diagnostic capabilities of the debug inspector by introducing detailed execution traces and a server-side warning bridge. Key changes include the addition of CodeExecuted and Warning events, which allow the debug panel to surface raw code, stdout, and filtered log entries. The frontend has been refactored to use a more robust event-handling pattern and now provides comprehensive visibility into tool parameters, gate resolutions, and sandbox job lifecycles. Review feedback suggests extending the use of the new event-handling helper for consistency and ensuring that structured parameters in gate events are properly stringified for the UI.

Comment on lines +268 to +273
function on(name, handler) {
es.addEventListener(name, function (e) {
try { handler(JSON.parse(e.data)); } catch (_) { /* ignore */ }
lastEventTime = Date.now(); totalEventsReceived++;
});
}

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.

medium

The on helper function is a great addition for deduplicating SSE event handling. However, several existing handlers (e.g., status at line 275 and turn_cost at line 359) were not migrated to use it. To ensure consistent bookkeeping of lastEventTime and totalEventsReceived, and to fully realize the deduplication benefits mentioned in the PR description, these should also be refactored to use the on() wrapper where they parse JSON.

References
  1. Maintain style consistency with existing code and ensure new patterns are applied uniformly across the file.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in aae97be. Migrated the remaining JSON-parsing listeners — status, thinking, tool_started, tool_completed, tool_result, turn_cost, response, error, turn_metrics, tool_result_full — to use the on() wrapper. Left stream_chunk on the raw addEventListener path since it doesn't parse JSON, with a short comment explaining why.

Comment on lines +462 to +470
on('gate_required', function (data) {
var body = [
data.gate_name ? 'gate: ' + data.gate_name : '',
data.tool_name ? 'tool: ' + data.tool_name : '',
data.description || '',
data.parameters ? 'params: ' + data.parameters : '',
].filter(Boolean).join('\n');
addActivity('gate', t('debug.activityGateRequired'), timeNow(), 'pending', body, { labelKey: 'debug.activityGateRequired' });
});

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.

medium

In the gate_required and approval_needed handlers, data.parameters is appended to the body string. If the backend sends these as structured objects rather than pre-summarized strings, they will render as [object Object]. Consider using JSON.stringify for these fields, similar to how data.input is handled in the job_tool_use listener at line 567.

Suggested change
on('gate_required', function (data) {
var body = [
data.gate_name ? 'gate: ' + data.gate_name : '',
data.tool_name ? 'tool: ' + data.tool_name : '',
data.description || '',
data.parameters ? 'params: ' + data.parameters : '',
].filter(Boolean).join('\n');
addActivity('gate', t('debug.activityGateRequired'), timeNow(), 'pending', body, { labelKey: 'debug.activityGateRequired' });
});
on('gate_required', function (data) {
var params = typeof data.parameters === 'string' ? data.parameters : JSON.stringify(data.parameters, null, 2);
var body = [
data.gate_name ? 'gate: ' + data.gate_name : '',
data.tool_name ? 'tool: ' + data.tool_name : '',
data.description || '',
params ? 'params: ' + params : '',
].filter(Boolean).join('\n');
addActivity('gate', t('debug.activityGateRequired'), timeNow(), 'pending', body, { labelKey: 'debug.activityGateRequired' });
});
References
  1. Maintain style consistency with existing code. The suggestion uses 'var' to match the existing codebase style.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not changing — this looks like a false positive. The backend wire contract for both gate_required and approval_needed types parameters as a String (see crates/ironclaw_common/src/event.rs:267 for ApprovalNeeded and :298 for GateRequired), so the UI can safely string-concat. The data.input field on job_tool_use is genuinely an object (it's the Claude-bridge tool arguments), which is why that handler uses JSON.stringify — different wire shape.

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

Expands the web debug panel “Activity” tab by emitting and rendering additional backend events (notably CodeAct traces and WARN/ERROR log forwarding), and by improving tool-parameter visibility via params_summary propagation.

Changes:

  • Add new engine/common event variants (EventKind::CodeExecuted, AppEvent::{CodeExecuted, Warning}) and wire them into the bridge/SSE flow.
  • Populate params_summary in structured + scripting executors and surface it in tool activity entries.
  • Extend debug-panel frontend (JS/CSS + i18n) to display many more activity event types, with shared listener wiring.

Reviewed changes

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

Show a summary per file
File Description
tests/support/replay_outcome.rs Recognize CodeExecuted in event-kind naming for replay/testing utilities.
src/channels/web/mod.rs Start warning→SSE forwarding bridge during gateway startup (scoped in multi-tenant mode).
src/channels/web/log_layer.rs Add spawn_warning_bridge to forward WARN/ERROR logs into SSE as AppEvent::Warning.
src/bridge/router.rs Backfill tool parameters from params_summary and emit AppEvent::CodeExecuted for engine v2 thread events.
crates/ironclaw_gateway/static/i18n/en.js Add i18n strings for new Activity event types.
crates/ironclaw_gateway/static/i18n/ko.js Add i18n strings for new Activity event types.
crates/ironclaw_gateway/static/i18n/zh-CN.js Add i18n strings for new Activity event types.
crates/ironclaw_gateway/static/debug-panel.js Add unified on() listener wrapper; render CodeAct, warnings, gate lifecycle, plan/thread/job events; show params earlier.
crates/ironclaw_gateway/static/debug-panel.css Add icon colors for newly rendered activity types.
crates/ironclaw_engine/src/types/event.rs Introduce EventKind::CodeExecuted and retain CodeAct stdout/code for observers.
crates/ironclaw_engine/src/executor/structured.rs Compute and attach params_summary for tool call events (success + failure).
crates/ironclaw_engine/src/executor/scripting.rs Compute and attach params_summary for preflight-denied scripting tool calls.
crates/ironclaw_engine/src/executor/orchestrator.rs Emit EventKind::CodeExecuted after CodeAct execution (success or failure) with code/stdout.
crates/ironclaw_common/src/event.rs Add AppEvent::{CodeExecuted, Warning} and mark them verbose-only.

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

Comment on lines +324 to +340
let mut rx = broadcaster.subscribe();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(entry) => {
if entry.level != "WARN" && entry.level != "ERROR" {
continue;
}
let event = AppEvent::Warning {
source: entry.target,
message: entry.message,
thread_id: None,
};
match &owner_id {
Some(uid) => sse.broadcast_for_user(uid, event),
None => sse.broadcast(event),
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

spawn_warning_bridge broadcasts AppEvent::Warning (verbose-only), but it currently does so regardless of whether any verbose/debug SSE subscribers exist. Because the SSE broadcast channel is shared by all clients, flooding it with verbose-only warnings can still cause lag/reconnect pressure for non-verbose subscribers (they must drain and filter the stream). Consider checking sse.has_verbose_receivers() before constructing/broadcasting the event (and skipping work when false).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in aae97be. spawn_warning_bridge now short-circuits on sse.has_verbose_receivers() before constructing the AppEvent, so when no debug/admin subscriber is connected the warning is dropped instead of flowing through the shared SSE broadcast channel.

Comment thread src/bridge/router.rs
Comment on lines +4586 to +4598
EventKind::CodeExecuted {
code,
stdout,
return_value,
duration_ms,
..
} => vec![AppEvent::CodeExecuted {
code: code.clone(),
stdout: stdout.clone(),
return_value: return_value.clone(),
duration_ms: *duration_ms,
thread_id: Some(thread_id.into()),
}],

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

AppEvent::CodeExecuted is marked verbose-only, but this mapping eagerly clones potentially large code/stdout into an SSE event. In await_thread_outcome these AppEvents are broadcast unconditionally, so verbose-only CodeExecuted payloads can still fill the shared broadcast buffer and increase lag/reconnects for non-verbose subscribers. Consider gating generation/broadcast of this event on SseManager::has_verbose_receivers() (e.g., check before calling thread_event_to_app_events, or plumb an include_verbose flag) to avoid unnecessary cloning and broadcast load.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in aae97be. Added the gate inside await_thread_outcome — after thread_event_to_app_events returns, any AppEvent whose is_verbose_only() is true is skipped when sse.has_verbose_receivers() is false. Mirrors the existing send_status short-circuit. This covers CodeExecuted, Warning, and any future verbose-only variant without per-event plumbing. (And CodeExecuted's payload is now capped at 8_000 chars before the clone — see the other thread on orchestrator.rs.)

error: reason.clone(),
duration_ms: 0,
params_summary: None,
params_summary: crate::types::event::summarize_params(action_name, params),

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.

High: this new params_summary path looks like it can bypass our normal sensitive-param redaction. summarize_params() falls back to the first string value for unknown tools, and this PR now threads that summary into ActionFailed/ActionExecuted events that reach SSE/debug-panel. For MCP or built-in tools whose first string field is something like token, api_key, or password, we would now surface up to 50 chars here. Could we redact before summarizing (using tool.sensitive_params()) or restrict the fallback to an explicit allow-list of safe keys instead of obj.values().find_map(...)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in aae97be. summarize_params's generic fallback now filters out sensitive-looking keys (token/secret/password/api_key/auth/credential/bearer + _key suffix / standalone key) before picking the first string value, so MCP or unknown-tool calls with a secret as the leading field no longer surface it into ActionExecuted/ActionFailed events or debug-panel SSE. Went with a denylist rather than a hard allow-list so legitimate unknown-tool params still render a summary; the engine crate can't reach the host's Tool::sensitive_params() directly. Added two regression tests in types::event::tests.

EventKind::CodeExecuted {
step_id: exec_ctx.step_id,
code: code.clone(),
stdout: result.stdout.clone(),

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.

Medium: this clones the full stdout payload into ThreadEvent::CodeExecuted on every CodeAct run, even when there are no verbose/debug subscribers. Because there is no truncation/cap here, a step that prints a large blob can now bloat stored thread events and SSE payloads significantly. Was that retention cost intentional? I think this should either be capped before persistence or gated behind a verbose-only check so debug instrumentation does not become an unbounded storage/memory path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in aae97be. code and stdout are now tail-truncated to 8_000 chars (reusing the tail_chars helper already used by CodeExecutionFailed's error text, and matching scripting::OUTPUT_TRUNCATE_LEN) before the event is constructed, so persisted thread events and SSE payloads stay bounded regardless of subscriber count. The broadcast path is also gated on has_verbose_receivers() now (see the Copilot thread on router.rs) so non-debug tabs don't see the event at all.

data.resolution ? 'resolution: ' + data.resolution : '',
data.message || '',
].filter(Boolean).join('\n');
var status = STATUS_TO_ACTIVITY[data.resolution] || 'success';

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.

Medium: defaulting unknown gate_resolved values to 'success' looks wrong. The backend already emits resolution: "expired" when the target thread disappears during resume (src/bridge/router.rs), so that failure path will render as a green success badge in Activity. Could we mirror the neighboring handlers and use || null (or an explicit failure mapping for expired) instead of treating every unmapped resolution as success?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in aae97be. Added a dedicated GATE_RESOLUTION_STATUS map with explicit entries for the six known resolutions (approved/credential_provided/external_callback → success, denied/cancelled/expired → failure) and a fallback to null, so expired now renders as a failure and any future unknown resolution renders neutral instead of green. Kept STATUS_TO_ACTIVITY for jobs/plans/onboarding where success is a sensible default for completed/ready.

- summarize_params generic fallback: skip sensitive-looking parameter
  keys (token/secret/password/api_key/auth/credential/bearer) so MCP
  and unknown-tool calls can't surface secret values into
  ActionExecuted events or debug-panel SSE. Adds two regression tests.
- Cap CodeExecuted code/stdout at 8_000 chars (tail-last) before
  emission so a step that prints a large blob can't bloat persisted
  thread events. Matches the existing scripting OUTPUT_TRUNCATE_LEN.
- await_thread_outcome: skip broadcasting verbose-only AppEvents when
  no debug subscriber is connected — mirrors the send_status gate
  and keeps CodeExecuted off the shared SSE broadcast buffer for
  normal browser tabs.
- spawn_warning_bridge: same short-circuit on has_verbose_receivers.
- debug-panel.js: introduce GATE_RESOLUTION_STATUS so `expired`
  (a failure path from router.rs) no longer renders as a green
  success badge; shared STATUS_TO_ACTIVITY map is kept for jobs/
  plans/onboarding where `success` is the right default.
- debug-panel.js: migrate the remaining legacy listeners to the
  shared on() wrapper so lastEventTime / totalEventsReceived
  bookkeeping stays consistent across every activity listener.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/channels/web/mod.rs Outdated
// owner so per-request log context (thread ids, paths, emails)
// doesn't bleed across tenants via admin debug panels.
if let Some(log_broadcaster) = self.state.log_broadcaster.as_ref() {
let owner_scope = self

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.

High Severity

This scopes the warning bridge once at gateway startup to the static gateway owner_id, not to the user who triggered the warning-producing request. In multi-tenant mode that means WARN/ERROR log lines from tenant A's request can be delivered to the owner/admin account instead of tenant A, while the originating tenant never sees the warning in their own debug panel.

That avoids broadcasting warnings to everyone, but it still misroutes per-request diagnostics across accounts. I think this needs request/user-aware scoping at the point where the warning event is emitted (or the warning bridge should be disabled for multi-tenant mode until that provenance exists), rather than binding everything to the owner up front.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1d499f8. Disabled the warning bridge entirely when multi_tenant_mode is true — matching the second option you suggested ("disable for multi-tenant mode until provenance exists"). The tracing layer captures log context at the global subscriber scope, so there's no way to scope warnings per-request today without threading tenant provenance through every warn!/error! call site, which is well outside this PR. Single-user gateways still get the bridge with no owner_scope since there's only one consumer.

Comment thread src/bridge/router.rs
success: true,
error: None,
parameters: None,
parameters: params_summary.clone(),

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.

High Severity

Routing params_summary into ToolCompleted.parameters makes the existing summary policy user-visible on the debug SSE path, and that policy still returns raw http/web_fetch URLs and raw shell commands. That means a successful tool call can now expose query-string secrets, signed URLs, or inline auth headers (for example curl -H "Authorization: Bearer ...") directly in the debug panel.

I don't think the generic MCP-key filtering added in this PR is enough here, because these known-tool branches bypass it entirely. Please either redact these summaries before broadcasting them (for example with the leak detector / safety layer) or make the per-tool summaries strip sensitive parts such as URL query strings and auth-bearing shell arguments.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1d499f8. Tightened summarize_params per-tool branches in crates/ironclaw_engine/src/types/event.rs:

  • http / web_fetch: strip_url_sensitive_parts drops the query string, fragment, and user[:pass]@ userinfo before truncation, so signed URLs / query-string API keys / embedded credentials no longer reach ToolCompleted.parameters.
  • shell: redact_shell_command_for_display masks values of -H/--header/-u/--user/--token/--api-key/--password/--auth/--bearer (quoted or unquoted), masks Authorization: / X-Api-Key: / X-Auth-Token: / Bearer: values inside any argument, and strips query strings from any URL embedded in the command.

Kept this in the engine crate (no new dependency on ironclaw_safety) since the leak shape is specific to how these known-tool summaries are constructed. Regression tests: summarize_params_http_strips_query_string_and_userinfo, summarize_params_http_preserves_non_url_strings, summarize_params_shell_redacts_auth_headers_and_query_strings. The generic MCP-key filter from aae97be still covers the unknown-tool path.

serde_json::Value::Null => None,
other => Some(other.clone()),
};
let code_executed_event = ThreadEvent::new(

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.

Medium Severity

CodeExecuted now persists and broadcasts raw CodeAct code, stdout, and return_value, but unlike normal external tool output this path never goes through the safety/leak-detection layer before reaching SSE. A snippet that prints a secret, echoes sensitive tool output, or returns a credential-shaped JSON blob will therefore surface that raw material to every verbose subscriber and keep it in the thread event log as well.

Given the IronClaw convention that external output should cross a safety boundary before leaving the runtime, I think this event either needs redaction/scrubbing before emission or a narrower payload that can't carry arbitrary sensitive output verbatim.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1d499f8. AppEvent::CodeExecuted now passes through the leak detector at the bridge boundary before reaching SSE. Added SafetyLayer::leak_detector() and EffectBridgeAdapter::safety() accessors, then redact_code_executed_secrets in src/bridge/router.rs::await_thread_outcome scrubs code, stdout, and return_value (recursively for nested JSON) via a new redact_leaks_in_string helper.

One important subtlety: LeakDetector::scan_and_clean / scan().redacted_content only redact Redact-action matches, leaving Block-action matches (OpenAI/Anthropic/GitHub/AWS/Stripe/NEAR patterns — the majority of the default set) untouched. For a verbose-only observability event, keeping any leak-carrying field verbatim is never appropriate, so the helper iterates scan().matches directly and redacts every match range regardless of action. Regression test redact_code_executed_scrubs_secrets_from_code_stdout_and_return_value covers the code / stdout / nested-JSON-return-value cases with a GitHub-token pattern. Keeping the engine crate safety-agnostic (no ironclaw_safety dep) — scrubbing stays at the adapter boundary where the rest of the safety pipeline already lives.

…ty-trace

# Conflicts:
#	crates/ironclaw_common/src/event.rs
#	crates/ironclaw_gateway/static/debug-panel.js
#	src/bridge/router.rs
…scoping

- Warning bridge (`src/channels/web/mod.rs`): disable entirely in
  multi-tenant mode. The `tracing` layer captures log context at the
  global subscriber scope, not at request scope, so scoping the bridge
  to the gateway `owner_id` misroutes tenant A's WARN/ERROR log lines
  to the admin account (and prevents tenant A from ever seeing them).
  Per-request provenance would need threading through every `warn!` /
  `error!` call site — out of scope for this PR — so the safe move is
  to keep the bridge off until that lands.
- `summarize_params` (`crates/ironclaw_engine/src/types/event.rs`):
  strip URL query strings / fragments / userinfo for `http` and
  `web_fetch`, and redact auth-bearing flag values (`-H`, `--header`,
  `-u`, `--user`, `--token`, `--api-key`, `--password`, `--auth`,
  `--bearer`) plus embedded URL query strings inside `shell` commands.
  Signed URLs, inline `Authorization: Bearer …` headers, and query-
  string API keys no longer reach `ToolCompleted.parameters` on the
  debug SSE stream. Six regression tests added.
- `CodeExecuted` redaction (`src/bridge/router.rs`): apply the leak
  detector to `code` / `stdout` / `return_value` at the bridge
  boundary before SSE broadcast. The engine crate has no dependency on
  `ironclaw_safety`, so scrubbing lives here. Adds
  `SafetyLayer::leak_detector()` and `EffectBridgeAdapter::safety()`
  accessors. Handles both `Redact` and `Block`-action matches
  (scan_and_clean's `redacted_content` is `None` for Block-only
  matches, which would have passed bearer tokens / API keys through
  unchanged). Regression test covers string and nested-JSON cases.
Copilot AI review requested due to automatic review settings April 23, 2026 17:42

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 16 out of 16 changed files in this pull request and generated 3 comments.


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

Comment thread src/channels/web/mod.rs
Comment on lines +671 to +678
if self.state.multi_tenant_mode {
tracing::debug!(
"warning bridge disabled in multi-tenant mode: \
WARN/ERROR log forwarding to debug panel requires \
per-request tenant provenance that is not yet \
wired through the tracing layer"
);
} else {

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

The PR description says the warning bridge is tenant-scoped in multi-tenant mode, but this code disables the bridge entirely when multi_tenant_mode is true (and therefore no AppEvent::Warning will ever reach the debug panel in that configuration). Please either update the PR description/test plan to match this behavior, or implement the described tenant-scoped forwarding once per-request provenance is available.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — PR description updated to state the warning bridge is disabled (not tenant-scoped) in multi-tenant mode. Both the Summary bullet and the Test plan's multi-tenant check now reflect the actual behavior shipped in 1d499f8. Re-enabling the bridge in multi-tenant deployments is blocked on per-request provenance being threaded through every warn!/error! call site, which is outside this PR's scope.

Comment thread src/channels/web/log_layer.rs Outdated
Comment on lines +345 to +346
Some(uid) => sse.broadcast_for_user(uid, event),
None => sse.broadcast(event),

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

New sse.broadcast* call sites are subject to the repo’s PROJECTION safety check (scripts/pre-commit-safety.sh). These added broadcast_for_user / broadcast lines need an inline // projection-exempt: <category>, <detail> annotation (or be routed through the projection bridge) to avoid failing the safety gate.

Suggested change
Some(uid) => sse.broadcast_for_user(uid, event),
None => sse.broadcast(event),
Some(uid) => sse.broadcast_for_user(uid, event), // projection-exempt: logs, scrubbed warning event for verbose SSE delivery
None => sse.broadcast(event), // projection-exempt: logs, scrubbed warning event for verbose SSE delivery

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 9f43803. Both sse.broadcast_for_user and sse.broadcast sites in spawn_warning_bridge now carry // projection-exempt: log source, WARN/ERROR tracing bridge → AppEvent::Warning annotations — the warning bridge is a typed source log in the sense of .claude/rules/gateway-events.md (every AppEvent::Warning on SSE projects from exactly one LogEntry produced by WebLogLayer), but that source log isn't yet in the rule's fixed table of three. Added a comment block above the match explaining why the annotation is there. Pre-commit safety script now passes clean on this branch.

Comment on lines +906 to +920
// Cap code and stdout at `CODE_EXECUTED_MAX_CHARS` each before
// emission so a step that prints a large blob cannot bloat
// persisted thread events or flood the SSE broadcast buffer.
// Same cap as `scripting::OUTPUT_TRUNCATE_LEN` (8_000).
const CODE_EXECUTED_MAX_CHARS: usize = 8_000;
let return_value = match &result.return_value {
serde_json::Value::Null => None,
other => Some(other.clone()),
};
let code_executed_event = ThreadEvent::new(
thread.id,
EventKind::CodeExecuted {
step_id: exec_ctx.step_id,
code: tail_chars(&code, CODE_EXECUTED_MAX_CHARS),
stdout: tail_chars(&result.stdout, CODE_EXECUTED_MAX_CHARS),

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

CodeExecuted is intended to be size-bounded, but tail_chars currently walks the entire string (chars().count()), so very large code/stdout payloads still incur O(n) work. Also return_value is cloned without any size cap, so it can still bloat persisted events/SSE frames. Consider truncating from the end using byte indices + UTF-8 boundary checks, and applying a similar size limit (or omission) for return_value.

Suggested change
// Cap code and stdout at `CODE_EXECUTED_MAX_CHARS` each before
// emission so a step that prints a large blob cannot bloat
// persisted thread events or flood the SSE broadcast buffer.
// Same cap as `scripting::OUTPUT_TRUNCATE_LEN` (8_000).
const CODE_EXECUTED_MAX_CHARS: usize = 8_000;
let return_value = match &result.return_value {
serde_json::Value::Null => None,
other => Some(other.clone()),
};
let code_executed_event = ThreadEvent::new(
thread.id,
EventKind::CodeExecuted {
step_id: exec_ctx.step_id,
code: tail_chars(&code, CODE_EXECUTED_MAX_CHARS),
stdout: tail_chars(&result.stdout, CODE_EXECUTED_MAX_CHARS),
// Cap code/stdout and return_value before emission so a step that
// produces a large payload cannot bloat persisted thread events or
// flood the SSE broadcast buffer.
// Same cap as `scripting::OUTPUT_TRUNCATE_LEN` (8_000).
fn tail_utf8_bytes(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_owned();
}
let mut start = s.len() - max_bytes;
while start < s.len() && !s.is_char_boundary(start) {
start += 1;
}
s[start..].to_owned()
}
fn bounded_return_value(
value: &serde_json::Value,
max_bytes: usize,
) -> Option<serde_json::Value> {
match value {
serde_json::Value::Null => None,
serde_json::Value::String(s) => {
Some(serde_json::Value::String(tail_utf8_bytes(s, max_bytes)))
}
other => match serde_json::to_vec(other) {
Ok(buf) if buf.len() <= max_bytes => Some(other.clone()),
_ => None,
},
}
}
const CODE_EXECUTED_MAX_BYTES: usize = 8_000;
let return_value =
bounded_return_value(&result.return_value, CODE_EXECUTED_MAX_BYTES);
let code_executed_event = ThreadEvent::new(
thread.id,
EventKind::CodeExecuted {
step_id: exec_ctx.step_id,
code: tail_utf8_bytes(&code, CODE_EXECUTED_MAX_BYTES),
stdout: tail_utf8_bytes(&result.stdout, CODE_EXECUTED_MAX_BYTES),

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 9f43803. At the CodeExecuted emission site, tail_chars is replaced with:

  • tail_utf8_bytes(s, max_bytes) — byte-based, O(1) start + ≤3-byte UTF-8 boundary walk. Used for code and stdout.
  • bounded_return_value(v, max_bytes) — Null ⇒ None, String ⇒ tail-truncated, structured Array/Object/Number/Bool serialized and either preserved intact if ≤ max_bytes or dropped to None. Truncating arbitrary JSON would be unparseable on the frontend so dropping is the conservative choice — observers see None and know the payload was omitted for size.

tail_chars is kept for its other callers (OUTPUT_TRUNCATE_LEN / 500-char error slices) whose inputs are already pre-bounded and where chars-vs-bytes semantics matter for character-count messaging. Seven regression tests added covering ASCII, multi-byte emoji boundary, Null, small struct, oversized struct, and large-string paths.

@ilblackdragon

Copy link
Copy Markdown
Member Author

Code Review — PR #2850 feat(debug-panel): expand Activity tab coverage with CodeAct + warnings

Scope (4 branch commits on top of origin/staging, +1074/−160 across 16 files): surfaces raw CodeAct code/stdout and WARN/ERROR log lines in the debug panel's Activity tab, backfills params_summary on engine action events, and refactors ~25 SSE listeners in debug-panel.js through a shared on() wrapper.

State: MERGEABLE but BLOCKED (likely required checks, not conflicts). 3 review threads resolved in aae97be7, 3 follow-up threads resolved in 1d499f88.

Overview

  • New wire events AppEvent::CodeExecuted + AppEvent::Warning (both is_verbose_only) plus engine EventKind::CodeExecuted.
  • Orchestrator emits CodeExecuted with code/stdout tail-truncated to 8k chars (matches scripting::OUTPUT_TRUNCATE_LEN).
  • Structured + scripting executors populate params_summary on success and failure paths.
  • Bridge router gates verbose events on has_verbose_receivers() before broadcast and pipes CodeExecuted payloads through LeakDetector at the bridge boundary.
  • summarize_params hardens http/web_fetch/shell branches against URL-query/userinfo/auth-flag leakage, plus denylists sensitive keys on the generic fallback.
  • Frontend adopts shared on() wrapper, adds GATE_RESOLUTION_STATUS table, new handlers for code_executed/warning/lease/plan/mission/onboarding/etc.
  • Warning bridge is disabled in multi-tenant mode (pragma until per-request tenant provenance exists).

Correctness

  • CodeExecuted redaction at the right layer — engine crate stays safety-agnostic; scrubbing lives in src/bridge/router.rs::redact_code_executed_secrets via SafetyLayer::leak_detector() (new accessor) + EffectBridgeAdapter::safety(). Good layering.
  • redact_leaks_in_string covers all actions. Important subtlety is correct: scan().redacted_content is populated only for LeakAction::Redact, leaving Block patterns (OpenAI/Anthropic/GitHub/AWS/Stripe — the majority of defaults) unredacted. Iterating scan().matches directly fixes that. The overlap-skip (range.start < cursor) is sound.
  • strip_url_sensitive_parts is str::find-based, not url-crate-based. Conservative and safe: non-URL strings ("not-really-a-url") pass through unchanged. Handles ? before #, strips userinfo via authority.rfind('@'). Edge case: if ? comes before the first / (e.g. https://host?x=1), the code splits end on / and treats the whole host?… as authority — which then hits rfind('@') cleanly; still correct because there is no @. ✔
  • redact_shell_command_for_display uses 4 ordered regexes on a OnceLock. .unwrap() on literal patterns with // safety: hardcoded literal matches the existing codebase convention (see leak_detector.rs default patterns).
  • EventKind::CodeExecuted already tail-truncated to 8k upstream (orchestrator.rs), so the SSE event bounds are enforced regardless of subscriber count. ✔
  • is_sensitive_param_key denylist is substring-based. "refresh_key" matches via ends_with("_key"); "token" matches "refresh_token" via contains("token"). ✔
  • params_summary backfill is symmetric across success/failure/preflight/rejected in structured and scripting — so the debug panel shows args on every event, not only some.

Potential issues

  • Frontend JSON-parse silence on on() wrapper. The new wrapper swallows JSON parse errors with /* ignore */ but still bumps totalEventsReceived. That's intentional (matches pre-refactor behavior) but will quietly count malformed frames. Low-severity; no action unless you want parse errors surfaced in the stats panel.
  • redact_secrets_in_json recurses into object values but never inspects keys. A return value like {"password": "hunter2"} is not redacted — the leak-detector only catches pattern-shaped secrets (bearer tokens, known API keys), not arbitrary string values under sensitive-named keys. Worth flagging in follow-up: consider extending the JSON walk to apply is_sensitive_param_key to keys as well.
  • EventKind::CodeExecuted.code is tail-truncated, not head-truncated. A long secret-bearing preamble (token = 'xxx…' at line 1) could be dropped while only the tail is kept. In practice leak-detector runs on the already-truncated string in the bridge; the 8k cap is a legitimate trade-off between bounded memory and complete leak scanning. Not blocking.
  • Multi-tenant warning bridge is fully disabled. The PR body still mentions multi-tenant scoping; the follow-up commit reverses that to "off in multi-tenant". PR body should be updated so readers don't chase a feature that's no longer there.

Style / conventions

  • ✔ crate:: imports preferred; no super:: in cross-module references outside tests.
  • ✔ No .unwrap()/.expect() in non-regex production code; regex .unwrap() is annotated per leak_detector.rs convention.
  • ✔ thiserror-family error handling unchanged (new code doesn't introduce error types).
  • ✔ Logging levels: only tracing::debug! used (the multi-tenant notice). No info! / warn! added that could corrupt REPL.
  • Minor: strip_url_sensitive_parts — the .map(...).map(...) chain using the first map only for its (i, marker) tuple is a little over-fluent; a bare if let Some(i) = … block would read more cleanly.
  • redact_shell_command_for_display strips URL query strings regardless of shell context — if shell is invoked with a benign public URL, the summary shows https://example.com/?… instead of the full path. Users might lose a diagnostic signal. Arguable trade-off; leaves no secrets behind, which is the conservative choice.

Test coverage

  • ✔ 6 regression tests in types::event::tests covering: generic fallback sensitive-key skip, http URL strip (query/userinfo/fragment + non-URL passthrough), shell auth-flag redaction, URL-in-shell query strip.
  • ✔ 1 regression test in bridge::router::tests::redact_code_executed_scrubs_secrets_from_code_stdout_and_return_value covering code + stdout + nested JSON with a GitHub-token pattern.
  • ✔ event_type_matches_serde_type_field updated to include the two new AppEvent variants.
  • Gap — frontend: new GATE_RESOLUTION_STATUS / STATUS_TO_ACTIVITY tables and on() wrapper lack JS unit/e2e coverage. Per .claude/rules/testing.md "Test Through the Caller, Not Just the Helper" this is below the bar for a helper change but would be worthwhile for the expired → failure regression the reviewer called out.
  • Gap — multi-tenant disable: no automated coverage that spawn_warning_bridge is not called when multi_tenant_mode=true. A one-line harness test on GatewayChannel::start with the flag set would lock the behavior.

Security

  • ✔ Three leak vectors the reviewer flagged are now closed: URL-query/userinfo, shell auth-flags, CodeExecuted stdout. Each has a regression test.
  • ✔ Size bounded: 8k-char cap on code/stdout prevents unbounded storage/broadcast.
  • ✔ Verbose-only gating: non-debug SSE subscribers don't receive these payloads at all; debug subscribers get redacted content.
  • ✔ Multi-tenant misrouting closed: disabled entirely for multi-tenant; single-user is unchanged.
  • Residual risk (minor): redact_shell_command_for_display regex family doesn't cover every possible leak shape. A shell command with an inline literal matching a known-key pattern bypasses the four regexes and is not re-scanned by LeakDetector because summaries are produced in the engine crate (which has no safety dep) before the bridge leak-detector pass. For shell, leak-detector coverage would be an additional defense layer. Non-blocking; suggest follow-up.
  • Residual risk (minor): redact_secrets_in_json recurses string-by-string through the return_value tree; cost is O(n × patterns). Bounded because return_value itself came through the 8k cap, but worth noting.

Performance

  • OnceLock<[Regex; 4]> avoids recompilation per call — good hot-path hygiene.
  • Leak detector's Aho-Corasick prefix matcher short-circuits most calls; cost for clean strings is near-zero.

Recommendations

  1. Update the PR body to reflect that the multi-tenant warning bridge is off, not scoped. (Non-blocking, doc accuracy.)
  2. Consider extending redact_secrets_in_json to apply is_sensitive_param_key to object keys as well — the denylist in the engine crate doesn't help JSON returns from CodeAct. (Non-blocking; follow-up issue.)
  3. Add a harness test in src/channels/web/tests.rs that asserts spawn_warning_bridge is skipped when multi_tenant_mode is true. Locks the current contract. (Non-blocking.)
  4. The one pre-existing flaky test (extensions::manager::tests::inject_mcp_client_partitions_cache_by_user) is unrelated to this PR but worth a follow-up issue if one doesn't exist already.

Overall: ✅ Approve. Three reviewer-flagged security issues addressed with matching regression tests; behavior changes are carefully bounded and well-documented. The small follow-ups above can ship in a separate PR.

- `src/channels/web/log_layer.rs`: annotate `spawn_warning_bridge`'s
  `sse.broadcast_for_user` / `sse.broadcast` sites with
  `// projection-exempt: log source, WARN/ERROR tracing bridge →
  AppEvent::Warning` so the PROJECTION safety check (#9 in
  `scripts/pre-commit-safety.sh`) recognises the tracing
  `LogBroadcaster` as a typed source log. Added a comment block
  explaining why the source-log category isn't yet in
  `.claude/rules/gateway-events.md`'s table.
- `crates/ironclaw_engine/src/executor/orchestrator.rs`: replace
  `tail_chars` (O(n) via `chars().count()`) with a local
  `tail_utf8_bytes` helper for the `CodeExecuted` emission path. Byte
  based so it stays O(1) + ≤3-byte UTF-8 boundary walk for arbitrarily
  large `code`/`stdout`. Also add `bounded_return_value` so a CodeAct
  snippet returning a 50 MB JSON value doesn't bloat persisted thread
  events — strings are tail-truncated; structured values that
  serialize past 8 KiB are dropped to `None` (rather than truncated
  into unparseable JSON). Seven regression tests cover ASCII / emoji
  boundary / null / small struct / oversized struct / large-string
  paths.

`tail_chars` is kept unchanged for its existing callers, whose inputs
are already bounded (`OUTPUT_TRUNCATE_LEN`, 500-char error slices).
@ilblackdragon
ilblackdragon merged commit eb75a62 into staging Apr 24, 2026
18 checks passed
@ilblackdragon
ilblackdragon deleted the debug-panel-activity-trace branch April 24, 2026 04:48
@ironclaw-ci ironclaw-ci Bot mentioned this pull request Apr 29, 2026
@ironclaw-ci ironclaw-ci Bot mentioned this pull request May 7, 2026
This was referenced May 7, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…gs (nearai#2850)

* feat(debug-panel): expand Activity tab coverage with CodeAct + warnings

The Activity tab was missing most event types: CodeAct runs showed only
lossy chat summaries, WARN/ERROR logs only landed in server stdout, and
tool entries hid their parameters on success.

- Emit AppEvent::CodeExecuted (verbose-only) with raw code, stdout, and
  return value from the engine orchestrator so observers see what the
  model actually wrote.
- Bridge WARN/ERROR tracing into AppEvent::Warning via
  spawn_warning_bridge, scoped by owner_id in multi-tenant mode to
  prevent cross-tenant log bleed.
- Backfill params_summary on ActionExecuted/ActionFailed events from
  structured + scripting executors so the Activity tab shows tool args
  immediately (not just on failure) without waiting for tool_completed.
- Wire debug-panel.js to render code_executed, warning, gate_required,
  gate_resolved, approval_needed, skill_activated, plan_update,
  thread_state_changed, child/mission_thread_spawned, onboarding_state,
  image_generated, suggestions, and the full sandbox-job event family.
- Extract shared on(name, handler) wrapper to dedupe ~25 copies of the
  JSON-parse + reconnect-counter housekeeping and keep lastEventTime
  bookkeeping consistent across listeners.
- Add i18n strings (en/ko/zh-CN) and CSS icon colors for the new
  activity types.

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

* fix(debug-panel): address review feedback on activity-trace PR

- summarize_params generic fallback: skip sensitive-looking parameter
  keys (token/secret/password/api_key/auth/credential/bearer) so MCP
  and unknown-tool calls can't surface secret values into
  ActionExecuted events or debug-panel SSE. Adds two regression tests.
- Cap CodeExecuted code/stdout at 8_000 chars (tail-last) before
  emission so a step that prints a large blob can't bloat persisted
  thread events. Matches the existing scripting OUTPUT_TRUNCATE_LEN.
- await_thread_outcome: skip broadcasting verbose-only AppEvents when
  no debug subscriber is connected — mirrors the send_status gate
  and keeps CodeExecuted off the shared SSE broadcast buffer for
  normal browser tabs.
- spawn_warning_bridge: same short-circuit on has_verbose_receivers.
- debug-panel.js: introduce GATE_RESOLUTION_STATUS so `expired`
  (a failure path from router.rs) no longer renders as a green
  success badge; shared STATUS_TO_ACTIVITY map is kept for jobs/
  plans/onboarding where `success` is the right default.
- debug-panel.js: migrate the remaining legacy listeners to the
  shared on() wrapper so lastEventTime / totalEventsReceived
  bookkeeping stays consistent across every activity listener.

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

* fix(debug-panel): address PR nearai#2850 follow-up review on leak / tenant scoping

- Warning bridge (`src/channels/web/mod.rs`): disable entirely in
  multi-tenant mode. The `tracing` layer captures log context at the
  global subscriber scope, not at request scope, so scoping the bridge
  to the gateway `owner_id` misroutes tenant A's WARN/ERROR log lines
  to the admin account (and prevents tenant A from ever seeing them).
  Per-request provenance would need threading through every `warn!` /
  `error!` call site — out of scope for this PR — so the safe move is
  to keep the bridge off until that lands.
- `summarize_params` (`crates/ironclaw_engine/src/types/event.rs`):
  strip URL query strings / fragments / userinfo for `http` and
  `web_fetch`, and redact auth-bearing flag values (`-H`, `--header`,
  `-u`, `--user`, `--token`, `--api-key`, `--password`, `--auth`,
  `--bearer`) plus embedded URL query strings inside `shell` commands.
  Signed URLs, inline `Authorization: Bearer …` headers, and query-
  string API keys no longer reach `ToolCompleted.parameters` on the
  debug SSE stream. Six regression tests added.
- `CodeExecuted` redaction (`src/bridge/router.rs`): apply the leak
  detector to `code` / `stdout` / `return_value` at the bridge
  boundary before SSE broadcast. The engine crate has no dependency on
  `ironclaw_safety`, so scrubbing lives here. Adds
  `SafetyLayer::leak_detector()` and `EffectBridgeAdapter::safety()`
  accessors. Handles both `Redact` and `Block`-action matches
  (scan_and_clean's `redacted_content` is `None` for Block-only
  matches, which would have passed bearer tokens / API keys through
  unchanged). Regression test covers string and nested-JSON cases.

* fix(debug-panel): address PR nearai#2850 Copilot follow-up review

- `src/channels/web/log_layer.rs`: annotate `spawn_warning_bridge`'s
  `sse.broadcast_for_user` / `sse.broadcast` sites with
  `// projection-exempt: log source, WARN/ERROR tracing bridge →
  AppEvent::Warning` so the PROJECTION safety check (#9 in
  `scripts/pre-commit-safety.sh`) recognises the tracing
  `LogBroadcaster` as a typed source log. Added a comment block
  explaining why the source-log category isn't yet in
  `.claude/rules/gateway-events.md`'s table.
- `crates/ironclaw_engine/src/executor/orchestrator.rs`: replace
  `tail_chars` (O(n) via `chars().count()`) with a local
  `tail_utf8_bytes` helper for the `CodeExecuted` emission path. Byte
  based so it stays O(1) + ≤3-byte UTF-8 boundary walk for arbitrarily
  large `code`/`stdout`. Also add `bounded_return_value` so a CodeAct
  snippet returning a 50 MB JSON value doesn't bloat persisted thread
  events — strings are tail-truncated; structured values that
  serialize past 8 KiB are dropped to `None` (rather than truncated
  into unparseable JSON). Seven regression tests cover ASCII / emoji
  boundary / null / small struct / oversized struct / large-string
  paths.

`tail_chars` is kept unchanged for its existing callers, whose inputs
are already bounded (`OUTPUT_TRUNCATE_LEN`, 500-char error slices).

---------

Co-authored-by: Claude Opus 4.7 (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/web Web gateway channel size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants