Skip to content

feat(messaging): add Signal adapter via signal-cli JSON-RPC daemon#347

Open
ibhagwan wants to merge 17 commits intospacedriveapp:mainfrom
ibhagwan:feat/signal-adapter
Open

feat(messaging): add Signal adapter via signal-cli JSON-RPC daemon#347
ibhagwan wants to merge 17 commits intospacedriveapp:mainfrom
ibhagwan:feat/signal-adapter

Conversation

@ibhagwan
Copy link

@ibhagwan ibhagwan commented Mar 7, 2026

Implements Signal messaging support using the signal-cli daemon HTTP API,
following the existing adapter architecture (Telegram, Discord, Slack, Twitch).

  • Inbound: SSE stream with automatic reconnection and exponential backoff
    (2s → 60s), UTF-8 chunk boundary handling, buffer overflow protection.
  • Outbound: JSON-RPC send calls. DM recipients must be a JSON array.
  • Typing indicators: JSON-RPC sendTyping with ~5s expiry.
  • Attachments: Temp files in {instance_dir}/tmp/, auto-cleaned after send.
  • Streaming: Not supported (Signal can't edit messages).
  • Permissions: DM allowlist + group filter (None = block all groups).

Config types in types.rs, toml_schema.rs, load.rs. SignalPermissions in
permissions.rs with from_config/from_instance_config. Hot-reload support in
watcher.rs. 23 unit tests.

Add to config.toml:

[messaging.signal]
enabled = true
http_url = "http://127.0.0.1:8686/"
account = "+1234567890"
dm_allowed_users = ["+0987654321"]
group_ids = ["<uuid1>", "<uuid2>"]
group_allowed_users = ["+5566778899", "+1122334455"]
ignore_stories = true

[[messaging.signal.instances]]
name = "work"
enabled = true
http_url = "http://127.0.0.1:8687/"
account = "+1122334455"
dm_allowed_users = ["+5566778899"]
group_ids = ["<uuid1>", "<uuid2>"]
group_allowed_users = ["+1122334455"]

Requires signal-cli daemon running: signal-cli daemon --http

Closes #310

Note

Summary: Complete Signal adapter implementation (1953 lines) following the existing messaging adapter pattern. Adds SSE-based inbound stream with exponential backoff reconnection, JSON-RPC outbound messaging, typing indicators, file attachment handling, and fine-grained permission controls (DM allowlist + group filters). Configuration schema extended for Signal with multi-instance support. Includes 23 unit tests covering JSON parsing, permissions, and SSE edge cases. No changes to core agent or Rig integration—purely additive messaging module.

Written by Tembo for commit 83b8f72. This will update automatically on new commits.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds first-class Signal support: a new Signal messaging adapter, config/toml schema and permissions, binding/group ACLs, prompt fragments, target parsing/normalization, tooling/agent wiring to propagate per-message adapter context, startup/watcher hot-reload integration, and API/binding extensions.

Changes

Cohort / File(s) Summary
Signal Adapter Core
src/messaging/signal.rs
New Signal adapter implementing SSE inbound, JSON‑RPC outbound (send text/files), typing indicators, permission checks, health/shutdown, metadata/routing, and extensive tests.
Config Schema & Types
src/config/types.rs, src/config/toml_schema.rs, src/config/load.rs, src/config.rs
Introduce SignalConfig/SignalInstanceConfig/Toml variants, wire signal into MessagingConfig, add group_ids and group_allowed_users to Binding, secrets and per-instance env resolution.
Permissions
src/config/permissions.rs
Add SignalPermissions, constructors from config/instance, binding aggregation logic, base64 validation helper, and unit tests.
Watcher / Startup / Main
src/config/watcher.rs, src/main.rs
Hot-start and hot-reload wiring for Signal adapters and permissions; propagate signal_permissions through watcher and agent initialization (function signatures updated).
Messaging Targeting & Normalization
src/messaging/target.rs
Add parsing/normalization for signal: targets (uuid, group, e164/phone), normalize_signal_target and parse_signal_target_parts, tests, and integrate into broadcast resolution.
Agent / Channel / Tools Integration
src/agent/channel.rs, src/agent/channel_history.rs, src/tools.rs, src/tools/send_message_to_another_channel.rs
Thread per-message adapter into agent/tool flows (run_agent_turn signature changed), add sender_context into history formatting, add current_adapter to SendMessageTool and explicit Signal target parsing/handling.
Prompts & Prompt Engine
prompts/en/adapters/signal.md.j2, prompts/en/tools/send_message_description.md.j2, src/prompts/engine.rs, src/prompts/text.rs
Add Signal prompt fragments and register adapter fragment for prompt rendering and tool descriptions.
API / Bindings
src/api/bindings.rs
Extend BindingResponse, CreateBindingRequest, and UpdateBindingRequest to include group_ids and group_allowed_users, persist them to config TOML when present.
Messaging Module Export
src/messaging.rs
Export new pub mod signal and update module docs.
Secrets & Misc / Minor Cleanups
src/secrets/store.rs, src/secrets/scrub.rs, src/llm/model.rs, src/conversation/worker_transcript.rs, tests/context_dump.rs, src/api/projects.rs
Register Signal secrets in system registry, small sort refactors to use sort_by_key, pattern-match guard simplifications, test call-site updates (add_channel_tools extra arg), and small formatting/cleanup changes.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • jamiepine
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(messaging): add Signal adapter via signal-cli JSON-RPC daemon' accurately summarizes the main change—a new Signal messaging adapter implementation.
Description check ✅ Passed The description is directly related to the changeset, detailing Signal adapter implementation, configuration, architecture, and testing.
Linked Issues check ✅ Passed The PR fully addresses issue #310's request for Signal channel support via signal-cli daemon, implementing SSE inbound streaming, JSON-RPC outbound messaging, permissions, and configuration schema.
Out of Scope Changes check ✅ Passed All changes are within scope of Signal adapter implementation. Minor refactors (match guards, sorting) in unrelated files are incidental code improvements consistent with the feature.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jamiepine
Copy link
Member

Woah! Thank you, huge.

@ibhagwan
Copy link
Author

ibhagwan commented Mar 7, 2026

Ty @jamiepine for this wonderful project, although lesser known ATM I believe this one has huge potential, I've been personally testing every decent project out there and so far I like this one and IronClaw the most.

Btw, this isn't my first signal support PR, I've added the same to ZeroClaw, IronClaw, NullClaw and Hermes-agent:
zeroclaw-labs/zeroclaw#468
nearai/ironclaw#271
nullclaw/nullclaw#63
NousResearch/hermes-agent#268

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/secrets/store.rs (1)

1103-1119: ⚠️ Potential issue | 🟠 Major

Signal secret registration is still incomplete.

This only helps for whatever SignalConfig::secret_fields() exposes, and that implementation currently declares http_url but not account. As a result, SIGNAL_ACCOUNT and SIGNAL_<INSTANCE>_ACCOUNT will still auto-categorize as Tool secrets and can leak into worker env vars.

🧩 Required follow-up in src/config/types.rs
 fn secret_fields() -> &'static [SecretField] {
-    &[SecretField {
-        toml_key: "http_url",
-        secret_name: "SIGNAL_HTTP_URL",
-        instance_pattern: Some(InstancePattern {
-            platform_prefix: "SIGNAL",
-            field_suffix: "HTTP_URL",
-        }),
-    }]
+    &[
+        SecretField {
+            toml_key: "http_url",
+            secret_name: "SIGNAL_HTTP_URL",
+            instance_pattern: Some(InstancePattern {
+                platform_prefix: "SIGNAL",
+                field_suffix: "HTTP_URL",
+            }),
+        },
+        SecretField {
+            toml_key: "account",
+            secret_name: "SIGNAL_ACCOUNT",
+            instance_pattern: Some(InstancePattern {
+                platform_prefix: "SIGNAL",
+                field_suffix: "ACCOUNT",
+            }),
+        },
+    ]
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/secrets/store.rs` around lines 1103 - 1119, Signal secret registration
misses the Signal account key: update SignalConfig::secret_fields (in the
SignalConfig implementation in src/config/types.rs) to include the account field
alongside http_url so SIGNAL_ACCOUNT and SIGNAL_<INSTANCE>_ACCOUNT are treated
as secret; modify the SignalConfig struct/impl to return the account secret name
(matching naming used elsewhere for instance-scoped secrets) in secret_fields so
the secrets store registers it correctly.
src/agent/channel.rs (1)

1789-1800: ⚠️ Potential issue | 🟠 Major

This still drops named-instance context before it reaches SendMessageTool.

self.current_adapter() only derives from source_adapter / conversation_id, and both are populated from the base source (message.source). A conversation running on signal:support therefore still passes just signal here, so explicit Signal sends can escape onto the default instance instead of the active one. Capture and prefer InboundMessage.adapter when establishing the channel adapter context.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel.rs` around lines 1789 - 1800, The code currently passes
self.current_adapter() into add_channel_tools which is derived from
source_adapter/conversation_id and can lose named-instance context; change the
adapter argument to prefer the InboundMessage.adapter (or equivalent inbound
message field available in this scope) when present, falling back to
self.current_adapter() only if InboundMessage.adapter is None, so the channel
context reaching SendMessageTool preserves the explicit inbound adapter
instance.
🧹 Nitpick comments (3)
src/agent/channel_history.rs (1)

320-328: Minor: Double space when sender_context is empty.

For non-Signal adapters (Slack, Discord, Telegram, Twitch), sender_context is not set, resulting in an empty string. The format string " {sender_context} " then produces consecutive spaces in the output (e.g., "Alice [timestamp]: text").

Consider conditionally including the leading space only when sender_context is non-empty:

🔧 Proposed fix
     let sender_context = message
         .metadata
         .get("sender_context")
         .and_then(|v| v.as_str())
-        .unwrap_or("");
+        .filter(|s| !s.is_empty())
+        .map(|s| format!(" {s}"))
+        .unwrap_or_default();

     format!(
-        "{display_name}{bot_tag}{reply_context} {sender_context} [{timestamp_text}]: {text_content}"
+        "{display_name}{bot_tag}{reply_context}{sender_context} [{timestamp_text}]: {text_content}"
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel_history.rs` around lines 320 - 328, The formatted string
currently always inserts a space before and after sender_context, causing a
double space when sender_context is empty; update the code around the retrieval
of sender_context (the variable named sender_context and the format! call that
builds the final line) to conditionally include the leading space only when
sender_context is non-empty (e.g., compute a sender_context_prefixed that is
either "" or " "+sender_context and use that in the format string) so the output
has no extra spaces for non-Signal adapters.
src/api/bindings.rs (1)

35-60: Keep the update endpoint symmetric with the new group permission fields.

CreateBindingRequest now accepts group_ids / group_allowed_users, but UpdateBindingRequest below still cannot modify them. That leaves API clients with delete-and-recreate as the only way to change group-based binding permissions on an existing record.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/bindings.rs` around lines 35 - 60, UpdateBindingRequest is missing
the new group permission fields present on CreateBindingRequest, preventing
updates to group-based permissions; add group_ids: Vec<String> with
#[serde(default)] and group_allowed_users: Vec<String> with #[serde(default)] to
the UpdateBindingRequest definition (use the same types and serde defaults as
CreateBindingRequest) so the update endpoint can modify group_ids and
group_allowed_users symmetrically with creation.
src/config/load.rs (1)

2031-2031: Rename s to signal_config in this loader block.

This closure is long enough that the single-letter binding makes the field mapping harder to scan than it needs to be.

As per coding guidelines, "Use non-abbreviated variable names in Rust code: queue not q, message not msg, channel not ch; common abbreviations like config are acceptable".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/load.rs` at line 2031, The closure mapping toml.messaging.signal
currently binds its argument as a single-letter `s`, which reduces readability;
rename that binding to `signal_config` in the closure used to build `signal:`
(i.e., change `toml.messaging.signal.and_then(|s| { ... }` to
`and_then(|signal_config| { ... }`) and update all references inside the closure
(field mappings and method calls) to use `signal_config` so the intent is
clearer while preserving behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/agent/channel.rs`:
- Around line 2134-2139: The send of the user-visible error via
self.response_tx.send(OutboundResponse::Text(error_msg)).await currently ignores
the Result with `let _ =`, which silently discards failures; update the code in
channel.rs around the error path where error_msg is built to handle the send
result explicitly—either call .ok() to mark it as best-effort
(self.response_tx.send(...).await.ok()) or log the error when send returns Err
(e.g., capture the Result and call a logger with context including error_msg and
the send Err); reference the send call, response_tx, OutboundResponse::Text, and
error_msg when making the change.

In `@src/config/permissions.rs`:
- Around line 339-343: SignalPermissions currently mixes DM and group allowlists
causing group wildcards to grant DM access; preserve them separately by keeping
dm_allowed_users as the canonical DM allowlist and
group_filter/group_allowed_users as the group-scoped allowlist. Update the code
paths that set ["*"] for group IDs (the logic around group_filter and any places
that assign to dm_allowed_users when encountering a group wildcard) so they only
mutate group_filter or group_allowed_users and never overwrite or merge into
dm_allowed_users; likewise remove any folding of group_allowed_users into
dm_allowed_users in the code that consolidates permissions (the blocks
referenced around the current group handling and the consolidation sections),
and ensure authorization checks use dm_allowed_users for direct messages and
group_filter/group_allowed_users for group messages only.
- Around line 388-428: The group_filter construction currently returns
Some(all_group_ids) even when all_group_ids is empty; change it so that after
aggregating and filtering seed_group_ids and signal_bindings (using
is_valid_base64) you return None when all_group_ids.is_empty() to represent the
"block all" case; keep the existing early returns for wildcard ("*") that
produce Self with group_filter Some(vec!["*"]) and dm_allowed_users vec!["*"],
but replace the final Some(all_group_ids) with None when no valid IDs remain so
consumers don’t receive Some(vec![]); update the block where group_filter is set
(the let group_filter = { ... } scope) to perform this empty-check before
returning.

In `@src/config/types.rs`:
- Around line 1330-1339: Binding::matches currently ignores Signal-specific
metadata so resolve_agent_for_message will incorrectly select the first
channel="signal" binding; update Binding::matches to inspect the message's
Signal fields (e.g., signal_chat_type, signal_group_id, sender_id) and enforce
the new binding fields: check that signal_chat_type (or equivalent) matches the
binding, that for group chats signal_group_id is allowed by group_ids or
sender_id is allowed by group_allowed_users, and that for DMs sender_id is
allowed by dm_allowed_users; also respect require_mention and channel_ids when
matching Signal messages so resolve_agent_for_message can correctly route to the
appropriate agent.
- Around line 2341-2374: The SignalConfig treats account as sensitive in Debug
but SystemSecrets.secret_fields only includes http_url; add a SecretField for
the account inside the SignalConfig impl of SystemSecrets (update
secret_fields() to return both SecretField entries) using toml_key "account", a
sensible secret_name like "SIGNAL_ACCOUNT", and the same InstancePattern style
used for http_url (platform_prefix "SIGNAL", field_suffix "ACCOUNT") so the
account/phone number is resolved as a secret consistently with Debug redaction.

In `@src/config/watcher.rs`:
- Around line 233-239: The reload only replaces a local Arc (new_perms) into an
ephemeral ArcSwap when signal_permissions was None or when creating a named
instance, so adapters started after boot keep an unreachable permissions handle
and won't get updates; change the logic to allocate and store a shared
ArcSwap<SignalPermissions> that lives in the watcher's state and hand clones of
that ArcSwap (or Arc<ArcSwap<...>>) to any hot-started Signal adapters instead
of creating a fresh, untracked Arc each time. Specifically, where
signal_permissions is initialized or a named Signal instance is created (the
places that call SignalPermissions::from_config / perms.store now and the
named-instance startup blocks), create or reuse a single ArcSwap container held
in the watcher and update that container on reload (call store with the new
Arc<SignalPermissions>) so all adapters receive updates.

In `@src/conversation/worker_transcript.rs`:
- Around line 451-458: The change currently maps UserContent::Text to
TranscriptStep::Action which causes user messages to be labeled as agent text;
instead, update the branch that matches rig::message::UserContent::Text in
worker_transcript.rs to emit TranscriptStep::UserText (preserving the same inner
text payload) rather than TranscriptStep::Action so worker_inspect.rs will
render it as "**User:**". Locate the match arm handling UserContent::Text and
replace the Action emission with the UserText variant, keeping the text
cloning/empty-and-system checks intact.

In `@src/messaging/signal.rs`:
- Around line 581-585: The tracing::info calls (e.g., the one using
sender_display, text.len(), is_group and the other similar calls around the
file) are logging raw Signal identifiers and URLs; update those log statements
to redact or obfuscate sensitive fields instead of printing them raw: replace
sender_display with a redacted/hashed form (e.g., mask or hash the phone/UUID)
and replace any http_url or full URLs with a redacted or sanitized version (keep
only domain or a fixed placeholder), and ensure any helper used
(redact_identifier/redact_url or similar) is applied consistently to the
tracing::info calls at this site and the other occurrences you flagged (around
lines ~677-680 and ~899-900) so no PII/embedded credentials are emitted.
- Around line 287-295: The current logic checks response_body.is_empty() before
validating the HTTP status, allowing non-success statuses with empty bodies to
be treated as Ok(None); update the control flow in the function that reads
response_body and status so the status.is_success() check runs before returning
on an empty body (i.e., if !status.is_success() keep the existing truncated_body
+ anyhow::bail! behavior even when response_body.is_empty()), ensuring failed
send/sendTyping calls propagate an error rather than Ok(None).
- Around line 480-485: The sender resolution currently only checks
envelope.source_number and envelope.source and thus returns None for
privacy-mode envelopes; update the chain to also consider envelope.source_uuid
(e.g., prefer envelope.source_number, then envelope.source, then
envelope.source_uuid) before mapping to String so the new UUID allowlist path
works; reference the existing sender binding and the envelope.source_number /
envelope.source / envelope.source_uuid fields and ensure the final
.map(String::from)? remains.
- Around line 397-405: The code currently joins untrusted `filename` into
`self.tmp_dir`, allowing path traversal or nested paths; fix by extracting and
sanitizing only the final path component before joining: use
Path::new(&filename).file_name().and_then(|s|
s.to_str()).unwrap_or("attachment") (or fallback to the UUID alone) then
optionally replace or strip unsafe characters to produce `safe_name`, build
`unique_name` with Uuid::new_v4() and `safe_name`, and use that when creating
`tmp_path` (still `self.tmp_dir.join(&unique_name)`); ensure you handle the
fallback case so `tmp_path` cannot escape `tmp_dir` and avoid relying on
intermediate directories from the original filename.
- Around line 821-889: The cancel sender is being dropped immediately which
closes cancel_rx and causes the spawned typing task (created in the block using
cancel_tx/cancel_rx and inserted into self.typing_tasks) to exit immediately;
instead of drop(cancel_tx), retain and store the sender so stop_typing() can
call send() to signal cancellation. Modify the storage for typing tasks (e.g.,
replace the current map entry of just the JoinHandle inserted by
typing_tasks.insert(conversation_id, handle) with a small struct or tuple
containing both the JoinHandle and the cancel_tx Sender) or add a separate map
for cancel senders, ensure you insert the cancel_tx alongside the handle, and
remove the explicit drop(cancel_tx); update stop_typing() to use the stored
cancel_tx to signal cancellation before/without aborting the handle.

In `@src/prompts/engine.rs`:
- Around line 476-479: The match in PromptEngine::new() selects
"adapters/signal" for the "signal" branch, but PromptEngine::new() only
registers the "adapters/email" template so "adapters/signal" falls through to
the default None; update PromptEngine::new() (or the environment/template
registration code it calls) to register the "adapters/signal" template alongside
"adapters/email" so that the match on template_name ("adapters/signal") finds a
registered template and returns the proper adapter-specific guidance.

In `@src/tools/send_message_to_another_channel.rs`:
- Around line 249-284: The current hyphen check in parse_explicit_signal_target
misclassifies ordinary hyphenated names as Signal UUIDs; update the hyphen
branch to only accept a true UUID shape (or require the explicit "signal:uuid:"
prefix) before calling parse_delivery_target. Concretely, in
parse_explicit_signal_target replace the loose trimmed.contains('-') &&
trimmed.len() > 8 check with a strict UUID validation (e.g., test with a UUID
regex or attempt to parse as a UUID) and only then call
parse_delivery_target(&format!("signal:uuid:{trimmed}")), otherwise fall through
to channel lookup.
- Around line 141-166: The code currently unconditionally replaces
explicit_target.adapter with self.current_adapter, which causes explicit Signal
targets (e.g., parse_explicit_signal_target returning "signal:+1555...") to be
sent via the wrong adapter; change the logic in the explicit-target branch so
you only assign self.current_adapter to explicit_target.adapter when the parsed
adapter is empty/unspecified (i.e., do not overwrite when
parse_explicit_signal_target returned a concrete adapter like "signal" or
"signal:..."); update the block around parse_explicit_signal_target,
explicit_target.adapter, and the subsequent messaging_manager.broadcast call to
use the parsed adapter unless it was empty.

---

Outside diff comments:
In `@src/agent/channel.rs`:
- Around line 1789-1800: The code currently passes self.current_adapter() into
add_channel_tools which is derived from source_adapter/conversation_id and can
lose named-instance context; change the adapter argument to prefer the
InboundMessage.adapter (or equivalent inbound message field available in this
scope) when present, falling back to self.current_adapter() only if
InboundMessage.adapter is None, so the channel context reaching SendMessageTool
preserves the explicit inbound adapter instance.

In `@src/secrets/store.rs`:
- Around line 1103-1119: Signal secret registration misses the Signal account
key: update SignalConfig::secret_fields (in the SignalConfig implementation in
src/config/types.rs) to include the account field alongside http_url so
SIGNAL_ACCOUNT and SIGNAL_<INSTANCE>_ACCOUNT are treated as secret; modify the
SignalConfig struct/impl to return the account secret name (matching naming used
elsewhere for instance-scoped secrets) in secret_fields so the secrets store
registers it correctly.

---

Nitpick comments:
In `@src/agent/channel_history.rs`:
- Around line 320-328: The formatted string currently always inserts a space
before and after sender_context, causing a double space when sender_context is
empty; update the code around the retrieval of sender_context (the variable
named sender_context and the format! call that builds the final line) to
conditionally include the leading space only when sender_context is non-empty
(e.g., compute a sender_context_prefixed that is either "" or " "+sender_context
and use that in the format string) so the output has no extra spaces for
non-Signal adapters.

In `@src/api/bindings.rs`:
- Around line 35-60: UpdateBindingRequest is missing the new group permission
fields present on CreateBindingRequest, preventing updates to group-based
permissions; add group_ids: Vec<String> with #[serde(default)] and
group_allowed_users: Vec<String> with #[serde(default)] to the
UpdateBindingRequest definition (use the same types and serde defaults as
CreateBindingRequest) so the update endpoint can modify group_ids and
group_allowed_users symmetrically with creation.

In `@src/config/load.rs`:
- Line 2031: The closure mapping toml.messaging.signal currently binds its
argument as a single-letter `s`, which reduces readability; rename that binding
to `signal_config` in the closure used to build `signal:` (i.e., change
`toml.messaging.signal.and_then(|s| { ... }` to `and_then(|signal_config| { ...
}`) and update all references inside the closure (field mappings and method
calls) to use `signal_config` so the intent is clearer while preserving
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b7ebd32c-d659-4f3e-b98b-298d5a3dd4f0

📥 Commits

Reviewing files that changed from the base of the PR and between 9d39ae8 and 6fdde46.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock, !**/*.lock
  • Cargo.toml is excluded by !**/*.toml
📒 Files selected for processing (23)
  • prompts/en/adapters/signal.md.j2
  • prompts/en/tools/send_message_description.md.j2
  • src/agent/channel.rs
  • src/agent/channel_history.rs
  • src/api/bindings.rs
  • src/config.rs
  • src/config/load.rs
  • src/config/permissions.rs
  • src/config/toml_schema.rs
  • src/config/types.rs
  • src/config/watcher.rs
  • src/conversation/worker_transcript.rs
  • src/llm/model.rs
  • src/main.rs
  • src/messaging.rs
  • src/messaging/signal.rs
  • src/messaging/target.rs
  • src/prompts/engine.rs
  • src/prompts/text.rs
  • src/secrets/scrub.rs
  • src/secrets/store.rs
  • src/tools.rs
  • src/tools/send_message_to_another_channel.rs

@ibhagwan
Copy link
Author

ibhagwan commented Mar 7, 2026

This needs some work, I'll change it to draft until I respond to all the review comments

@ibhagwan ibhagwan marked this pull request as draft March 7, 2026 03:55
@jamiepine
Copy link
Member

jamiepine commented Mar 7, 2026

Ty @jamiepine for this wonderful project, although lesser known ATM I believe this one has huge potential, I've been personally testing every decent project out there and so far I like this one and IronClaw the most.

Btw, this isn't my first signal support PR, I've added the same to ZeroClaw, IronClaw, NullClaw and Hermes-agent: zeroclaw-labs/zeroclaw#468 nearai/ironclaw#271 nullclaw/nullclaw#63 NousResearch/hermes-agent#268

This is wonderful to hear, thank you. I'm putting a load of effort into making this the best I can. Hoping soon it will pick up traction wise, but so far the contributions have been amazing and we're moving quickly to stability. If you want to chat please join the Discord and/or DM me anytime!

https://discord.gg/g8fMZsVSTT

@ibhagwan ibhagwan force-pushed the feat/signal-adapter branch from 90cc30d to b7b7218 Compare March 7, 2026 04:12
ibhagwan added a commit to ibhagwan/spacebot that referenced this pull request Mar 7, 2026
…eapp#347)

Fixes critical issues identified in code review:

**Critical:**
- conversation/worker_transcript.rs: Restore TranscriptStep::UserText emission to prevent user messages being mislabeled as agent text
- config/permissions.rs: Separate DM and group permissions to prevent group wildcards from granting DM access
- messaging/signal.rs: Move HTTP status check before empty body check to properly fail on non-2xx responses
- messaging/signal.rs: Sanitize attachment filenames to prevent path traversal vulnerabilities
- messaging/signal.rs: Add source_uuid fallback for sender resolution in privacy-mode envelopes

**Medium:**
- agent/channel.rs: Use .ok() instead of let _ = for best-effort error channel sends
- config/types.rs: Add SecretField for Signal account to treat phone number as sensitive
- prompts/engine.rs: Register adapters/signal template for proper Signal-specific guidance
- tools/send_message_to_another_channel.rs: Only overwrite adapter when explicit target has no adapter specified

All changes pass cargo check.
ibhagwan added a commit to ibhagwan/spacebot that referenced this pull request Mar 7, 2026
…eapp#347)

Fixes critical issues identified in code review:

**Critical:**
- conversation/worker_transcript.rs: Restore TranscriptStep::UserText emission to prevent user messages being mislabeled as agent text
- config/permissions.rs: Separate DM and group permissions to prevent group wildcards from granting DM access
- messaging/signal.rs: Move HTTP status check before empty body check to properly fail on non-2xx responses
- messaging/signal.rs: Sanitize attachment filenames to prevent path traversal vulnerabilities
- messaging/signal.rs: Add source_uuid fallback for sender resolution in privacy-mode envelopes

**Medium:**
- agent/channel.rs: Use .ok() instead of let _ = for best-effort error channel sends
- config/types.rs: Add SecretField for Signal account to treat phone number as sensitive
- prompts/engine.rs: Register adapters/signal template for proper Signal-specific guidance
- tools/send_message_to_another_channel.rs: Only overwrite adapter when explicit target has no adapter specified

**Low**
- agent/channel_history.rs: Condition space before sender_context on non-empty value to prevent double spacing

All changes pass cargo check.
@ibhagwan ibhagwan force-pushed the feat/signal-adapter branch from e741a28 to b2509ac Compare March 7, 2026 04:55
ibhagwan added a commit to ibhagwan/spacebot that referenced this pull request Mar 7, 2026
…eapp#347)

Fixes critical issues identified in code review:

**Critical:**
- conversation/worker_transcript.rs: Restore TranscriptStep::UserText emission to prevent user messages being mislabeled as agent text
- config/permissions.rs: Separate DM and group permissions to prevent group wildcards from granting DM access
- messaging/signal.rs: Move HTTP status check before empty body check to properly fail on non-2xx responses
- messaging/signal.rs: Sanitize attachment filenames to prevent path traversal vulnerabilities
- messaging/signal.rs: Add source_uuid fallback for sender resolution in privacy-mode envelopes

**Medium:**
- agent/channel.rs: Use .ok() instead of let _ = for best-effort error channel sends
- config/types.rs: Add SecretField for Signal account to treat phone number as sensitive
- prompts/engine.rs: Register adapters/signal template for proper Signal-specific guidance
- tools/send_message_to_another_channel.rs: Only overwrite adapter when explicit target has no adapter specified

**Low:**
- agent/channel_history.rs: Condition space before sender_context on non-empty value to prevent double spacing
- api/bindings.rs: Add group_ids and group_allowed_users to UpdateBindingRequest for symmetric updates

All changes pass cargo check.
@ibhagwan ibhagwan force-pushed the feat/signal-adapter branch from b2509ac to 22b4717 Compare March 7, 2026 04:58
ibhagwan added a commit to ibhagwan/spacebot that referenced this pull request Mar 7, 2026
…eapp#347)

Fixes critical issues identified in code review:

**Critical:**
- conversation/worker_transcript.rs: Restore TranscriptStep::UserText emission to prevent user messages being mislabeled as agent text
- config/permissions.rs: Separate DM and group permissions to prevent group wildcards from granting DM access
- messaging/signal.rs: Move HTTP status check before empty body check to properly fail on non-2xx responses
- messaging/signal.rs: Sanitize attachment filenames to prevent path traversal vulnerabilities
- messaging/signal.rs: Add source_uuid fallback for sender resolution in privacy-mode envelopes

**Medium:**
- agent/channel.rs: Use .ok() instead of let _ = for best-effort error channel sends
- config/types.rs: Add SecretField for Signal account to treat phone number as sensitive
- prompts/engine.rs: Register adapters/signal template for proper Signal-specific guidance
- tools/send_message_to_another_channel.rs: Only overwrite adapter when explicit target has no adapter specified
- agent/channel.rs: Add adapter parameter to run_agent_turn, prefer InboundMessage.adapter to preserve named-instance context
- api/bindings.rs: Add group_ids and group_allowed_users to UpdateBindingRequest for symmetric updates

**Low:**
- agent/channel_history.rs: Condition space before sender_context on non-empty value to prevent double spacing

All changes pass cargo check.
@ibhagwan ibhagwan force-pushed the feat/signal-adapter branch from 22b4717 to dd419d7 Compare March 7, 2026 05:21
ibhagwan added a commit to ibhagwan/spacebot that referenced this pull request Mar 7, 2026
…eapp#347)

Fixes critical issues identified in code review:

**Critical:**
- conversation/worker_transcript.rs: Restore TranscriptStep::UserText emission to prevent user messages being mislabeled as agent text
- config/permissions.rs: Separate DM and group permissions to prevent group wildcards from granting DM access
- messaging/signal.rs: Move HTTP status check before empty body check to properly fail on non-2xx responses
- messaging/signal.rs: Sanitize attachment filenames to prevent path traversal vulnerabilities
- messaging/signal.rs: Add source_uuid fallback for sender resolution in privacy-mode envelopes

**Medium:**
- agent/channel.rs: Use .ok() instead of let _ = for best-effort error channel sends
- config/types.rs: Add SecretField for Signal account to treat phone number as sensitive
- prompts/engine.rs: Register adapters/signal template for proper Signal-specific guidance
- tools/send_message_to_another_channel.rs: Only overwrite adapter when explicit target has no adapter specified
- agent/channel.rs: Add adapter parameter to run_agent_turn, prefer InboundMessage.adapter to preserve named-instance context
- api/bindings.rs: Add group_ids and group_allowed_users to UpdateBindingRequest for symmetric updates

**Low:**
- agent/channel_history.rs: Condition space before sender_context on non-empty value to prevent double spacing

All changes pass cargo check.
@ibhagwan ibhagwan force-pushed the feat/signal-adapter branch 5 times, most recently from aba09a9 to 0d2bf8f Compare March 7, 2026 06:43
@ibhagwan
Copy link
Author

ibhagwan commented Mar 7, 2026

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

1 similar comment
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/agent/channel.rs (1)

1325-1333: ⚠️ Potential issue | 🟠 Major

Preserve the concrete adapter on batched turns.

Line 1332 still passes self.current_adapter(), which is derived from the channel/source and can lose the per-message adapter instance. The single-message path at Lines 1625-1628 now prefers message.adapter, but the coalesced path does not, so a batched Signal conversation on a non-default instance can still send explicit signal:* targets through the wrong adapter in SendMessageTool.

Suggested fix
-        let (result, skip_flag, replied_flag, _) = self
+        let adapter = messages
+            .iter()
+            .find_map(|message| message.adapter.as_deref())
+            .or_else(|| self.current_adapter());
+        let (result, skip_flag, replied_flag, _) = self
             .run_agent_turn(
                 &combined_text,
                 &system_prompt,
                 &conversation_id,
                 attachment_parts,
                 false, // not a retrigger
-                self.current_adapter(),
+                adapter,
             )
             .await?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel.rs` around lines 1325 - 1333, The batched/coalesced turn is
still calling run_agent_turn(..., self.current_adapter()) which can lose a
per-message adapter; change the coalesced-path call site to preserve and pass
the concrete adapter instance used for those messages (the same adapter you use
for single-message handling via message.adapter) into run_agent_turn (or into
the coalescing routine) so SendMessageTool receives the correct adapter for
signal:* targets; update the coalescing logic that builds
combined_text/attachment_parts to carry the chosen adapter and pass that adapter
instead of self.current_adapter().
♻️ Duplicate comments (4)
src/config/watcher.rs (1)

233-239: ⚠️ Potential issue | 🟠 Major

Hot-started Signal adapters still get untracked permission handles.

When Signal is enabled after boot, or when a named instance starts here, this code allocates a fresh ArcSwap and passes it straight into the adapter. Later reloads only update signal_permissions, so those adapters keep stale DM/group filters until restart. The watcher needs one shared, stored handle per live Signal adapter.

Also applies to: 541-547, 562-574

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/watcher.rs` around lines 233 - 239, The current watcher creates a
fresh Arc/ArcSwap and hands it directly to newly started Signal adapters (via
the code path that calls SignalPermissions::from_config and perms.store(...)),
so adapters started after boot keep their own Arc and never see subsequent
reloads; instead ensure the watcher maintains one shared
ArcSwap<SignalPermissions> per live Signal adapter and always pass a clone of
that shared ArcSwap handle into adapter constructors. Concretely: stop
allocating a new ArcSwap for each adapter start, centralize the ArcSwap (the
existing signal_permissions variable) so adapters receive
signal_permissions.clone() (or an Arc clone of the ArcSwap/holder) and update
SignalPermissions::from_config -> perms.store(Arc::new(new_perms)) on reloads;
apply the same change where Signal adapters are instantiated (the branches that
touch signal_permissions, SignalPermissions::from_config, and perms.store) so
adapters observe reloads instead of keeping stale filters.
src/tools/send_message_to_another_channel.rs (1)

147-156: ⚠️ Potential issue | 🟠 Major

Don't overwrite explicitly selected Signal instances.

This still rewrites any explicit Signal target onto current_adapter as long as the current conversation came from Signal. signal:work:+1555... sent from a signal:personal thread will go out on the wrong account. Only the bare shorthand forms should inherit the current adapter.

Suggested guard
-            if let Some(current_adapter) = self
-                .current_adapter
-                .as_ref()
-                .filter(|adapter| adapter.starts_with("signal"))
-            {
-                explicit_target.adapter = current_adapter.clone();
-            }
+            if !args.target.trim_start().starts_with("signal:")
+                && let Some(current_adapter) = self
+                    .current_adapter
+                    .as_ref()
+                    .filter(|adapter| adapter.starts_with("signal"))
+            {
+                explicit_target.adapter = current_adapter.clone();
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/send_message_to_another_channel.rs` around lines 147 - 156, The
code currently overwrites any Signal target with self.current_adapter when the
conversation is from Signal; change this so only bare shorthand targets inherit
the current adapter. In the block that handles
parse_explicit_signal_target(&args.target) (and sets explicit_target.adapter),
first detect whether the caller provided an explicit adapter (e.g., args.target
contains an adapter prefix like "signal:" or parse_explicit_signal_target
produced a non-empty adapter); only if the target is a bare shorthand (no
adapter specified) assign explicit_target.adapter = current_adapter.clone() —
otherwise leave the explicit adapter untouched. Use the existing symbols
parse_explicit_signal_target, explicit_target.adapter, args.target and
self.current_adapter to implement the guard.
src/messaging/signal.rs (2)

559-579: ⚠️ Potential issue | 🔴 Critical

Keep DM and group sender scopes separate.

This re-merges dm_allowed_users into group authorization. With dm_allowed_users = ["*"], every sender in an allowed group passes even when group_allowed_users is empty, so the new group-specific allowlist never actually narrows access.

🔒 Proposed fix
-            let all_group_users: Vec<&String> = permissions
-                .dm_allowed_users
-                .iter()
-                .chain(permissions.group_allowed_users.iter())
-                .collect();
-
-            let sender_allowed = if all_group_users.is_empty() {
+            let sender_allowed = if permissions.group_allowed_users.is_empty() {
                 false // Empty = block all
-            } else if all_group_users.iter().any(|u| u.as_str() == "*") {
+            } else if permissions
+                .group_allowed_users
+                .iter()
+                .any(|user| user.as_str() == "*")
+            {
                 true // Wildcard = allow all
             } else {
-                all_group_users.iter().any(|allowed| {
-                    allowed.as_str() == sender
+                permissions.group_allowed_users.iter().any(|allowed| {
+                    allowed.as_str() == sender.as_str()
                         || envelope
                             .source_uuid
                             .as_deref()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/signal.rs` around lines 559 - 579, The group authorization
logic incorrectly merges dm_allowed_users into the group allowlist; update the
check so it uses only permissions.group_allowed_users (remove dm_allowed_users
from all_group_users) when computing sender_allowed for groups, keeping
dm_allowed_users separate for DM checks; preserve the semantics for empty (deny
all), wildcard "*" (allow all), and matching by sender or envelope.source_uuid
in the group_allowed_users-based logic (referencing dm_allowed_users,
group_allowed_users, all_group_users, sender_allowed, and envelope.source_uuid
to locate the change).

105-111: ⚠️ Potential issue | 🟠 Major

redact_url() still exposes URL userinfo.

Keeping the first three /-separated components preserves user:pass@host:port, so a credentialed http_url still lands in logs. Parse the URL and emit only scheme://host[:port], or a fixed placeholder if parsing fails.

🛡️ Proposed fix
 fn redact_url(url: &str) -> String {
-    url.split('/')
-        .take(3) // scheme://domain
-        .collect::<Vec<_>>()
-        .join("/")
-        .to_string()
+    match reqwest::Url::parse(url) {
+        Ok(parsed) => match parsed.host_str() {
+            Some(host) => {
+                let mut redacted = format!("{}://{}", parsed.scheme(), host);
+                if let Some(port) = parsed.port() {
+                    redacted.push_str(&format!(":{port}"));
+                }
+                redacted
+            }
+            None => "[redacted-url]".to_string(),
+        },
+        Err(_) => "[redacted-url]".to_string(),
+    }
 }

As per coding guidelines, "Compliance/privacy risks (PII retention, logging sensitive data -- like emails and other user identifiers, GDPR/CCPA violations)" are major issues.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/signal.rs` around lines 105 - 111, The current redact_url
function still leaks URL userinfo because it naively keeps the first three
slash-separated components; update redact_url to properly parse the input URL
(using a URL parser) and reconstruct only the scheme and host (including port if
present) in the form "scheme://host[:port]", explicitly omitting any userinfo,
and return a fixed placeholder (e.g., "redacted://") if parsing fails or the
scheme/host are missing; reference the redact_url function to locate and replace
the naive split/join logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/bindings.rs`:
- Around line 157-166: The update_binding() path currently ignores the new
Signal permission fields so updates never persist group_ids and
group_allowed_users; modify update_binding() to copy these fields from the
incoming binding DTO into the in-memory binding (assign binding.group_ids =
dto.group_ids and binding.group_allowed_users = dto.group_allowed_users) and
ensure the updated config is written back to TOML using the same persistence
path used elsewhere (the existing save/write config routine in this module) so
changes to group_ids and group_allowed_users are persisted; keep types as
Vec<String> to match toml_schema.rs and respect serde defaults.

In `@src/config/permissions.rs`:
- Around line 397-419: The wildcard-handling branches inside the permission
assembly prematurely return a new Self (seen in the branches that set
group_filter to Some(vec!["*".to_string()])) which prevents merging the rest of
the allowlists (dm_allowed_users and group_allowed_users) from signal_bindings
and seed lists; change those branches to set only the relevant field (e.g., set
group_filter = Some(vec!["*".to_string()]) or dm_allowed_users =
vec!["*".to_string()]) and continue processing instead of returning, then after
iterating all signal_bindings build and return the final Self using the merged
dm_allowed_users, group_allowed_users, and group_filter so wildcards finalize
only their own field while allowing other lists to be combined (refer to
symbols: all_group_ids, signal_bindings, group_filter, dm_allowed_users,
group_allowed_users).

In `@src/config/types.rs`:
- Around line 1504-1511: Signal DM handling in SignalConfig is inverted: update
the block that checks self.dm_allowed_users for DMs so that when
dm_allowed_users is empty DMs are ignored, and when non-empty only listed
sender_ids are allowed. Specifically, in the Signal DM branch (SignalConfig /
method handling message routing that currently references self.dm_allowed_users
and message.sender_id) change the condition to return false if
self.dm_allowed_users.is_empty() or if
!self.dm_allowed_users.contains(&message.sender_id), so DMs are blocked when the
list is empty and otherwise only allowed for contained sender_ids.

In `@src/messaging/signal.rs`:
- Around line 887-897: The typing-indicator loop currently only handles
transport errors from client.post(...).send().await and keeps retrying on HTTP
4xx/5xx; change the block in the typing task that calls
client.post(&rpc_url).timeout(RPC_REQUEST_TIMEOUT).header(...).json(&body).send().await
to capture the Response on success, check response.status().is_success(), and if
not successful log the status and response body (or error) and break the
loop—follow the same status-checking pattern used by the rpc_request() function
so server-side failures stop the typing loop instead of retrying.

In `@src/messaging/target.rs`:
- Around line 335-374: parse_signal_target_parts currently handles e164 and
phone branches itself, causing inconsistent parsing vs the shared normalizer;
update parse_signal_target_parts to call the existing normalize_signal_target()
for any phone/e164 branches (both default and named-adapter arms) and use its
returned canonical phone string (or None to reject) when building
BroadcastTarget, keeping the adapter construction logic (adapter: "signal" or
format!("signal:{instance}")) and the uuid/group handling unchanged; this
ensures parse_signal_target_parts mirrors parse_delivery_target() behavior and
accepts bare numeric forms that normalize_signal_target accepts.

In `@src/tools/send_message_to_another_channel.rs`:
- Around line 288-291: The bare-digit branch (checking trimmed.len() >= 7 && all
digits) preempts ChannelStore lookup and wrongly treats numeric channel IDs as
Signal numbers; change the logic so that you only call
crate::messaging::target::parse_delivery_target for Signal when the input
explicitly indicates Signal (e.g., original input starts with "signal:" or an
explicit "+"/international prefix), otherwise fall through to the ChannelStore
resolution path. Update the condition around the trimmed check (and any use of
trimmed) to require an explicit signal indicator, and ensure ChannelStore lookup
remains the default resolution for plain numeric strings.

---

Outside diff comments:
In `@src/agent/channel.rs`:
- Around line 1325-1333: The batched/coalesced turn is still calling
run_agent_turn(..., self.current_adapter()) which can lose a per-message
adapter; change the coalesced-path call site to preserve and pass the concrete
adapter instance used for those messages (the same adapter you use for
single-message handling via message.adapter) into run_agent_turn (or into the
coalescing routine) so SendMessageTool receives the correct adapter for signal:*
targets; update the coalescing logic that builds combined_text/attachment_parts
to carry the chosen adapter and pass that adapter instead of
self.current_adapter().

---

Duplicate comments:
In `@src/config/watcher.rs`:
- Around line 233-239: The current watcher creates a fresh Arc/ArcSwap and hands
it directly to newly started Signal adapters (via the code path that calls
SignalPermissions::from_config and perms.store(...)), so adapters started after
boot keep their own Arc and never see subsequent reloads; instead ensure the
watcher maintains one shared ArcSwap<SignalPermissions> per live Signal adapter
and always pass a clone of that shared ArcSwap handle into adapter constructors.
Concretely: stop allocating a new ArcSwap for each adapter start, centralize the
ArcSwap (the existing signal_permissions variable) so adapters receive
signal_permissions.clone() (or an Arc clone of the ArcSwap/holder) and update
SignalPermissions::from_config -> perms.store(Arc::new(new_perms)) on reloads;
apply the same change where Signal adapters are instantiated (the branches that
touch signal_permissions, SignalPermissions::from_config, and perms.store) so
adapters observe reloads instead of keeping stale filters.

In `@src/messaging/signal.rs`:
- Around line 559-579: The group authorization logic incorrectly merges
dm_allowed_users into the group allowlist; update the check so it uses only
permissions.group_allowed_users (remove dm_allowed_users from all_group_users)
when computing sender_allowed for groups, keeping dm_allowed_users separate for
DM checks; preserve the semantics for empty (deny all), wildcard "*" (allow
all), and matching by sender or envelope.source_uuid in the
group_allowed_users-based logic (referencing dm_allowed_users,
group_allowed_users, all_group_users, sender_allowed, and envelope.source_uuid
to locate the change).
- Around line 105-111: The current redact_url function still leaks URL userinfo
because it naively keeps the first three slash-separated components; update
redact_url to properly parse the input URL (using a URL parser) and reconstruct
only the scheme and host (including port if present) in the form
"scheme://host[:port]", explicitly omitting any userinfo, and return a fixed
placeholder (e.g., "redacted://") if parsing fails or the scheme/host are
missing; reference the redact_url function to locate and replace the naive
split/join logic.

In `@src/tools/send_message_to_another_channel.rs`:
- Around line 147-156: The code currently overwrites any Signal target with
self.current_adapter when the conversation is from Signal; change this so only
bare shorthand targets inherit the current adapter. In the block that handles
parse_explicit_signal_target(&args.target) (and sets explicit_target.adapter),
first detect whether the caller provided an explicit adapter (e.g., args.target
contains an adapter prefix like "signal:" or parse_explicit_signal_target
produced a non-empty adapter); only if the target is a bare shorthand (no
adapter specified) assign explicit_target.adapter = current_adapter.clone() —
otherwise leave the explicit adapter untouched. Use the existing symbols
parse_explicit_signal_target, explicit_target.adapter, args.target and
self.current_adapter to implement the guard.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3a757d08-c636-441b-a7e1-bc51616e900f

📥 Commits

Reviewing files that changed from the base of the PR and between 6fdde46 and 0d2bf8f.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock, !**/*.lock
  • Cargo.toml is excluded by !**/*.toml
📒 Files selected for processing (23)
  • prompts/en/adapters/signal.md.j2
  • prompts/en/tools/send_message_description.md.j2
  • src/agent/channel.rs
  • src/agent/channel_history.rs
  • src/api/bindings.rs
  • src/config.rs
  • src/config/load.rs
  • src/config/permissions.rs
  • src/config/toml_schema.rs
  • src/config/types.rs
  • src/config/watcher.rs
  • src/conversation/worker_transcript.rs
  • src/llm/model.rs
  • src/main.rs
  • src/messaging.rs
  • src/messaging/signal.rs
  • src/messaging/target.rs
  • src/prompts/engine.rs
  • src/prompts/text.rs
  • src/secrets/scrub.rs
  • src/secrets/store.rs
  • src/tools.rs
  • src/tools/send_message_to_another_channel.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/agent/channel_history.rs
  • src/config/load.rs
  • src/prompts/engine.rs
  • src/secrets/store.rs
  • src/conversation/worker_transcript.rs
  • src/tools.rs

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/agent/channel.rs (1)

1325-1333: ⚠️ Potential issue | 🟠 Major

Preserve the full runtime adapter key for named instances.

These call sites still fall back to self.current_adapter(), but that value is derived from message.source / the first conversation_id segment, so a named adapter like signal:support gets collapsed to signal. Batched turns and synthetic retriggers will then register channel tools against the default adapter and can send follow-up messages through the wrong account. Please store/propagate message.adapter as the canonical per-channel adapter key instead of reconstructing it from source.

Also applies to: 1625-1637

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel.rs` around lines 1325 - 1333, The call is using
self.current_adapter() which collapses named adapters (e.g., "signal:support")
to their transport only; instead propagate and use the full per-channel adapter
key stored on the message (message.adapter) when invoking run_agent_turn and any
other places that reconstruct adapters (also at the other call site around
run_agent_turn lines ~1625-1637). Replace usages of self.current_adapter() for
per-message operations with the message.adapter (or a function that returns the
full adapter key from the Message struct) so named instances are preserved for
registering channel tools and sending follow-ups.
♻️ Duplicate comments (10)
src/messaging/target.rs (2)

335-374: ⚠️ Potential issue | 🟠 Major

Reuse normalize_signal_target() in parse_signal_target_parts().

This helper still special-cases e164/phone forms instead of normalizing them through the shared Signal normalizer. That leaves it inconsistent with parse_delivery_target(): signal:e164:1234567890 becomes 1234567890 here instead of canonical +1234567890, and bare numeric forms accepted by normalize_signal_target() are still rejected on this path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/target.rs` around lines 335 - 374, parse_signal_target_parts
currently special-cases e164/phone forms instead of reusing
normalize_signal_target, causing inconsistent normalization; update
parse_signal_target_parts to call normalize_signal_target for branches that
handle phone/e164 or single-phone inputs (both default adapter and named adapter
cases) and only construct BroadcastTarget when normalize_signal_target returns
Some(normalized_target), using adapter "signal" or format!("signal:{instance}")
as before; remove the duplicated phone/e164 normalization logic so numeric-only
inputs accepted by normalize_signal_target are handled consistently.

104-122: ⚠️ Potential issue | 🟠 Major

Keep the named Signal adapter when resolving stored channels.

This branch reparses metadata as signal:{signal_target}, which hard-codes the default adapter. Since src/messaging/signal.rs:1329-1333 only persists the recipient under signal_target, any channel whose ID encodes a named adapter (for example signal:<instance>:...) will resolve back to signal here and outbound sends can jump to the wrong daemon/account. Use the adapter encoded in channel.id for adapter selection and metadata only for the recipient payload.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/target.rs` around lines 104 - 122, When resolving Signal
channels in this branch, do not hard-code the adapter as "signal" by calling
parse_delivery_target with "signal:{signal_target}"; instead extract the adapter
name from channel.id (the first part before the first ':') and combine that
adapter with the recipient from platform_meta["signal_target"] so adapter
selection uses the channel.id while the metadata supplies only the recipient
payload; update the logic around parse_delivery_target and
parse_signal_target_parts (and the parts extraction from channel.id) so that
when signal_target exists you call parse_delivery_target with
"<adapter>:<signal_target>" (adapter taken from channel.id) and otherwise
continue to parse parts from channel.id via parse_signal_target_parts.
src/tools/send_message_to_another_channel.rs (2)

288-290: ⚠️ Potential issue | 🟠 Major

Plain numeric targets still hijack channel IDs.

This parser runs before ChannelStore::find_by_name(), so "1234567890" will still be coerced into signal:+1234567890 instead of resolving a numeric channel ID. Require an explicit Signal marker here (signal: or +) and leave bare digits for normal channel lookup.

Minimal fix
-    // Bare phone number (7+ digits)
-    if trimmed.len() >= 7 && trimmed.chars().all(|c| c.is_ascii_digit()) {
-        return crate::messaging::target::parse_delivery_target(&format!("signal:+{trimmed}"));
-    }
-
     // Group ID format: group:xxx (might be passed directly)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/send_message_to_another_channel.rs` around lines 288 - 290, The
current bare-digit check in send_message_to_another_channel.rs incorrectly
converts plain numeric strings to a Signal delivery target; change the logic so
only explicit Signal markers are accepted: only call parse_delivery_target for
inputs that either start with "signal:" or start with '+' followed by digits
(e.g., "+1234567"); leave purely numeric trimmed strings untouched so
ChannelStore::find_by_name() can resolve numeric channel IDs. Ensure you still
construct the "signal:+{trimmed_digits}" form when handling a leading '+' case
and keep parse_delivery_target usage for the explicit-signal branch.

150-156: ⚠️ Potential issue | 🟠 Major

Don't overwrite explicitly named Signal adapters.

If parse_explicit_signal_target() resolves something like signal:work:+1555, this branch still replaces it with self.current_adapter whenever the current conversation is on Signal. That sends through the wrong Signal account. Only inherit the current adapter for generic targets that resolved to the default "signal" adapter.

Suggested guard
-            if let Some(current_adapter) = self
-                .current_adapter
-                .as_ref()
-                .filter(|adapter| adapter.starts_with("signal"))
-            {
-                explicit_target.adapter = current_adapter.clone();
+            if explicit_target.adapter == "signal" {
+                if let Some(current_adapter) = self
+                    .current_adapter
+                    .as_ref()
+                    .filter(|adapter| adapter.starts_with("signal"))
+                {
+                    explicit_target.adapter = current_adapter.clone();
+                }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/send_message_to_another_channel.rs` around lines 150 - 156, The
current logic overwrites explicitly resolved Signal adapters (e.g.,
"signal:work:+1555") by checking only that self.current_adapter
starts_with("signal"); instead, only inherit the current adapter when the parsed
target was the generic "signal". Change the guard in the block that sets
explicit_target.adapter to ensure explicit_target.adapter == "signal" (and still
optionally verify self.current_adapter.starts_with("signal")) before assigning
self.current_adapter.clone(); reference the symbols explicit_target.adapter and
self.current_adapter to locate and update the condition within the
send_message_to_another_channel logic.
src/messaging/signal.rs (2)

887-897: ⚠️ Potential issue | 🟠 Major

Stop the typing loop on HTTP failures too.

This branch only handles transport errors. reqwest::send().await still returns Ok(Response) for 4xx/5xx responses, so the typing task keeps retrying every 4 seconds even when Signal is rejecting the request. rpc_request() already handles statuses correctly.

In reqwest, does RequestBuilder::send().await return Err for HTTP 4xx/5xx, or do callers need to inspect Response::status() / call error_for_status() themselves?
Minimal fix
-                        if let Err(error) = client
-                            .post(&rpc_url)
-                            .timeout(RPC_REQUEST_TIMEOUT)
-                            .header("Content-Type", "application/json")
-                            .json(&body)
-                            .send()
-                            .await
-                        {
-                            tracing::debug!(%error, "failed to send signal typing indicator");
-                            break;
-                        }
+                        match client
+                            .post(&rpc_url)
+                            .timeout(RPC_REQUEST_TIMEOUT)
+                            .header("Content-Type", "application/json")
+                            .json(&body)
+                            .send()
+                            .await
+                        {
+                            Ok(response) if response.status().is_success() => {}
+                            Ok(response) => {
+                                tracing::debug!(
+                                    status = %response.status(),
+                                    "failed to send signal typing indicator"
+                                );
+                                break;
+                            }
+                            Err(error) => {
+                                tracing::debug!(%error, "failed to send signal typing indicator");
+                                break;
+                            }
+                        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/signal.rs` around lines 887 - 897, The current send() branch
only treats transport errors as failures, but
reqwest::RequestBuilder::send().await returns Ok(Response) for HTTP 4xx/5xx so
the typing loop keeps retrying; after awaiting
client.post(&rpc_url)...send().await, inspect the Response and treat non-success
statuses as errors (e.g., call response.error_for_status() or check
!response.status().is_success()) and log the error and break the loop similarly
to the Err branch; update the block around
client.post(&rpc_url)...timeout(RPC_REQUEST_TIMEOUT)...json(&body).send().await
to handle and break on non-2xx responses (same behavior as rpc_request()).

105-112: ⚠️ Potential issue | 🟠 Major

redact_url() still leaks embedded credentials.

https://user:pass@host/path currently becomes https://user:pass@host, so the health-check debug log can still expose daemon credentials. Rebuild the redacted value from scheme/host/port instead of splitting on /.

Suggested fix
 fn redact_url(url: &str) -> String {
-    url.split('/')
-        .take(3) // scheme://domain
-        .collect::<Vec<_>>()
-        .join("/")
-        .to_string()
+    match reqwest::Url::parse(url) {
+        Ok(parsed) => {
+            let scheme = parsed.scheme();
+            let host = parsed.host_str().unwrap_or("[invalid]");
+            match parsed.port() {
+                Some(port) => format!("{scheme}://{host}:{port}"),
+                None => format!("{scheme}://{host}"),
+            }
+        }
+        Err(_) => "[invalid-url]".to_string(),
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/signal.rs` around lines 105 - 112, redact_url currently leaks
embedded credentials by splitting on '/'; update redact_url to parse the input
with a URL parser (e.g., Url::parse) and reconstruct the redacted string using
the scheme and host (and port if present) only, explicitly omitting
username/password and path/query/fragment; locate the function redact_url and
replace the split/join logic with parsing and building "scheme://host"
(including ":port" when Url::port_or_known_default indicates a non-default port)
to ensure credentials are never included.
src/config/watcher.rs (1)

233-239: ⚠️ Potential issue | 🟠 Major

Keep Signal permissions on a shared ArcSwap.

When signal_permissions is None or a named instance starts here, these branches allocate a fresh ArcSwap and pass it only to the new adapter. The watcher never stores that handle, so later reloads stop updating that adapter's DM/group filters.

Also applies to: 541-546, 572-574

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/watcher.rs` around lines 233 - 239, The watcher currently creates
a fresh ArcSwap for signal permissions and only gives it to the new adapter, so
later reloads don't update that adapter; change the logic around
signal_permissions and SignalPermissions::from_config so that when you create a
new ArcSwap (the ArcSwap holding SignalPermissions produced by
SignalPermissions::from_config) you also store that ArcSwap into the shared
watcher state (the signal_permissions Option) instead of only passing it to the
adapter; ensure the code path that calls perms.store(Arc::new(new_perms)) always
operates on the ArcSwap instance that is kept in signal_permissions
(create-and-insert into signal_permissions when it was None, and reuse the
existing ArcSwap when Some) so subsequent reloads update all adapters.
src/api/bindings.rs (1)

157-166: ⚠️ Potential issue | 🟠 Major

Wire the new Signal group fields through update_binding().

These fields are still dead on the update path. create_binding() and list_bindings() handle group_ids / group_allowed_users, but update_binding() never writes or clears them, so Signal binding edits silently no-op.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/bindings.rs` around lines 157 - 166, The Signal-specific fields
group_ids and group_allowed_users are not persisted in update_binding(), so
edits to those properties silently no-op; modify update_binding() to read the
incoming request's group_ids and group_allowed_users (respecting empty-to-clear
semantics) and write them to the stored Binding record just like
create_binding() and list_bindings() do, ensuring you handle serde/default-empty
Vecs and retain require_mention/dm_allowed_users behavior consistent with the
create path.
src/config/types.rs (1)

1504-1510: ⚠️ Potential issue | 🟠 Major

An empty Signal DM allowlist still matches every DM.

This still behaves as allow-all when dm_allowed_users is empty, which contradicts SignalConfig.dm_allowed_users and the adapter-level permission checks. It also only compares message.sender_id, so a binding written with the sender's phone number won't match once SignalAdapter normalized sender_id to the UUID.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 1504 - 1510, The current DM allowlist logic
treats an empty dm_allowed_users as allow-all and only compares
message.sender_id (a normalized UUID), which lets mismatched binding entries
(e.g., phone numbers) slip through; change the logic so an empty
self.dm_allowed_users results in denying the DM (return false) and, when
checking membership, compare using the same normalized identifier as
SignalAdapter produces (or compare both the normalized UUID and the original raw
phone identifier used by bindings) so that contains(&message.sender_id) actually
matches real binding entries; update the check around self.dm_allowed_users and
the membership test that references message.sender_id to use the normalized
id(s).
src/config/permissions.rs (1)

393-419: ⚠️ Potential issue | 🟠 Major

Wildcard branches shouldn't short-circuit the rest of permission merging.

Each return Self { ... } here exits before the remaining binding-derived allowlists are collected. For example, group_ids = ["*"] skips later dm_allowed_users / group_allowed_users, and dm_allowed_users = ["*"] drops the configured group-user list. Set the wildcard for that field, then keep merging the others.

Also applies to: 449-504

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/permissions.rs` around lines 393 - 419, The current wildcard
handling in the group_filter/dm_allowed_users/group_allowed_users logic returns
early (using return Self { ... }) when encountering "*" in seed_group_ids or
binding.group_ids or dm_allowed_users, which prevents subsequent merging of
allowlists; instead, modify the logic in the functions that build group_filter,
dm_allowed_users, and group_allowed_users (look for usages of seed_group_ids,
signal_bindings, and binding.group_ids / binding.dm_allowed_users) to set the
respective field to a wildcard marker (e.g., set group_filter to
Some(vec!["*".to_string())] or mark dm_allowed_users/group_allowed_users as
wildcard) and then continue processing remaining bindings so other allowlists
are still collected and merged; apply the same fix to the other identical block
around the 449-504 region so wildcards no longer short-circuit merging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/config/types.rs`:
- Around line 1481-1502: The group-matching logic currently treats
self.group_ids and self.group_allowed_users as alternatives; change it so each
non-empty selector is required (cumulative narrowing): for the "group" branch in
the matching function, compute group_allowed only if self.group_ids is non-empty
and return false if that check fails, and likewise compute sender_allowed only
if self.group_allowed_users is non-empty and return false if that fails; use
message.metadata.get("signal_group_id") and message.sender_id as the inputs and
ensure both non-empty selectors must pass instead of using a single OR check.

In `@src/tools/send_message_to_another_channel.rs`:
- Around line 355-366: The tests use a non-canonical fake UUID so
parse_explicit_signal_target returns None; update the fixtures to use a real
canonical UUID string (for example "123e4567-e89b-12d3-a456-426655440000").
Change the parses_signal_bare_uuid test to call parse_explicit_signal_target
with that bare UUID and assert target.adapter == "signal" and target.target ==
"uuid:123e4567-e89b-12d3-a456-426655440000", and also update
parses_signal_uuid_prefixed to use
"signal:uuid:123e4567-e89b-12d3-a456-426655440000" so both tests exercise the
canonical-UUID parsing path in parse_explicit_signal_target.

---

Outside diff comments:
In `@src/agent/channel.rs`:
- Around line 1325-1333: The call is using self.current_adapter() which
collapses named adapters (e.g., "signal:support") to their transport only;
instead propagate and use the full per-channel adapter key stored on the message
(message.adapter) when invoking run_agent_turn and any other places that
reconstruct adapters (also at the other call site around run_agent_turn lines
~1625-1637). Replace usages of self.current_adapter() for per-message operations
with the message.adapter (or a function that returns the full adapter key from
the Message struct) so named instances are preserved for registering channel
tools and sending follow-ups.

---

Duplicate comments:
In `@src/api/bindings.rs`:
- Around line 157-166: The Signal-specific fields group_ids and
group_allowed_users are not persisted in update_binding(), so edits to those
properties silently no-op; modify update_binding() to read the incoming
request's group_ids and group_allowed_users (respecting empty-to-clear
semantics) and write them to the stored Binding record just like
create_binding() and list_bindings() do, ensuring you handle serde/default-empty
Vecs and retain require_mention/dm_allowed_users behavior consistent with the
create path.

In `@src/config/permissions.rs`:
- Around line 393-419: The current wildcard handling in the
group_filter/dm_allowed_users/group_allowed_users logic returns early (using
return Self { ... }) when encountering "*" in seed_group_ids or
binding.group_ids or dm_allowed_users, which prevents subsequent merging of
allowlists; instead, modify the logic in the functions that build group_filter,
dm_allowed_users, and group_allowed_users (look for usages of seed_group_ids,
signal_bindings, and binding.group_ids / binding.dm_allowed_users) to set the
respective field to a wildcard marker (e.g., set group_filter to
Some(vec!["*".to_string())] or mark dm_allowed_users/group_allowed_users as
wildcard) and then continue processing remaining bindings so other allowlists
are still collected and merged; apply the same fix to the other identical block
around the 449-504 region so wildcards no longer short-circuit merging.

In `@src/config/types.rs`:
- Around line 1504-1510: The current DM allowlist logic treats an empty
dm_allowed_users as allow-all and only compares message.sender_id (a normalized
UUID), which lets mismatched binding entries (e.g., phone numbers) slip through;
change the logic so an empty self.dm_allowed_users results in denying the DM
(return false) and, when checking membership, compare using the same normalized
identifier as SignalAdapter produces (or compare both the normalized UUID and
the original raw phone identifier used by bindings) so that
contains(&message.sender_id) actually matches real binding entries; update the
check around self.dm_allowed_users and the membership test that references
message.sender_id to use the normalized id(s).

In `@src/config/watcher.rs`:
- Around line 233-239: The watcher currently creates a fresh ArcSwap for signal
permissions and only gives it to the new adapter, so later reloads don't update
that adapter; change the logic around signal_permissions and
SignalPermissions::from_config so that when you create a new ArcSwap (the
ArcSwap holding SignalPermissions produced by SignalPermissions::from_config)
you also store that ArcSwap into the shared watcher state (the
signal_permissions Option) instead of only passing it to the adapter; ensure the
code path that calls perms.store(Arc::new(new_perms)) always operates on the
ArcSwap instance that is kept in signal_permissions (create-and-insert into
signal_permissions when it was None, and reuse the existing ArcSwap when Some)
so subsequent reloads update all adapters.

In `@src/messaging/signal.rs`:
- Around line 887-897: The current send() branch only treats transport errors as
failures, but reqwest::RequestBuilder::send().await returns Ok(Response) for
HTTP 4xx/5xx so the typing loop keeps retrying; after awaiting
client.post(&rpc_url)...send().await, inspect the Response and treat non-success
statuses as errors (e.g., call response.error_for_status() or check
!response.status().is_success()) and log the error and break the loop similarly
to the Err branch; update the block around
client.post(&rpc_url)...timeout(RPC_REQUEST_TIMEOUT)...json(&body).send().await
to handle and break on non-2xx responses (same behavior as rpc_request()).
- Around line 105-112: redact_url currently leaks embedded credentials by
splitting on '/'; update redact_url to parse the input with a URL parser (e.g.,
Url::parse) and reconstruct the redacted string using the scheme and host (and
port if present) only, explicitly omitting username/password and
path/query/fragment; locate the function redact_url and replace the split/join
logic with parsing and building "scheme://host" (including ":port" when
Url::port_or_known_default indicates a non-default port) to ensure credentials
are never included.

In `@src/messaging/target.rs`:
- Around line 335-374: parse_signal_target_parts currently special-cases
e164/phone forms instead of reusing normalize_signal_target, causing
inconsistent normalization; update parse_signal_target_parts to call
normalize_signal_target for branches that handle phone/e164 or single-phone
inputs (both default adapter and named adapter cases) and only construct
BroadcastTarget when normalize_signal_target returns Some(normalized_target),
using adapter "signal" or format!("signal:{instance}") as before; remove the
duplicated phone/e164 normalization logic so numeric-only inputs accepted by
normalize_signal_target are handled consistently.
- Around line 104-122: When resolving Signal channels in this branch, do not
hard-code the adapter as "signal" by calling parse_delivery_target with
"signal:{signal_target}"; instead extract the adapter name from channel.id (the
first part before the first ':') and combine that adapter with the recipient
from platform_meta["signal_target"] so adapter selection uses the channel.id
while the metadata supplies only the recipient payload; update the logic around
parse_delivery_target and parse_signal_target_parts (and the parts extraction
from channel.id) so that when signal_target exists you call
parse_delivery_target with "<adapter>:<signal_target>" (adapter taken from
channel.id) and otherwise continue to parse parts from channel.id via
parse_signal_target_parts.

In `@src/tools/send_message_to_another_channel.rs`:
- Around line 288-290: The current bare-digit check in
send_message_to_another_channel.rs incorrectly converts plain numeric strings to
a Signal delivery target; change the logic so only explicit Signal markers are
accepted: only call parse_delivery_target for inputs that either start with
"signal:" or start with '+' followed by digits (e.g., "+1234567"); leave purely
numeric trimmed strings untouched so ChannelStore::find_by_name() can resolve
numeric channel IDs. Ensure you still construct the "signal:+{trimmed_digits}"
form when handling a leading '+' case and keep parse_delivery_target usage for
the explicit-signal branch.
- Around line 150-156: The current logic overwrites explicitly resolved Signal
adapters (e.g., "signal:work:+1555") by checking only that self.current_adapter
starts_with("signal"); instead, only inherit the current adapter when the parsed
target was the generic "signal". Change the guard in the block that sets
explicit_target.adapter to ensure explicit_target.adapter == "signal" (and still
optionally verify self.current_adapter.starts_with("signal")) before assigning
self.current_adapter.clone(); reference the symbols explicit_target.adapter and
self.current_adapter to locate and update the condition within the
send_message_to_another_channel logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: da324a68-d864-43e9-b033-6679c394fc6b

📥 Commits

Reviewing files that changed from the base of the PR and between 6fdde46 and 0d2bf8f.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock, !**/*.lock
  • Cargo.toml is excluded by !**/*.toml
📒 Files selected for processing (23)
  • prompts/en/adapters/signal.md.j2
  • prompts/en/tools/send_message_description.md.j2
  • src/agent/channel.rs
  • src/agent/channel_history.rs
  • src/api/bindings.rs
  • src/config.rs
  • src/config/load.rs
  • src/config/permissions.rs
  • src/config/toml_schema.rs
  • src/config/types.rs
  • src/config/watcher.rs
  • src/conversation/worker_transcript.rs
  • src/llm/model.rs
  • src/main.rs
  • src/messaging.rs
  • src/messaging/signal.rs
  • src/messaging/target.rs
  • src/prompts/engine.rs
  • src/prompts/text.rs
  • src/secrets/scrub.rs
  • src/secrets/store.rs
  • src/tools.rs
  • src/tools/send_message_to_another_channel.rs
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/tools.rs
  • src/llm/model.rs
  • src/prompts/text.rs
  • src/agent/channel_history.rs
  • src/messaging.rs
  • src/conversation/worker_transcript.rs
  • src/secrets/store.rs
  • src/secrets/scrub.rs
  • prompts/en/adapters/signal.md.j2
  • prompts/en/tools/send_message_description.md.j2

@ibhagwan ibhagwan force-pushed the feat/signal-adapter branch from 0d2bf8f to aa8342e Compare March 7, 2026 08:09
ibhagwan added a commit to ibhagwan/spacebot that referenced this pull request Mar 7, 2026
…eapp#347)

Fixes critical issues identified in code review:

**Critical:**
- conversation/worker_transcript.rs: Restore TranscriptStep::UserText emission to prevent user messages being mislabeled as agent text
- config/permissions.rs: Separate DM and group permissions to prevent group wildcards from granting DM access
- messaging/signal.rs: Move HTTP status check before empty body check to properly fail on non-2xx responses
- messaging/signal.rs: Sanitize attachment filenames to prevent path traversal vulnerabilities
- messaging/signal.rs: Add source_uuid fallback for sender resolution in privacy-mode envelopes

**Medium:**
- agent/channel.rs: Use .ok() instead of let _ = for best-effort error channel sends
- config/types.rs: Add SecretField for Signal account to treat phone number as sensitive
- prompts/engine.rs: Register adapters/signal template for proper Signal-specific guidance
- tools/send_message_to_another_channel.rs: Only overwrite adapter when explicit target has no adapter specified
- agent/channel.rs: Add adapter parameter to run_agent_turn, prefer InboundMessage.adapter to preserve named-instance context
- api/bindings.rs: Add group_ids and group_allowed_users to UpdateBindingRequest for symmetric updates

**Low:**
- agent/channel_history.rs: Condition space before sender_context on non-empty value to prevent double spacing

All changes pass cargo check.
@ibhagwan ibhagwan force-pushed the feat/signal-adapter branch 2 times, most recently from 09cc301 to 83b8f72 Compare March 7, 2026 16:55
@ibhagwan ibhagwan marked this pull request as ready for review March 7, 2026 17:24
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/api/bindings.rs (1)

448-478: ⚠️ Potential issue | 🟠 Major

Refresh the live Signal permission snapshot after these writes.

These new Signal-specific binding fields are persisted, but the post-write reload path in this file still only updates the Discord/Slack permission snapshots. A create/update through the API can therefore leave the running Signal adapter on stale DM/group filters until some other reload path runs. Mirror the Signal permission refresh here as well, and in the delete reload path for consistency.

Also applies to: 815-849

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/bindings.rs` around lines 448 - 478, The new Signal-specific binding
fields (e.g., updates to binding_table via request.group_ids,
request.channel_ids, request.dm_allowed_users, request.group_allowed_users,
request.require_mention) are persisted but the post-write reload currently only
refreshes Discord/Slack snapshots; update the create/update code path to also
call the Signal permission snapshot refresh (same routine used elsewhere to
reload Signal DM/group filters) immediately after writing these binding_table
changes, and mirror the same Signal-refresh call in the delete reload path
handling deletions so Signal adapter state is consistently updated after
create/update/delete operations.
♻️ Duplicate comments (3)
src/main.rs (1)

1489-1525: ⚠️ Potential issue | 🟠 Major

Retain a shared permissions handle for every Signal instance.

Only the default adapter’s signal_permissions is kept alive and passed into spawn_file_watcher(). Each named Signal instance gets a brand-new ArcSwap here and then drops the last external reference, so reloading dm_allowed_users, group_ids, or group_allowed_users will not update already-running named Signal adapters. This needs a runtime-keyed registry of Arc<ArcSwap<SignalPermissions>> values that both startup and hot-start paths reuse.

Also applies to: 2995-3048

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main.rs` around lines 1489 - 1525, The code currently creates
per-instance Signal permission ArcSwap values and drops external references so
hot-reloads don't update running named Signal adapters; fix this by introducing
a runtime-keyed registry (e.g., HashMap<String, Arc<ArcSwap<SignalPermissions>>>
or a concurrent registry) that stores and reuses Arc<ArcSwap<SignalPermissions>>
for every Signal instance name, ensure initialize_agents looks up/creates
entries in this registry instead of creating throwaway ArcSwaps, and pass the
shared Arc<ArcSwap<SignalPermissions>> handles (not ephemeral values) into
spawn_file_watcher and any adapter startup paths so both initial startup and
hot-start reuse the same permission handles (refer to symbols:
signal_permissions, initialize_agents, spawn_file_watcher, SignalPermissions,
ArcSwap).
src/messaging/signal.rs (1)

913-925: ⚠️ Potential issue | 🟠 Major

Avoid logging raw typing-error bodies.

This warn! path logs the full RPC response body, which can echo Signal phone numbers, UUIDs, or account details from the daemon. For a transient typing failure, keep the warning to status only, or move a truncated/redacted body to debug.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/signal.rs` around lines 913 - 925, The warn path in the
response handling for the Signal typing indicator currently logs the full
response body (body_text) which may contain sensitive data; change the
tracing::warn call in this block to only include the HTTP status (remove
%body_text) and keep the "signal typing indicator request failed" message, and
if you still want the body for debugging, emit a truncated or redacted version
at debug level using tracing::debug with a safe truncation/redaction helper
(referencing the same response, body_text variable and the existing warn message
string to locate the block).
src/config/types.rs (1)

1529-1550: ⚠️ Potential issue | 🟠 Major

Keep group_ids and group_allowed_users cumulative.

This still matches a Signal group binding when either selector passes. A binding with both fields set currently accepts anyone in an allowed group or an allowed sender in any group, which widens routing instead of narrowing it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 1529 - 1550, The group-matching logic in
the chat_type "group" branch incorrectly allows either group_ids OR
group_allowed_users to pass independently; change it so that when both
self.group_ids and self.group_allowed_users are non-empty both conditions must
be true, otherwise require whichever list is configured. Concretely, in the
match arm handling Some("group") (around the group_allowed and sender_allowed
calculations), replace the final check (currently if !group_allowed &&
!sender_allowed { return false; }) with: if both lists are non-empty require
(group_allowed && sender_allowed) else if only group_ids is non-empty require
group_allowed else if only group_allowed_users is non-empty require
sender_allowed; return false when the required condition is not met.
🧹 Nitpick comments (1)
src/api/projects.rs (1)

856-856: Use a descriptive closure parameter name.

The sorting refactor is correct and idiomatic, but the closure parameter b should use a non-abbreviated name like entry for clarity.

As per coding guidelines: "Use non-abbreviated variable names in Rust code: queue not q, message not msg, channel not ch".

Suggested fix
-    entries.sort_by_key(|b| std::cmp::Reverse(b.bytes));
+    entries.sort_by_key(|entry| std::cmp::Reverse(entry.bytes));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/projects.rs` at line 856, Rename the closure parameter in the
sort_by_key call to a non-abbreviated name for clarity: change the closure used
on entries.sort_by_key(|b| std::cmp::Reverse(b.bytes)) to use a descriptive
parameter like entry (e.g., entries.sort_by_key(|entry|
std::cmp::Reverse(entry.bytes))) so the purpose of the variable is clear when
referencing the entry.bytes field.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/config/permissions.rs`:
- Around line 464-487: The config-sourced allowlists (seed_dm_allowed_users and
seed_group_allowed_users) are not trimmed before use, causing mismatches with
binding-derived trimmed entries; update the initialization of dm_users and
group_users to clone and normalize by trimming each entry and skipping empty
strings (e.g., map/iter trim + filter) so dm_wildcard and group_wildcard
detection and subsequent contains checks use trimmed values consistently with
the binding handling in the loop over signal_bindings.

In `@src/config/types.rs`:
- Around line 1542-1559: The current checks against dm_allowed_users and
group_allowed_users only test message.sender_id (which may be populated with
sourceUuid) and thus miss entries configured as phone numbers; update the
permission checks in the DM and group branches to consider both identities by
checking membership against message.sender_id, message.signal_source_number and
message.signal_source_uuid (or whichever fields hold the E164 number and UUID on
the Message struct). Concretely, replace the single contains(&message.sender_id)
tests used in the group_allowed_users / dm_allowed_users logic with a combined
predicate that returns true if any of the three identifiers are contained,
ensuring both phone-number and UUID-configured allowed users are matched.

In `@src/messaging/target.rs`:
- Around line 112-125: The code incorrectly infers a named adapter from
channel.id part count and returns an unnormalized signal_target; change the
logic in the function that builds BroadcastTarget so it (1) determines adapter
from the actual shape/prefix of signal_target (accepting exactly "uuid:...",
"group:...", or phone "+..."), (2) normalizes bare UUID reply targets by
prepending "uuid:" where needed, and (3) stops using channel.id.split count to
choose a named adapter; update the BroadcastTarget construction to use the
normalized target and adapter derived from signal_target so
SignalAdapter::broadcast will accept the value.

In `@src/tools/send_message_to_another_channel.rs`:
- Around line 111-117: The code treats any args.target starting with "signal:"
as an explicit adapter target, which incorrectly bypasses the current named
Signal adapter; instead call parse_signal_target_parts(&args.target) and use its
returned adapter value to decide: only consider it an explicit instance target
when adapter is Some(x) and x != "signal" (i.e., a named adapter like
"signal:work"); update the conditional that now uses
args.target.starts_with("signal:") to check the parsed adapter accordingly, and
make the same change in the other occurrence around the 147-160 block so
unscoped forms like signal:uuid:... still route to the current_adapter when set.

---

Outside diff comments:
In `@src/api/bindings.rs`:
- Around line 448-478: The new Signal-specific binding fields (e.g., updates to
binding_table via request.group_ids, request.channel_ids,
request.dm_allowed_users, request.group_allowed_users, request.require_mention)
are persisted but the post-write reload currently only refreshes Discord/Slack
snapshots; update the create/update code path to also call the Signal permission
snapshot refresh (same routine used elsewhere to reload Signal DM/group filters)
immediately after writing these binding_table changes, and mirror the same
Signal-refresh call in the delete reload path handling deletions so Signal
adapter state is consistently updated after create/update/delete operations.

---

Duplicate comments:
In `@src/config/types.rs`:
- Around line 1529-1550: The group-matching logic in the chat_type "group"
branch incorrectly allows either group_ids OR group_allowed_users to pass
independently; change it so that when both self.group_ids and
self.group_allowed_users are non-empty both conditions must be true, otherwise
require whichever list is configured. Concretely, in the match arm handling
Some("group") (around the group_allowed and sender_allowed calculations),
replace the final check (currently if !group_allowed && !sender_allowed { return
false; }) with: if both lists are non-empty require (group_allowed &&
sender_allowed) else if only group_ids is non-empty require group_allowed else
if only group_allowed_users is non-empty require sender_allowed; return false
when the required condition is not met.

In `@src/main.rs`:
- Around line 1489-1525: The code currently creates per-instance Signal
permission ArcSwap values and drops external references so hot-reloads don't
update running named Signal adapters; fix this by introducing a runtime-keyed
registry (e.g., HashMap<String, Arc<ArcSwap<SignalPermissions>>> or a concurrent
registry) that stores and reuses Arc<ArcSwap<SignalPermissions>> for every
Signal instance name, ensure initialize_agents looks up/creates entries in this
registry instead of creating throwaway ArcSwaps, and pass the shared
Arc<ArcSwap<SignalPermissions>> handles (not ephemeral values) into
spawn_file_watcher and any adapter startup paths so both initial startup and
hot-start reuse the same permission handles (refer to symbols:
signal_permissions, initialize_agents, spawn_file_watcher, SignalPermissions,
ArcSwap).

In `@src/messaging/signal.rs`:
- Around line 913-925: The warn path in the response handling for the Signal
typing indicator currently logs the full response body (body_text) which may
contain sensitive data; change the tracing::warn call in this block to only
include the HTTP status (remove %body_text) and keep the "signal typing
indicator request failed" message, and if you still want the body for debugging,
emit a truncated or redacted version at debug level using tracing::debug with a
safe truncation/redaction helper (referencing the same response, body_text
variable and the existing warn message string to locate the block).

---

Nitpick comments:
In `@src/api/projects.rs`:
- Line 856: Rename the closure parameter in the sort_by_key call to a
non-abbreviated name for clarity: change the closure used on
entries.sort_by_key(|b| std::cmp::Reverse(b.bytes)) to use a descriptive
parameter like entry (e.g., entries.sort_by_key(|entry|
std::cmp::Reverse(entry.bytes))) so the purpose of the variable is clear when
referencing the entry.bytes field.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0083118d-6db4-48e5-8100-cd8591396949

📥 Commits

Reviewing files that changed from the base of the PR and between fd80a7c and 83b8f72.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock, !**/*.lock
  • Cargo.toml is excluded by !**/*.toml
📒 Files selected for processing (25)
  • prompts/en/adapters/signal.md.j2
  • prompts/en/tools/send_message_description.md.j2
  • src/agent/channel.rs
  • src/agent/channel_history.rs
  • src/api/bindings.rs
  • src/api/projects.rs
  • src/config.rs
  • src/config/load.rs
  • src/config/permissions.rs
  • src/config/toml_schema.rs
  • src/config/types.rs
  • src/config/watcher.rs
  • src/conversation/worker_transcript.rs
  • src/llm/model.rs
  • src/main.rs
  • src/messaging.rs
  • src/messaging/signal.rs
  • src/messaging/target.rs
  • src/prompts/engine.rs
  • src/prompts/text.rs
  • src/secrets/scrub.rs
  • src/secrets/store.rs
  • src/tools.rs
  • src/tools/send_message_to_another_channel.rs
  • tests/context_dump.rs
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/prompts/engine.rs
  • src/secrets/scrub.rs
  • src/tools.rs
  • src/agent/channel_history.rs
  • src/prompts/text.rs
  • src/llm/model.rs
  • prompts/en/tools/send_message_description.md.j2
  • src/secrets/store.rs
  • src/conversation/worker_transcript.rs
  • prompts/en/adapters/signal.md.j2

@ibhagwan
Copy link
Author

ibhagwan commented Mar 7, 2026

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/messaging/signal.rs (1)

654-660: ⚠️ Potential issue | 🟠 Major

Canonicalize UUID DM conversation IDs.

UUID-backed DMs currently become signal:{uuid}. The shared target resolver rebuilds tracked Signal DMs from signal:uuid:{uuid} / signal:+..., so sending to a tracked UUID-only Signal conversation will fail once it goes through channel resolution instead of the direct reply path. The regression test below should be updated to lock the canonical shape in.

🛠️ Suggested fix
         let base_conversation_id = if let Some(gid) = group_id {
             format!("signal:group:{gid}")
-        } else {
-            // Use UUID if available (stable), fall back to phone number.
-            let identifier = envelope.source_uuid.as_deref().unwrap_or(&sender);
-            format!("signal:{identifier}")
+        } else if let Some(uuid) = envelope.source_uuid.as_deref() {
+            format!("signal:uuid:{uuid}")
+        } else {
+            format!("signal:{sender}")
         };
...
-        assert_eq!(msg.conversation_id, "signal:uuid-1234");
+        assert_eq!(msg.conversation_id, "signal:uuid:uuid-1234");

Also applies to: 1843-1862

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/signal.rs` around lines 654 - 660, The DM conversation ID must
canonicalize UUID-backed Signal targets to the form "signal:uuid:{uuid}" so
channel resolution matches tracked IDs; update the base_conversation_id
construction in signal.rs (the block that sets base_conversation_id, using
group_id, envelope.source_uuid and sender) to format as "signal:uuid:{uuid}"
when envelope.source_uuid is Some, otherwise fall back to "signal:{sender}" (or
the phone-number form), and update the related regression test to assert the
canonical "signal:uuid:{uuid}" shape for UUID DMs.
🧹 Nitpick comments (2)
src/config/types.rs (2)

2471-2484: Consider redacting phone numbers in Debug output for Signal allowed-users lists.

Unlike other adapters where dm_allowed_users contains platform-specific IDs (Discord snowflakes, Slack user IDs, etc.), Signal's lists can contain E.164 phone numbers per the documentation. Phone numbers are generally considered more sensitive PII.

This is consistent with the existing adapter pattern, so flagging as optional. If you decide to redact, a simple &"[REDACTED]" like account would suffice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 2471 - 2484, The Debug impl for
SignalInstanceConfig currently prints dm_allowed_users and group_allowed_users
which may contain E.164 phone numbers; update the impl for SignalInstanceConfig
(the std::fmt::Debug implementation) to redact those lists by replacing the
.field calls that reference dm_allowed_users and group_allowed_users with a
literal placeholder (e.g., &"[REDACTED]") similar to how account and http_url
are redacted so phone numbers are not emitted in debug output.

1537-1542: Avoid string allocation in group ID comparison.

The contains(&gid.to_string()) creates a new String on each comparison. Use iterator-based comparison to avoid allocation.

♻️ Suggested improvement
-                    } else {
-                        message
-                            .metadata
-                            .get("signal_group_id")
-                            .and_then(|v| v.as_str())
-                            .is_some_and(|gid| self.group_ids.contains(&gid.to_string()))
-                    };
+                    } else {
+                        message
+                            .metadata
+                            .get("signal_group_id")
+                            .and_then(|v| v.as_str())
+                            .is_some_and(|gid| self.group_ids.iter().any(|id| id == gid))
+                    };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 1537 - 1542, The comparison currently
allocates with contains(&gid.to_string()); change it to avoid allocation by
comparing the &str `gid` against the stored Strings directly, e.g. replace the
contains call with an iterator check like `self.group_ids.iter().any(|id| id ==
gid)` so the closure `|gid| ...` uses `self.group_ids.iter().any(|id| id ==
gid)` instead of `contains(&gid.to_string())`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/messaging/target.rs`:
- Around line 339-353: The helper extract_signal_adapter_from_channel_id
currently mis-parses named Signal channels like "signal:{instance}:uuid:{...}"
because splitting on ':' yields the third component as "uuid" or "group" (not
"uuid:{...}"); update extract_signal_adapter_from_channel_id to detect the
pattern when parts[0] == "signal" and parts[2] is "uuid" or "group" (or parts[2]
starts_with '+' / starts_with "uuid:" / "group:" to be robust) and return the
named adapter format!("signal:{instance}") in those cases; otherwise fall back
to the default "signal". Ensure you reference the function name
extract_signal_adapter_from_channel_id and the match arm handling ["signal",
instance, target_part, ..] when making the change.

In `@src/tools/send_message_to_another_channel.rs`:
- Around line 165-168: The tracing::info calls that log adapter and
broadcast_target (e.g., the one with adapter = %explicit_target.adapter,
broadcast_target = %explicit_target.target, "message sent via explicit target")
are leaking Signal recipient identifiers; update those log sites to redact or
omit the broadcast_target when explicit_target.adapter indicates Signal (or when
adapter == "signal"), e.g., replace broadcast_target with a masked value (like
"[REDACTED]" or a hash) or drop the field entirely for Signal sends; apply the
same change to the analogous tracing::info call around the other branch
mentioned (the similar block at lines ~235-238) so no successful Signal send
logs include raw recipient PII.

---

Duplicate comments:
In `@src/messaging/signal.rs`:
- Around line 654-660: The DM conversation ID must canonicalize UUID-backed
Signal targets to the form "signal:uuid:{uuid}" so channel resolution matches
tracked IDs; update the base_conversation_id construction in signal.rs (the
block that sets base_conversation_id, using group_id, envelope.source_uuid and
sender) to format as "signal:uuid:{uuid}" when envelope.source_uuid is Some,
otherwise fall back to "signal:{sender}" (or the phone-number form), and update
the related regression test to assert the canonical "signal:uuid:{uuid}" shape
for UUID DMs.

---

Nitpick comments:
In `@src/config/types.rs`:
- Around line 2471-2484: The Debug impl for SignalInstanceConfig currently
prints dm_allowed_users and group_allowed_users which may contain E.164 phone
numbers; update the impl for SignalInstanceConfig (the std::fmt::Debug
implementation) to redact those lists by replacing the .field calls that
reference dm_allowed_users and group_allowed_users with a literal placeholder
(e.g., &"[REDACTED]") similar to how account and http_url are redacted so phone
numbers are not emitted in debug output.
- Around line 1537-1542: The comparison currently allocates with
contains(&gid.to_string()); change it to avoid allocation by comparing the &str
`gid` against the stored Strings directly, e.g. replace the contains call with
an iterator check like `self.group_ids.iter().any(|id| id == gid)` so the
closure `|gid| ...` uses `self.group_ids.iter().any(|id| id == gid)` instead of
`contains(&gid.to_string())`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f7c2af63-65a7-4f67-9198-2d59bdd02fee

📥 Commits

Reviewing files that changed from the base of the PR and between 83b8f72 and 3977083.

📒 Files selected for processing (6)
  • src/api/projects.rs
  • src/config/permissions.rs
  • src/config/types.rs
  • src/messaging/signal.rs
  • src/messaging/target.rs
  • src/tools/send_message_to_another_channel.rs

@ibhagwan
Copy link
Author

ibhagwan commented Mar 7, 2026

@jamiepine, addressed everything by coderabbit/tembo, fmt, clippy and signal tests pass (some local test failures unrelated to the code, I believe will pass in the CI), I think this is ready

ibhagwan added a commit to ibhagwan/spacebot that referenced this pull request Mar 8, 2026
…eapp#347)

Fixes critical issues identified in code review:

**Critical:**
- conversation/worker_transcript.rs: Restore TranscriptStep::UserText emission to prevent user messages being mislabeled as agent text
- config/permissions.rs: Separate DM and group permissions to prevent group wildcards from granting DM access
- messaging/signal.rs: Move HTTP status check before empty body check to properly fail on non-2xx responses
- messaging/signal.rs: Sanitize attachment filenames to prevent path traversal vulnerabilities
- messaging/signal.rs: Add source_uuid fallback for sender resolution in privacy-mode envelopes

**Medium:**
- agent/channel.rs: Use .ok() instead of let _ = for best-effort error channel sends
- config/types.rs: Add SecretField for Signal account to treat phone number as sensitive
- prompts/engine.rs: Register adapters/signal template for proper Signal-specific guidance
- tools/send_message_to_another_channel.rs: Only overwrite adapter when explicit target has no adapter specified
- agent/channel.rs: Add adapter parameter to run_agent_turn, prefer InboundMessage.adapter to preserve named-instance context
- api/bindings.rs: Add group_ids and group_allowed_users to UpdateBindingRequest for symmetric updates

**Low:**
- agent/channel_history.rs: Condition space before sender_context on non-empty value to prevent double spacing

All changes pass cargo check.
@ibhagwan ibhagwan force-pushed the feat/signal-adapter branch from 71323f6 to 74d1e53 Compare March 8, 2026 00:59
ibhagwan added 17 commits March 7, 2026 20:32
When an LLM call fails (e.g., provider error like StepFun's 'Unrecognized chat
message'), the channel was only logging the error and sending nothing to the
user. This led to confusing silent failures where the user received no response
and had to check logs to understand what happened.

Changes:
- src/agent/channel.rs: Modified error handler to send error to user
  - Formats error message and sends via response_tx
  - User now sees: 'I encountered an error: ...'
  - Still logs full error for debugging

This ensures users always receive feedback when something goes wrong, even if
it's an internal/provider error rather than a channel bug.
Implements Signal messaging support using the signal-cli daemon HTTP API,
following the existing adapter architecture (Telegram, Discord, Slack, Twitch).

- Inbound: SSE stream with automatic reconnection and exponential backoff
  (2s → 60s), UTF-8 chunk boundary handling, buffer overflow protection.
- Outbound: JSON-RPC send calls. DM recipients must be a JSON array.
- Typing indicators: JSON-RPC sendTyping with ~5s expiry.
- Attachments: Temp files in {instance_dir}/tmp/, auto-cleaned after send.
- Streaming: Not supported (Signal can't edit messages).
- Permissions: DM allowlist + group filter (None = block all groups).

Config types in types.rs, toml_schema.rs, load.rs. SignalPermissions in
permissions.rs with from_config/from_instance_config. Hot-reload support in
watcher.rs. 23 unit tests.

Add to config.toml:
```toml
[messaging.signal]
enabled = true
http_url = "http://127.0.0.1:8686"
account = "+1234567890"
dm_allowed_users = ["+0987654321"]
group_ids = ["<uuid1>", "<uuid2>"]
group_allowed_users = ["+5566778899", "+1122334455"]
ignore_stories = true

[[messaging.signal.instances]]
name = "work"
enabled = true
http_url = "http://127.0.0.1:8687"
account = "+1122334455"
dm_allowed_users = ["+5566778899"]
group_ids = ["<uuid1>", "<uuid2>"]
group_allowed_users = ["+1122334455"]
```

Requires signal-cli daemon running: `signal-cli daemon --http`

Closes spacedriveapp#310
…eapp#347)

Fixes critical issues identified in code review:

**Critical:**
- conversation/worker_transcript.rs: Restore TranscriptStep::UserText emission to prevent user messages being mislabeled as agent text
- config/permissions.rs: Separate DM and group permissions to prevent group wildcards from granting DM access
- messaging/signal.rs: Move HTTP status check before empty body check to properly fail on non-2xx responses
- messaging/signal.rs: Sanitize attachment filenames to prevent path traversal vulnerabilities
- messaging/signal.rs: Add source_uuid fallback for sender resolution in privacy-mode envelopes

**Medium:**
- agent/channel.rs: Use .ok() instead of let _ = for best-effort error channel sends
- config/types.rs: Add SecretField for Signal account to treat phone number as sensitive
- prompts/engine.rs: Register adapters/signal template for proper Signal-specific guidance
- tools/send_message_to_another_channel.rs: Only overwrite adapter when explicit target has no adapter specified
- agent/channel.rs: Add adapter parameter to run_agent_turn, prefer InboundMessage.adapter to preserve named-instance context
- api/bindings.rs: Add group_ids and group_allowed_users to UpdateBindingRequest for symmetric updates

**Low:**
- agent/channel_history.rs: Condition space before sender_context on non-empty value to prevent double spacing

All changes pass cargo check.
Adds Signal-specific routing logic to Binding::matches in src/config/types.rs:

- Check signal_chat_type metadata (group, dm, or private)
- For group chats: validate against group_ids list OR check if sender is
  in group_allowed_users
- For DMs: validate sender against dm_allowed_users list
- Unknown chat types fail to match (return false)

This fixes the issue where resolve_agent_for_message would incorrectly
select the first signal binding regardless of group/DM permissions,
enabling proper multi-agent routing based on Signal group membership
and user allowlists.
...the fallback path.

If signal_target metadata is missing, this branch only recognizes signal:uuid:*, signal:group:*,
and signal:+*. Supported bare forms like signal:abc-123-def and signal:1234567890 then resolve to
None even though normalize_signal_target() accepts them.
..onto whatever adapter the current conversation uses.

Once parse_explicit_signal_target() returns a Signal target, this branch overwrites its adapter
with current_adapter unconditionally. From a Telegram/Discord/Slack conversation, signal:+1555...
would therefore be broadcast through the wrong adapter instead of Signal.
Replaces loose hyphen-based UUID detection with strict UUID format validation

using uuid::Uuid::parse_str to prevent misclassifying hyphenated names

as Signal UUIDs.

Before: 'my-channel-name' would be treated as a Signal UUID

After: Only valid 8-4-4-4-12 hexadecimal UUIDs are accepted

The is_valid_uuid helper ensures proper UUID parsing before

constructing the signal:uuid: target prefix.
Adds PII redaction helpers to prevent sensitive Signal identifiers from

being logged in plain text:

- redact_identifier(): Masks phone numbers and UUIDs, keeping only

  first/last 4 characters (e.g., '+123****8901' or 'abcd****wxyz')

- redact_url(): Keeps only scheme and domain, removing path/query

Applied to all tracing::info calls in the Signal adapter:

- Group ID logging in permission rejection messages

- Sender ID logging in DM/group rejection messages

- Sender display in authorized message logging

- Account (phone number) in connection logging

- Health check URL in debug logging

This prevents phone numbers, UUIDs, group IDs, and any embedded

credentials in URLs from appearing in application logs.
Adds the broadcast method to SignalAdapter to enable SendMessageTool

to send explicit Signal targets.

The broadcast method parses target strings in uuid:xxx, group:xxx, or +xxx

format into RecipientTarget and delegates to send_text/send_file.

Handles Text, RichMessage, File, ThreadReply, Ephemeral, and ScheduledMessage

response types. Unsupported types are logged and dropped.

This fixes the no-op behavior where MessagingManager::broadcast would silently

succeed without actually sending Signal messages.

fix(signal): handle named adapters in resolve_broadcast_target

Updates resolve_broadcast_target to properly parse Signal conversation IDs

that include named adapter instances.

For default adapter (signal):

- signal:uuid:xxx -> adapter: signal, target: uuid:xxx

- signal:group:xxx -> adapter: signal, target: group:xxx

- signal:+xxx -> adapter: signal, target: +xxx

For named adapters (signal:instance):

- signal:instance:uuid:xxx -> adapter: signal:instance, target: uuid:xxx

- signal:instance:group:xxx -> adapter: signal:instance, target: group:xxx

- signal:instance:+xxx -> adapter: signal:instance, target: +xxx

Also uses parse_delivery_target for signal_target metadata to ensure

consistent handling with explicit target parsing.

This fixes send_message_to_another_channel failing to resolve Signal

channels created via named adapters.
…g issues

This commit fixes six critical issues in Signal messaging handling:

**1. Fix wildcard permission handling (src/config/permissions.rs)**
- Wildcard handling was prematurely returning before processing all
  signal_bindings, preventing dm_allowed_users and group_allowed_users
  from bindings from being merged into the final permissions.
- Changed wildcard branches to set flags and continue processing instead
  of early return, then build final Self after iterating all bindings.
- Now wildcards finalize only their own field while allowing other lists
  to be combined from multiple bindings.

**2. Fix inverted DM logic (src/config/types.rs)**
- Signal DM handling was inverted: when dm_allowed_users was empty,
  DMs were being allowed (logic only checked non-empty && !contains).
- Changed condition to block DMs when dm_allowed_users is empty OR
  when sender is not in the list. Now DMs are blocked when list is
  empty and only allowed for contained sender_ids.

**3. Fix typing indicator status check (src/messaging/signal.rs)**
- Typing indicator loop only handled transport errors and kept retrying
  on HTTP 4xx/5xx responses.
- Changed to capture Response on success, check status().is_success(),
  and break loop on server failures. Follows same pattern as rpc_request().

**4. Fix parse_signal_target_parts consistency (src/messaging/target.rs)**
- Function was handling e164 and phone branches directly instead of
  using the shared normalize_signal_target() function.
- Updated phone/e164 branches to call normalize_signal_target() and use
  its returned canonical phone string, ensuring consistent parsing and
  acceptance of bare numeric forms.

**5. Fix send_message_to_another_channel Signal detection (src/tools/send_message_to_another_channel.rs)**
- Bare digit branch was preempting ChannelStore lookup and wrongly
  treating numeric channel IDs as Signal numbers.
- Changed logic to require explicit Signal indicator (signal: prefix)
  for bare numeric strings, ensuring ChannelStore lookup remains the
  default resolution path for plain numeric strings.

**6. Fix coalesced batch adapter preservation (src/agent/channel.rs)**
- Batched turn was calling run_agent_turn with self.current_adapter()
  which could lose per-message adapter (e.g., signal:instance).
- Changed to extract adapter from messages (preferring message.adapter)
  and pass that adapter to run_agent_turn, matching single-message
  handling behavior.
Update parses_signal_uuid_prefixed and parses_signal_bare_uuid tests in
send_message_to_another_channel.rs to use valid canonical UUID strings
(123e4567-e89b-12d3-a456-426655440000) instead of non-canonical fake UUIDs.

This ensures the tests properly exercise the is_valid_uuid() validation
logic which uses uuid::Uuid::parse_str() requiring the standard 8-4-4-4-12
hexadecimal format.

Previous tests used invalid abc-123-def format which would fail actual
UUID validation but were being parsed by parse_signal_target_parts without
validation. Using real canonical UUIDs ensures the tests accurately test
the intended code paths.
…lidation

Fixes the following issues identified in code review:

1. channel.rs: Use message.adapter instead of message.source for source_adapter
   - Channel now prefers message.adapter (full adapter string like "signal:work")
   - Falls back to message.source only when adapter is not present
   - Batch adapter extraction now uses stored source_adapter instead of current_adapter()
   - This preserves per-message adapter info for Signal named instances

2. permissions.rs: Apply same validation to seed_group_ids as bindings
   - seed_group_ids now go through the same validation pipeline as signal_bindings
   - Validates: trim, empty check, wildcard handling, base64 format
   - Ensures both sources follow identical rules for group filter construction

3. signal.rs: Fix redact_url to properly strip userinfo
   - Replaced naive split("/") parsing with proper userinfo detection
   - Now correctly extracts scheme://host (or scheme://host:port)
   - Discards username:password@ patterns that could leak credentials

4. signal.rs: Add source_uuid to sender extraction fallback chain
   - UUID-only privacy-mode envelopes now preserve sender identity
   - Fallback order: source_number -> source_uuid -> source -> "unknown"

5. target.rs: Preserve named adapter when using signal_target metadata
   - When channel.id contains a named instance (e.g., "signal:work:uuid:xxx")
   - And signal_target metadata exists, the adapter portion is now preserved
   - Only the target portion is replaced from metadata

6. send_message_to_another_channel.rs: Respect explicit adapter in target
   - Only overwrites parsed adapter when target lacks "signal:" prefix
   - Prevents retargeting explicit named adapters like "signal:personal:..."
   - Maintains backward compatibility for bare UUID/phone inputs

All changes pass cargo fmt, cargo check, and preflight/gate-pr checks.
fix(signal): address review feedback on permissions, routing, and logging

This commit addresses multiple issues identified in review:

1. **config/permissions.rs**: Trim seed allowed users lists
   - Process seed_dm_allowed_users and seed_group_allowed_users with same
     trimming/filtering as binding entries for consistency
   - Map/trim each entry and filter empty strings before wildcard detection

2. **messaging/target.rs**: Fix Signal broadcast target resolution
   - Add extract_signal_adapter_from_channel_id() helper
   - Parse adapter from signal_target metadata format instead of relying
     on channel.id.split() count (which was error-prone)
   - Handle uuid:, group:, and +phone formats correctly

3. **tools/send_message_to_another_channel.rs**: Fix Signal target routing
   - Only named adapters (signal:work:...) are treated as explicit targets
   - Default signal:... targets now fall through to current_adapter
   - Added fallback to send via current adapter when channel not found

4. **messaging/signal.rs**: Remove sensitive data from logs
   - Removed body_text from warn! log (potential PII/sensitive data)
   - Log truncated body (200 chars max) at debug level only
   - Keep status code in warn log for troubleshooting

5. **api/projects.rs**: Rename variable for clarity
   - Changed |b| to |entry| in sort_by_key closure
Signal PII leaks and conversation ID canonicalization

1. Redact Signal recipient PII in send_message_to_another_channel.rs logs
   - Mask broadcast_target as "[REDACTED]" when adapter is Signal
   - Applied to both explicit target and current adapter code paths

2. Redact dm_allowed_users and group_allowed_users in SignalInstanceConfig Debug
   - Phone numbers in allowed user lists are now redacted in debug output
   - Consistent with existing redaction of account and http_url

3. Avoid allocation in group_ids check (config/types.rs:1541)
   - Replace contains(&gid.to_string()) with iter().any(|id| id == gid)
   - Eliminates unnecessary string allocation during permission checks

4. Canonicalize Signal DM conversation ID to "signal:uuid:{uuid}" format
   - UUID-backed targets now use explicit prefix for consistent channel resolution
   - Falls back to "signal:{sender}" when UUID unavailable
   - Updated regression test to assert canonical format

Also fixed pre-existing clippy warning:
- Replace metadata.get(...).is_none() with !metadata.contains_key(...)
@ibhagwan ibhagwan force-pushed the feat/signal-adapter branch from 74d1e53 to 5a3f77b Compare March 8, 2026 01:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Signal channel support using signal-cli

2 participants