Release: credential resolver, WhatsApp native, exec hardening, traces UI (#754)

* fix(ci): skip CI condition in semantic-release for main branch

go-semantic-release auto-detects the default branch from GitHub API
(which is dev), but releases are cut from main. The CI condition
rejects runs on non-default branches. Use --no-ci to bypass this
check since the workflow already gates on push to main.

* docs: document CI/CD pipelines, release flow, and v2.66.0 changelog

- CLAUDE.md: add CI/CD & Releases section with workflow table, tag
  patterns, Docker variants, beta/desktop release commands
- CONTRIBUTING.md: expand Releases section with standard (auto),
  beta (manual tag), and desktop release workflows
- docs/17-changelog.md: add v2.66.0 entry covering IDOR fix, BytePlus
  provider, per-agent grants, beta pipeline, and CI fixes

* fix(telegram): handle group-to-supergroup migration seamlessly

When a Telegram group upgrades to a supergroup, the chat ID changes and
all existing references become stale. This caused send failures (400),
orphaned sessions, and required manual re-pairing.

Add dual-path migration handling:
- Proactive: intercept inbound MigrateToChatID before isServiceMessage
- Reactive: detect 400 + MigrateToChatID on send, migrate DB, retry

DB migration updates in a single transaction (scoped by tenant + channel):
- paired_devices: sender_id, chat_id
- sessions: session_key, user_id
- channel_contacts: sender_id
- channel_pending_messages: history_key

Also invalidates in-memory caches (approvedGroups, pairingReplySent,
groupHistory) and handles media sends via migration retry in Send().

* fix(tools): quote-aware shell operator detection in credentialed exec (#700) (#702)

* fix(tools): quote-aware shell operator detection in credentialed exec (#700)

- Replace detectShellOperators with detectUnquotedShellOperators in
  credentialed exec path — respects single/double quoting so that
  characters like | inside argument values (e.g. --jq '.[0] | .name')
  are not falsely flagged as shell operators
- Pass raw command string (preserving quotes) to executeCredentialed
  instead of reconstructing from parsed args
- Downgrade "no credential found" log from Warn to Debug (fires for
  every non-credentialed command, too noisy at Warn)
- Add extractUnquotedSegments() helper with comprehensive tests

* fix(tools): handle backslash escape outside quotes in shell operator detection

extractUnquotedSegments did not handle \ as an escape character outside
of quotes, causing \" to incorrectly enter double-quote mode. This hid
subsequent shell operators from detection (e.g. gh \"arg\" | env would
not detect the unquoted pipe).

Add backslash escape handling in the unquoted state to match
go-shellwords parsing behavior. Both \ and the escaped character are
emitted as unquoted content so operator detection still catches them.

---------

Co-authored-by: viettranx <viettranx@gmail.com>

* feat(infra): tracing recovery, browser cleanup, CLI fixes, UI workspace split (#709)

- Tracing: recover stale running traces/spans on startup (PG + SQLite)
- Browser: Chrome orphan cleanup via launcher PID, timeouts, Leakless
- Claude CLI: WaitDelay 5s + context-cancel early exit
- Agent loop: safety-net defer to finalize orphan root traces
- UI: split workspace sharing into separate Memory and KG toggles
- Minor: for-range idiom, min() builtin

* fix(prompt): skip credentialed CLI context when exec tool is denied

Agents with exec in their deny list cannot run CLI commands, so
injecting wrangler/gh credential context is misleading — the LLM
sees instructions for tools it cannot use. Gate the section on
exec being present in the filtered tool list.

* fix(ui): improve traces table layout and readability

Compact columns: status as icon-only, merge time+duration into one column.
Truncate long user IDs, clean <media:*> tags from preview, move badges to second line.

* fix(security): harden exec path exemption matching

- Add absolute path exemption for dataDir/skills-store/ (fixes skill
  scripts using absolute paths like /app/data/skills-store/ being denied)
- Strip surrounding quotes before prefix matching (LLMs often quote paths)
- Reject path traversal ("..") in exempt fields to prevent escape
- Switch from "any field exempt → skip" to per-field matching: only exempt
  if ALL fields that match the deny pattern are individually exempt
- Closes pipe/comment bypass vectors where an exempt path in one argument
  would exempt the entire command including non-exempt paths

Includes 27 test cases covering: legitimate access, quoted paths,
path traversal, unicode bypass, pipe/comment bypass, mixed args.

* feat(whatsapp): add native WhatsApp channel with whatsmeow (#720)

Replace Node.js Baileys bridge with native go.mau.fi/whatsmeow — zero
external dependencies. QR auth, media support, markdown formatting,
typing indicators, dual JID/LID identity, group policies, pairing.

Resolves #703

* fix(chat): load message history on first conversation click (#730)

* fix(chat): load message history when selecting existing conversation from clean state

The skipNextHistoryRef was unconditionally set when sessionKey transitioned
from empty to non-empty. This prevented loadHistory() from running when
clicking an existing conversation from the initial /chat page. The skip
was only intended for the new-chat send flow where the optimistic message
is already displayed.

Guard the skip with expectingRunRef so it only activates when a message
send is in flight.

Closes #729

* docs: add UI diff evidence for PR #730

Before/after screenshots and HTML comparison report showing
first conversation click behavior fix.

* fix(permissions): use cron-specific permission check for cron tool (#725)

* fix(security): harden exec path exemption matching (#721)

- Add absolute path exemption for dataDir/skills-store/ (fixes skill
  scripts using absolute paths like /app/data/skills-store/ being denied)
- Strip surrounding quotes before prefix matching (LLMs often quote paths)
- Reject path traversal ("..") in exempt fields to prevent escape
- Switch from "any field exempt → skip" to per-field matching: only exempt
  if ALL fields that match the deny pattern are individually exempt
- Closes pipe/comment bypass vectors where an exempt path in one argument
  would exempt the entire command including non-exempt paths

Includes 27 test cases covering: legitimate access, quoted paths,
path traversal, unicode bypass, pipe/comment bypass, mixed args.

* fix(permissions): use cron-specific permission check for cron tool

Cron tool was hardcoded to check `file_writer` configType via
CheckFileWriterPermission(), ignoring the `cron` configType that
the UI actually saves when granting cron permissions. This caused
agents in group chats to be denied cron access even with correct
permission configured.

Add ConfigTypeCron constant and CheckCronPermission() that checks
`cron` configType first, falling back to `file_writer`.

---------

Co-authored-by: Viet Tran <viettranx@gmail.com>

* fix(exec): allow uploaded files in active workspaces (#748)

Shell-aware command parsing, dynamic workspace exemptions, and symlink canonicalization for exec path denial. Fixes #739.

* refactor(exec): extract path exemption logic to separate file

Move shell-aware parsing, dynamic workspace exemptions, path alias
variants, and canonicalization functions from shell.go (688 LOC) to
shell_path_exemption.go (284 LOC) for maintainability.

* feat(agent): centralized tenant user identity resolution for credentials

Add CredentialUserID context key that resolves channel contacts to merged
tenant users for credential lookups (SecureCLI, MCP). Keeps UserID
unchanged for session/workspace scoping. Resolves group senders, group
contacts, and unresolved DMs via ContactStore with 60s TTL cache.

* fix(ui): improve traces table column layout

Add width constraints and whitespace-nowrap to prevent column wrapping
on narrow viewports. Cherry-picked from dev-v3.

* fix(ui): enable search filtering on knowledge graph view (#758)

Search box on /knowledge-graph only filtered table view. Add useMemo client-side filtering of graph entities by name/type/description, only show relations where both endpoints match.

Closes #759

* feat: add zoom controls to knowledge graph (#757)

Add +/- buttons and percentage display to the knowledge graph stats bar
so users can zoom without relying solely on mouse wheel. Uses existing
react-force-graph-2D zoom() API with 1.5x step and 300ms animation.

Closes #755

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(providers): use DB name for ClaudeCLI, ACP, and Anthropic registration

Provider Name() methods returned hardcoded strings, so DB-registered
providers with custom names got wrong registry key — causing "provider
not found" fallback. Add WithClaudeCLIName/WithACPName/WithAnthropicName
options, pass p.Name from DB registration paths. Config-based paths
keep hardcoded defaults.

Closes #742

* fix(agent): correct soft-trim head/tail budget allocation when tail is important (#723)

Co-authored-by: quxy5 <quxy5@outlook.com>

---------

Co-authored-by: Duy /zuey/ <duy@wearetopgroup.com>
Co-authored-by: Duc Nguyen <me@vanducng.dev>
Co-authored-by: Kai (Tam Nhu) Tran <61256810+kaitranntt@users.noreply.github.com>
Co-authored-by: Plateau Nguyen <nguyennlt.ncc@gmail.com>
Co-authored-by: Reski Rukmantiyo <reski.rukmantio@lintasarta.co.id>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: QuXiangyu <771744189@qq.com>
Co-authored-by: quxy5 <quxy5@outlook.com>
This commit is contained in:
85 files changed
+4076 -605

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PR #730 — fix(chat): first conversation click</title>
<style>
:root { --bg: #0d0d0d; --fg: #e8e4df; --card: #1a1714; --border: #2d2923; --accent: #ef4444; --muted: #6b6560; }
.light { --bg: #f8f6f3; --fg: #1a1714; --card: #ffffff; --border: #e5e0da; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); padding: 2rem; }
.theme-toggle { position: fixed; top: 1rem; right: 1rem; background: var(--card); border: 1px solid var(--border); color: var(--fg); padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; z-index: 100; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.subtitle { color: var(--muted); margin-bottom: 2rem; }
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.summary-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
.summary-card h3 { font-size: 0.75rem; text-transform: uppercase; color: var(--muted); margin-bottom: 0.5rem; }
.env { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 2rem; font-size: 0.85rem; color: var(--muted); }
.comparison { margin-bottom: 3rem; }
.comparison h2 { font-size: 1.1rem; margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
.col-header { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.col-header.before { color: var(--muted); }
.col-header.after { color: var(--accent); }
.grid img { width: 100%; border: 1px solid var(--border); border-radius: 6px; }
.desc { font-size: 0.85rem; color: var(--muted); margin-top: 0.5rem; }
</style>
</head>
<body>
<button class="theme-toggle" onclick="document.documentElement.classList.toggle('light'); this.textContent = document.documentElement.classList.contains('light') ? '● Dark' : '○ Light'">○ Light</button>
<h1>fix(chat): load message history on first conversation click</h1>
<p class="subtitle">PR #730 — Closes #729</p>
<div class="summary">
<div class="summary-card">
<h3>Bug</h3>
<p>First conversation click from clean state shows empty placeholder instead of messages</p>
</div>
<div class="summary-card">
<h3>Root Cause</h3>
<p><code>skipNextHistoryRef</code> unconditionally set on empty→non-empty transition, skipping <code>loadHistory()</code></p>
</div>
<div class="summary-card">
<h3>Fix</h3>
<p>Guard skip with <code>expectingRunRef.current</code> — only active during new-chat send flow</p>
</div>
</div>
<div class="env">
Same instance (claw:3000 backend), same user (kai/Master), same agent (glm thục bô), same conversation (Chat a95c8ffa), light theme. Callout borders mark the review area.
</div>
<div class="comparison">
<h2>Chat — First Click from Clean State</h2>
<div class="grid">
<div>
<div class="col-header before">BEFORE</div>
<img src="before-chat-first-click.png" alt="Before: empty placeholder on first click">
<p class="desc">Before: Clicking "Chat a95c8ffa" from initial state — conversation highlighted but main area shows "Start a conversation" placeholder. Messages never load.</p>
</div>
<div>
<div class="col-header after">AFTER</div>
<img src="after-chat-first-click.png" alt="After: messages load on first click">
<p class="desc">After: Same click — conversation messages load immediately. The 5-message history is displayed correctly on first click.</p>
</div>
</div>
</div>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
=== RUN TestSetupToolRegistryExecWorkspacePaths
2026/04/07 21:24:13 INFO memory + knowledge graph tools registered (PG-backed)
2026/04/07 21:24:13 INFO web_search tool enabled
2026/04/07 21:24:13 INFO web_fetch tool enabled policy="" blocked=0
2026/04/07 21:24:13 INFO tts enabled provider=edge auto=off
2026/04/07 21:24:13 INFO tool rate limiting enabled per_hour=150
2026/04/07 21:24:13 INFO exec approval enabled security=full ask=off
=== RUN TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed
2026/04/07 21:24:13 WARN secure_cli.lookup: store is nil, skipping credentialed exec command="printf '%s' file=@\"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/001/personal/.uploads/Quarterly Report.png\""
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed
2026/04/07 21:24:13 WARN secure_cli.lookup: store is nil, skipping credentialed exec command="printf '%s' --input=\"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/001/teams/team-123/Quarterly Report.png\""
=== RUN TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed
2026/04/07 21:24:13 WARN secure_cli.lookup: store is nil, skipping credentialed exec command="cp \"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/001/.goclaw/goclaw-workspace/ws/system/uploads/Quarterly Report.png\" \"/var/folders/t9/0cyfylls7d1gdy_6qp8kn2qc0000gn/T/TestSetupToolRegistryExecWorkspacePaths3385685647/002/partner.png\""
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied
=== RUN TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied
=== RUN TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied
--- PASS: TestSetupToolRegistryExecWorkspacePaths (0.02s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed (0.01s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed (0.01s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied (0.00s)
PASS
ok github.com/nextlevelbuilder/goclaw/cmd 0.041s
+94
View File
@@ -0,0 +1,94 @@
go: downloading github.com/golang-migrate/migrate/v4 v4.19.1
go: downloading github.com/jackc/pgx/v5 v5.6.0
go: downloading github.com/google/uuid v1.6.0
go: downloading github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
go: downloading github.com/charmbracelet/huh v0.8.0
go: downloading github.com/spf13/cobra v1.10.2
go: downloading github.com/bwmarrin/discordgo v0.29.0
go: downloading golang.org/x/sync v0.19.0
go: downloading github.com/slack-go/slack v0.19.0
go: downloading github.com/mattn/go-runewidth v0.0.16
go: downloading github.com/mymmrac/telego v1.6.0
go: downloading github.com/fsnotify/fsnotify v1.9.0
go: downloading github.com/titanous/json5 v1.0.0
go: downloading github.com/disintegration/imaging v1.6.2
go: downloading golang.org/x/image v0.27.0
go: downloading golang.org/x/time v0.14.0
go: downloading github.com/mark3labs/mcp-go v0.44.0
go: downloading github.com/adhocore/gronx v1.19.6
go: downloading github.com/go-rod/rod v0.116.2
go: downloading github.com/lib/pq v1.10.9
go: downloading github.com/mattn/go-shellwords v1.0.12
go: downloading golang.org/x/net v0.49.0
go: downloading golang.org/x/text v0.33.0
go: downloading github.com/rivo/uniseg v0.4.7
go: downloading github.com/catppuccin/go v0.3.0
go: downloading github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
go: downloading github.com/charmbracelet/bubbletea v1.3.6
go: downloading github.com/charmbracelet/lipgloss v1.1.0
go: downloading github.com/charmbracelet/x/cellbuf v0.0.13
go: downloading github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0
go: downloading github.com/mitchellh/hashstructure/v2 v2.0.2
go: downloading github.com/spf13/pflag v1.0.9
go: downloading golang.org/x/crypto v0.47.0
go: downloading golang.org/x/sys v0.42.0
go: downloading github.com/valyala/fasthttp v1.69.0
go: downloading github.com/invopop/jsonschema v0.13.0
go: downloading github.com/spf13/cast v1.7.1
go: downloading github.com/yosida95/uritemplate/v3 v3.0.2
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading github.com/charmbracelet/x/ansi v0.9.3
go: downloading github.com/charmbracelet/x/term v0.2.1
go: downloading github.com/atotto/clipboard v0.1.4
go: downloading github.com/grbit/go-json v0.11.0
go: downloading github.com/valyala/fastjson v1.6.7
go: downloading github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
go: downloading github.com/muesli/cancelreader v0.2.2
go: downloading github.com/muesli/termenv v0.16.0
go: downloading github.com/wk8/go-ordered-map/v2 v2.1.8
go: downloading github.com/ysmood/goob v0.4.0
go: downloading github.com/ysmood/got v0.40.0
go: downloading github.com/ysmood/gson v0.7.3
go: downloading github.com/ysmood/fetchup v0.2.3
go: downloading github.com/ysmood/leakless v0.9.0
go: downloading github.com/andybalholm/brotli v1.2.0
go: downloading github.com/klauspost/compress v1.18.2
go: downloading github.com/valyala/bytebufferpool v1.0.0
go: downloading github.com/jackc/pgpassfile v1.0.0
go: downloading github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761
go: downloading github.com/jackc/puddle/v2 v2.2.2
go: downloading github.com/lucasb-eyer/go-colorful v1.2.0
go: downloading github.com/bahlo/generic-list-go v0.2.0
go: downloading github.com/buger/jsonparser v1.1.1
go: downloading github.com/mailru/easyjson v0.7.7
go: downloading gopkg.in/yaml.v3 v3.0.1
go: downloading github.com/aymanbagabas/go-osc52/v2 v2.0.1
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc
go: downloading github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
=== RUN TestSetupToolRegistryExecWorkspacePaths
2026/04/08 01:24:40 INFO memory + knowledge graph tools registered (PG-backed)
2026/04/08 01:24:40 INFO web_search tool enabled
2026/04/08 01:24:40 INFO web_fetch tool enabled policy="" blocked=0
2026/04/08 01:24:40 INFO tts enabled provider=edge auto=off
2026/04/08 01:24:40 INFO tool rate limiting enabled per_hour=150
2026/04/08 01:24:40 INFO exec approval enabled security=full ask=off
=== RUN TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed
gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths1657441769/001
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed
gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths1657441769/001
=== RUN TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed
gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths1657441769/001
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied
=== RUN TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied
=== RUN TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied
--- FAIL: TestSetupToolRegistryExecWorkspacePaths (0.00s)
--- FAIL: TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed (0.00s)
--- FAIL: TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed (0.00s)
--- FAIL: TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/team_workspace_symlink_escape_denied (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/unrelated_data_dir_path_denied (0.00s)
--- PASS: TestSetupToolRegistryExecWorkspacePaths/workspace_local_dotgoclaw_denied (0.00s)
FAIL
FAIL github.com/nextlevelbuilder/goclaw/cmd 0.019s
FAIL
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

+228
View File
@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PR #748 — fix(exec): allow uploaded files in active workspaces</title>
<style>
:root {
--bg: #0d0d0d;
--fg: #e8e4df;
--card: #1a1714;
--border: #2d2923;
--accent: #ef4444;
--good: #22c55e;
--muted: #8a837d;
--code: #151210;
}
.light {
--bg: #f8f6f3;
--fg: #1a1714;
--card: #ffffff;
--border: #e5e0da;
--accent: #dc2626;
--good: #15803d;
--muted: #6b6560;
--code: #f3f0eb;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
padding: 2rem;
line-height: 1.5;
}
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
background: var(--card);
border: 1px solid var(--border);
color: var(--fg);
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
z-index: 100;
}
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.subtitle { color: var(--muted); margin-bottom: 2rem; }
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.summary-card, .env, .comparison, .supporting {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
}
.summary-card { padding: 1rem; }
.summary-card h3 {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.5rem;
letter-spacing: 0.04em;
}
.env, .supporting, .comparison { padding: 1.25rem; margin-bottom: 1.5rem; }
.env { color: var(--muted); font-size: 0.92rem; }
.comparison h2, .supporting h2 {
font-size: 1.05rem;
margin-bottom: 1rem;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 980px) { .grid { grid-template-columns: 1fr; } }
.col-header {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.before { color: var(--muted); }
.after { color: var(--accent); }
.panel {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--code);
}
.panel img {
display: block;
width: 100%;
height: auto;
}
.terminal {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 1rem;
min-height: 320px;
}
.hl-bad {
display: inline-block;
background: rgba(239, 68, 68, 0.14);
border-left: 3px solid var(--accent);
padding: 0.05rem 0.35rem;
}
.hl-good {
display: inline-block;
background: rgba(34, 197, 94, 0.14);
border-left: 3px solid var(--good);
padding: 0.05rem 0.35rem;
}
.desc {
color: var(--muted);
font-size: 0.88rem;
margin-top: 0.65rem;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--code);
padding: 0.12rem 0.32rem;
border-radius: 4px;
}
ul { margin-left: 1.1rem; color: var(--muted); }
</style>
</head>
<body>
<button class="theme-toggle" onclick="document.documentElement.classList.toggle('light'); this.textContent = document.documentElement.classList.contains('light') ? '● Dark' : '○ Light'">○ Light</button>
<h1>fix(exec): allow uploaded files in active workspaces</h1>
<p class="subtitle">PR #748 — visual proof pack for issue #739</p>
<div class="summary">
<div class="summary-card">
<h3>Bug</h3>
<p>Active workspace files under deny-roots were rejected by <code>exec</code> even when they belonged to the current upload/team scope.</p>
</div>
<div class="summary-card">
<h3>Root Cause</h3>
<p>Path exemptions were static and non-shell-aware, so quoted, prefixed, and legacy screenshot-era upload paths still matched the deny root.</p>
</div>
<div class="summary-card">
<h3>Fix</h3>
<p>Shell-aware parsing + canonical path checks now exempt the active personal <code>.uploads</code> subtree, the legacy <code>uploads</code> subtree, and the active team workspace.</p>
</div>
</div>
<div class="env">
Before source: stable <code>claw:3000</code> backed by the unfixed <code>/opt/goclaw</code> deployment (<code>v2.7.1</code>). After source: temporary PR deployment on <code>claw:3001</code>, backed by <code>goclaw-pr748:local</code> built from the fixed code commit <code>83fa1342</code>. Both UI captures use fresh chats with the same user and <code>GLM thục bô</code>. Callout border marks the reviewed <code>exec</code> row.
</div>
<div class="comparison">
<h2>UI Before / After</h2>
<div class="grid">
<div>
<div class="col-header before">Before</div>
<div class="panel">
<img src="pr748-before-absolute-goclaw-blocked.png" alt="Before: stable claw blocks absolute .goclaw upload path">
</div>
<p class="desc">Stable <code>claw</code> release, fresh chat with <code>GLM thục bô</code>: the agent runs the exact absolute <code>/app/.goclaw/.../.uploads/...jpg</code> copy command and the <code>exec</code> row fails with the safety-policy <code>.goclaw/</code> denial.</p>
</div>
<div>
<div class="col-header after">After</div>
<div class="panel">
<img src="pr748-after-absolute-goclaw-allowed.png" alt="After: fixed temporary claw deployment allows absolute .goclaw upload path">
</div>
<p class="desc">Temporary fixed deployment on <code>claw:3001</code>, fresh chat with the same agent: the same absolute <code>/app/.goclaw/.../.uploads/...jpg</code> copy command now completes successfully and returns <code>/tmp/partner-abs.png</code>.</p>
</div>
</div>
</div>
<div class="comparison">
<h2>Gateway Reproduction</h2>
<div class="grid">
<div>
<div class="col-header before">Before</div>
<div class="panel">
<pre class="terminal">$ ssh claw 'docker run --rm -v /tmp/goclaw-739-before:/src -w /src golang:1.26 sh -lc "/usr/local/go/bin/go test ./cmd -run TestSetupToolRegistryExecWorkspacePaths -count=1 -v"'
=== RUN TestSetupToolRegistryExecWorkspacePaths
=== RUN TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed
<span class="hl-bad">gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths.../001</span>
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed
<span class="hl-bad">gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths.../001</span>
=== RUN TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed
<span class="hl-bad">gateway_setup_exec_workspace_test.go:133: denied = true, want false; output = command denied by safety policy: matches pattern /tmp/TestSetupToolRegistryExecWorkspacePaths.../001</span>
FAIL</pre>
</div>
<p class="desc">Baseline source on <code>claw</code>: the exact gateway wiring still rejects all three representative workspace-path forms.</p>
</div>
<div>
<div class="col-header after">After</div>
<div class="panel">
<pre class="terminal">$ go test ./cmd -run TestSetupToolRegistryExecWorkspacePaths -count=1 -v
=== RUN TestSetupToolRegistryExecWorkspacePaths
=== RUN TestSetupToolRegistryExecWorkspacePaths/personal_workspace_prefixed_uploads_allowed
=== RUN TestSetupToolRegistryExecWorkspacePaths/team_workspace_quoted_input_allowed
=== RUN TestSetupToolRegistryExecWorkspacePaths/legacy_dotgoclaw_uploads_layout_allowed
<span class="hl-good">--- PASS: TestSetupToolRegistryExecWorkspacePaths (0.01s)</span>
<span class="hl-good"> --- PASS: personal_workspace_prefixed_uploads_allowed</span>
<span class="hl-good"> --- PASS: team_workspace_quoted_input_allowed</span>
<span class="hl-good"> --- PASS: legacy_dotgoclaw_uploads_layout_allowed</span>
PASS</pre>
</div>
<p class="desc">Current worktree: the same gateway path handling now passes for personal uploads, team workspace paths, and the screenshot-era legacy <code>.goclaw/.../uploads</code> layout.</p>
</div>
</div>
</div>
<div class="supporting">
<h2>Supporting Live Recreation on <code>claw</code></h2>
<div class="panel">
<img src="claw-live-glm-thuc-bo-before.png" alt="Live claw recreation with GLM thục bô">
</div>
<p class="desc">
This earlier live run documents the same production agent and workspace context on stable <code>claw</code>. The report’s primary Before/After pair above is the clean isolated proof you asked for; this screenshot is kept as supporting context for the reproduction journey.
</p>
</div>
<div class="comparison">
<h2>What This Proves</h2>
<ul>
<li>Quoted/prefixed upload arguments now survive the deny matcher when they point into the active personal <code>.uploads</code> subtree, the legacy <code>.goclaw/.../uploads</code> subtree, or the active team workspace.</li>
<li>Symlink escapes, unrelated <code>dataDir</code> files, and workspace-local <code>.goclaw/*</code> remain denied.</li>
<li>The visual behavior difference is now shown on two real frontends: stable <code>claw:3000</code> Before and temporary fixed <code>claw:3001</code> After, both using fresh conversations.</li>
</ul>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

+1 -1
View File
@@ -44,7 +44,7 @@ func channelsListCmd() *cobra.Command {
{"discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != ""},
{"zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != ""},
{"feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != ""},
{"whatsapp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != ""},
{"whatsapp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.Enabled},
}
if jsonOutput {
+1 -1
View File
@@ -120,7 +120,7 @@ func runDoctor() {
checkChannel("Discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != "")
checkChannel("Zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != "")
checkChannel("Feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != "")
checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != "")
checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.Enabled)
}
// External tools
+1 -1
View File
@@ -563,7 +563,7 @@ func runGateway() {
instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory)
instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.Factory)
instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDB(pgStores.DB, pgStores.PendingMessages, "pgx"))
instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages))
if err := instanceLoader.LoadAll(context.Background()); err != nil {
slog.Error("failed to load channel instances from DB", "error", err)
+7 -3
View File
@@ -68,9 +68,12 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms
}
if cfg.Channels.WhatsApp.Enabled {
if cfg.Channels.WhatsApp.BridgeURL == "" {
recordMissingConfig(channels.TypeWhatsApp, "Set channels.whatsapp.bridge_url in config.")
} else if wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, nil); err != nil {
waDialect := "pgx"
if strings.Contains(fmt.Sprintf("%T", pgStores.DB.Driver()), "sqlite") {
waDialect = "sqlite3"
}
wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing, pgStores.DB, pgStores.PendingMessages, waDialect)
if err != nil {
channelMgr.RecordFailure(channels.TypeWhatsApp, "", err)
slog.Error("failed to initialize whatsapp channel", "error", err)
} else {
@@ -143,6 +146,7 @@ func wireChannelRPCMethods(server *gateway.Server, pgStores *store.Stores, chann
methods.NewChannelInstancesMethods(pgStores.ChannelInstances, msgBus, msgBus).Register(server.Router())
zalomethods.NewQRMethods(pgStores.ChannelInstances, msgBus).Register(server.Router())
zalomethods.NewContactsMethods(pgStores.ChannelInstances).Register(server.Router())
whatsapp.NewQRMethods(pgStores.ChannelInstances, channelMgr).Register(server.Router())
}
// Register agent links WS RPC methods
+1
View File
@@ -165,6 +165,7 @@ func wireExtras(
ModelPricing: appCfg.Telemetry.ModelPricing,
TracingStore: stores.Tracing,
MemoryStore: stores.Memory,
ContactStore: stores.Contacts,
TenantStore: stores.Tenants,
BuiltinToolTenantCfgs: stores.BuiltinToolTenantCfgs,
SkillTenantCfgs: stores.SkillTenantCfgs,
+3
View File
@@ -294,6 +294,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
continue
}
var cliOpts []providers.ClaudeCLIOption
cliOpts = append(cliOpts, providers.WithClaudeCLIName(p.Name))
cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true))
if gatewayAddr != "" {
mcpData := providers.BuildCLIMCPConfigData(nil, gatewayAddr, gatewayToken)
@@ -341,6 +342,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
registry.RegisterForTenant(p.TenantID, codex)
case store.ProviderAnthropicNative:
registry.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey,
providers.WithAnthropicName(p.Name),
providers.WithAnthropicBaseURL(p.APIBase)))
case store.ProviderDashScope:
registry.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, ""))
@@ -483,6 +485,7 @@ func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) {
}
registry.RegisterForTenant(p.TenantID, providers.NewACPProvider(
binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns(),
providers.WithACPName(p.Name),
providers.WithACPModel(p.Name),
))
slog.Info("registered provider from DB", "name", p.Name, "type", "acp")
+144
View File
@@ -0,0 +1,144 @@
package cmd
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
func testExecToolFromGatewaySetup(t *testing.T, workspace, dataDir string) *tools.ExecTool {
t.Helper()
cfg := config.Default()
cfg.DataDir = dataDir
cfg.Agents.Defaults.Workspace = workspace
cfg.Tools.Browser.Enabled = false
providerRegistry := providers.NewRegistry(nil)
toolsReg, _, _, _, _, _, _, _, _, _, _ := setupToolRegistry(cfg, workspace, providerRegistry)
execToolAny, ok := toolsReg.Get("exec")
if !ok {
t.Fatal("exec tool not registered")
}
execTool, ok := execToolAny.(*tools.ExecTool)
if !ok {
t.Fatalf("exec tool type = %T, want *tools.ExecTool", execToolAny)
}
return execTool
}
func TestSetupToolRegistryExecWorkspacePaths(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink setup requires elevated privileges on Windows")
}
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "personal")
teamWorkspace := filepath.Join(dataDir, "teams", "team-123")
for _, dir := range []string{workspace, teamWorkspace} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", dir, err)
}
}
execTool := testExecToolFromGatewaySetup(t, workspace, dataDir)
uploadPath := filepath.Join(workspace, ".uploads", "Quarterly Report.png")
if err := os.MkdirAll(filepath.Dir(uploadPath), 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(uploadPath, []byte("ok"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
teamFilePath := filepath.Join(teamWorkspace, "Quarterly Report.png")
if err := os.WriteFile(teamFilePath, []byte("ok"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
protectedPath := filepath.Join(dataDir, "config.json")
if err := os.WriteFile(protectedPath, []byte("secret"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
symlinkPath := filepath.Join(teamWorkspace, "leak.txt")
if err := os.Symlink(protectedPath, symlinkPath); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
legacyWorkspace := filepath.Join(dataDir, ".goclaw", "goclaw-workspace", "ws", "system")
legacyUploadPath := filepath.Join(legacyWorkspace, "uploads", "Quarterly Report.png")
legacyCopyTarget := filepath.Join(t.TempDir(), "partner.png")
if err := os.MkdirAll(filepath.Dir(legacyUploadPath), 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(legacyUploadPath, []byte("ok"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
tests := []struct {
name string
ctx context.Context
command string
wantDenied bool
wantPath string
}{
{
name: "personal_workspace_prefixed_uploads_allowed",
ctx: tools.WithToolWorkspace(context.Background(), workspace),
command: "printf '%s' file=@\"" + uploadPath + "\"",
},
{
name: "team_workspace_quoted_input_allowed",
ctx: tools.WithToolTeamWorkspace(tools.WithToolWorkspace(context.Background(), workspace), teamWorkspace),
command: "printf '%s' --input=\"" + teamFilePath + "\"",
},
{
name: "legacy_dotgoclaw_uploads_layout_allowed",
ctx: tools.WithToolWorkspace(context.Background(), legacyWorkspace),
command: "cp \"" + legacyUploadPath + "\" \"" + legacyCopyTarget + "\"",
wantPath: legacyCopyTarget,
},
{
name: "team_workspace_symlink_escape_denied",
ctx: tools.WithToolTeamWorkspace(tools.WithToolWorkspace(context.Background(), workspace), teamWorkspace),
command: "printf '%s' " + symlinkPath,
wantDenied: true,
},
{
name: "unrelated_data_dir_path_denied",
ctx: tools.WithToolWorkspace(context.Background(), workspace),
command: "printf '%s' " + protectedPath,
wantDenied: true,
},
{
name: "workspace_local_dotgoclaw_denied",
ctx: tools.WithToolWorkspace(context.Background(), workspace),
command: "printf '%s' " + filepath.Join(workspace, ".goclaw", "secrets.json"),
wantDenied: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := execTool.Execute(tc.ctx, map[string]any{
"command": tc.command,
})
denied := strings.Contains(result.ForLLM, "command denied by safety policy")
if denied != tc.wantDenied {
t.Fatalf("denied = %v, want %v; output = %s", denied, tc.wantDenied, result.ForLLM)
}
if tc.wantPath != "" {
if _, err := os.Stat(tc.wantPath); err != nil {
t.Fatalf("expected copied file at %q, got stat error: %v", tc.wantPath, err)
}
} else if !tc.wantDenied && !strings.Contains(result.ForLLM, "Quarterly Report.png") {
t.Fatalf("expected output to contain quoted file path, got: %s", result.ForLLM)
}
})
}
}
+16 -10
View File
@@ -156,13 +156,13 @@ flowchart TD
| Feature | Telegram | Feishu/Lark | Discord | Slack | WhatsApp | Zalo OA | Zalo Personal |
|---------|----------|-------------|---------|-------|----------|---------|---------------|
| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | External WS bridge | Long polling | Internal protocol |
| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | Direct protocol (in-process) | Long polling | Internal protocol |
| DM support | Yes | Yes | Yes | Yes | Yes | Yes (DM only) | Yes |
| Group support | Yes (mention gating) | Yes | Yes | Yes (mention gating + thread cache) | Yes | No | Yes |
| Forum/Topics | Yes (per-topic config) | Yes (topic session mode) | -- | -- | -- | -- | -- |
| Message limit | 4,096 chars | Configurable (default 4,000) | 2,000 chars | 4,000 chars | N/A (bridge) | 2,000 chars | 2,000 chars |
| Message limit | 4,096 chars | Configurable (default 4,000) | 2,000 chars | 4,000 chars | WhatsApp native limit | 2,000 chars | 2,000 chars |
| Streaming | Typing indicator | Streaming message cards | Edit "Thinking..." | Edit "Thinking..." (throttled 1s) | No | No | No |
| Media | Photos, voice, files | Images, files (30 MB) | Files, embeds | Files (download w/ SSRF protection) | JSON messages | Images (5 MB) | -- |
| Media | Photos, voice, files | Images, files (30 MB) | Files, embeds | Files (download w/ SSRF protection) | Images, audio, video, documents | Images (5 MB) | -- |
| Speech-to-text | Yes (STT proxy) | -- | -- | -- | -- | -- | -- |
| Voice routing | Yes (VoiceAgentID) | -- | -- | -- | -- | -- | -- |
| Rich formatting | Markdown → HTML | Card messages | Markdown | Markdown → mrkdwn | Plain text | Plain text | Plain text |
@@ -430,15 +430,18 @@ Auto-enables when both bot_token and app_token are set.
## 9. WhatsApp
The WhatsApp channel communicates through an external WebSocket bridge (e.g., whatsapp-web.js based). GoClaw does not implement the WhatsApp protocol directly.
The WhatsApp channel connects directly to the WhatsApp network via the multi-device protocol. Authentication state is stored in the database (PostgreSQL standard, SQLite for desktop edition).
### Key Behaviors
- **Bridge connection**: Connects to configurable `bridge_url` via WebSocket
- **JSON format**: Messages sent/received as JSON objects
- **Auto-reconnect**: Exponential backoff (1s → 30s max)
- **DM and group support**: Group detection via `@g.us` suffix in chat ID
- **Media handling**: Array of file paths from bridge protocol
- **Direct connection**: In-process WhatsApp client (direct to WhatsApp servers, no external bridge)
- **Database auth store**: Persists auth state, keys, and device info in the database
- **QR code authentication**: Interactive QR code for initial pairing, served via WebSocket API
- **Auto-reconnect**: Built-in reconnection with exponential backoff
- **DM and group support**: Full group messaging with mention detection via JID format
- **Media handling**: Direct media download/upload to WhatsApp servers with type detection
- **Typing indicators**: Typing state managed per chat with auto-refresh
- **Group mention gating**: Detects when bot is mentioned via LID (Local ID) and JID (standard format)
---
@@ -590,7 +593,10 @@ flowchart TD
| `internal/channels/slack/format.go` | Markdown → Slack mrkdwn pipeline |
| `internal/channels/slack/reactions.go` | Status emoji reactions on messages |
| `internal/channels/slack/stream.go` | Streaming message updates via placeholder editing |
| `internal/channels/whatsapp/whatsapp.go` | WhatsApp: external WS bridge |
| `internal/channels/whatsapp/whatsapp.go` | WhatsApp: direct protocol client, QR auth, database persistence |
| `internal/channels/whatsapp/factory.go` | Channel factory, database dialect detection |
| `internal/channels/whatsapp/qr_methods.go` | QR code generation and authentication flow |
| `internal/channels/whatsapp/format.go` | Message formatting (HTML-to-WhatsApp) |
| `internal/channels/zalo/zalo.go` | Zalo OA: Bot API, long polling |
| `internal/channels/zalo/personal/channel.go` | Zalo Personal: reverse-engineered protocol |
| `internal/store/pg/pairing.go` | Pairing: code generation, approval, persistence (database-backed) |
+15
View File
@@ -34,6 +34,21 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep
### Added
#### WhatsApp Native Protocol Integration (2026-04-06)
- **Direct protocol migration**: Replaced Node.js Baileys bridge with direct in-process WhatsApp connectivity
- **Database auth persistence**: Auth state, device keys, and client metadata stored in PostgreSQL (standard) or SQLite (desktop)
- **QR authentication**: Interactive QR code authentication for device linking without external bridge relay
- **No more bridge_url**: Removed `bridge_url` configuration, eliminated `docker-compose.whatsapp.yml`, removed `bridge/whatsapp/` sidecar service
- **Enhanced media handling**: Direct media download/upload to WhatsApp servers with automatic type detection and streaming
- **Improved mention detection**: Group mention detection now uses LID (Local ID) + JID (standard format) for robust message routing
- **Files added**:
- `internal/channels/whatsapp/factory.go` — Dialect detection and channel factory
- `internal/channels/whatsapp/qr_methods.go` — QR code generation and authentication flow
- `internal/channels/whatsapp/format.go` — HTML-to-WhatsApp message formatting
- Database-backed auth persistence for cross-platform support
### Refactored
#### Parallel Sub-Agent Enhancement (#600) (2026-03-31)
- **Smart leader delegation**: Conditional leader delegation prompt instead of forced delegation for all subagent spawns
- **Compaction prompt persistence**: Preserves pending subagent and team task state across context summarization to maintain work continuity
+17 -8
View File
@@ -17,11 +17,13 @@ require (
github.com/mattn/go-shellwords v1.0.12
github.com/mymmrac/telego v1.6.0
github.com/redis/go-redis/v9 v9.18.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/slack-go/slack v0.19.0
github.com/spf13/cobra v1.10.2
github.com/titanous/json5 v1.0.0
github.com/wailsapp/wails/v2 v2.11.0
github.com/zalando/go-keyring v0.2.8
go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4
go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0
@@ -53,6 +55,7 @@ require (
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
@@ -64,11 +67,12 @@ require (
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/creachadair/msync v0.7.1 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.18.0 // indirect
@@ -91,7 +95,7 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
@@ -101,11 +105,13 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
@@ -117,17 +123,20 @@ require (
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.mau.fi/util v0.9.6 // indirect
go.uber.org/atomic v1.11.0 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
@@ -175,14 +184,14 @@ require (
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.49.0
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.33.0
golang.org/x/text v0.34.0
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
google.golang.org/protobuf v1.36.11
)
+47 -13
View File
@@ -8,16 +8,22 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@@ -60,6 +66,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -114,14 +122,15 @@ github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho=
github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008=
@@ -159,6 +168,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -188,6 +199,7 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -288,9 +300,11 @@ github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9a
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -299,6 +313,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
@@ -337,6 +353,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
@@ -367,11 +385,18 @@ github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGG
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44=
github.com/slack-go/slack v0.19.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
@@ -433,6 +458,8 @@ github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpB
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
@@ -469,6 +496,12 @@ github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPc
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4 h1:E4A6eca9vMJQctC9DIfzUIg27TrJ8IrDHgkJwJ8WPUQ=
go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
@@ -505,10 +538,10 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -518,8 +551,8 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -532,16 +565,17 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+3 -2
View File
@@ -228,8 +228,9 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (result *RunResult,
var allowedTools map[string]bool
// Resolve per-user MCP tools (servers requiring user credentials).
// Must run before buildFilteredTools so tools are in the Registry for policy filtering.
if req.UserID != "" {
l.getUserMCPTools(iterCtx, req.UserID)
// Uses resolved credential user ID so merged tenant users get correct MCP creds.
if mcpUserID := store.CredentialUserIDFromContext(iterCtx); mcpUserID != "" {
l.getUserMCPTools(iterCtx, mcpUserID)
}
toolDefs, allowedTools, messages = l.buildFilteredTools(&req, hadBootstrap, rs.iteration, maxIter, messages)
+2 -2
View File
@@ -18,7 +18,7 @@ func TestParallelToolCollection_ContextCancel(t *testing.T) {
var err error
collectLoop:
for i := 0; i < 3; i++ {
for range 3 {
select {
case r, ok := <-resultCh:
if !ok {
@@ -50,7 +50,7 @@ func TestParallelToolCollection_AllComplete(t *testing.T) {
collected := make([]indexedResult, 0, 3)
collectLoop:
for i := 0; i < 3; i++ {
for range 3 {
select {
case r, ok := <-resultCh:
if !ok {
+12
View File
@@ -41,6 +41,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
if req.UserID != "" {
ctx = store.WithUserID(ctx, req.UserID)
}
// Resolve merged tenant user identity for credential lookups.
// Keeps UserID unchanged (session/workspace scoping) but sets a separate
// CredentialUserID for SecureCLI, MCP, and other per-user features.
if l.userResolver != nil && req.UserID != "" {
credUserID := l.resolveCredentialUserID(ctx, *req)
if credUserID != "" && credUserID != req.UserID {
ctx = store.WithCredentialUserID(ctx, credUserID)
}
}
// Inject agent type into context for interceptor routing
if l.agentType != "" {
ctx = store.WithAgentType(ctx, l.agentType)
@@ -237,11 +246,14 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
if l.provider != nil {
providerName = l.provider.Name()
}
// Extract resolved credential user ID (set earlier via WithCredentialUserID, empty if not resolved).
credUserID, _ := ctx.Value(store.CredentialUserIDKey).(string)
rc := &store.RunContext{
AgentID: l.agentUUID,
AgentKey: l.id,
TenantID: l.tenantID,
UserID: req.UserID,
CredentialUserID: credUserID,
AgentType: l.agentType,
SenderID: req.SenderID,
SelfEvolve: l.selfEvolve,
+1 -4
View File
@@ -438,10 +438,7 @@ func (l *Loop) resolveSkillsSummary(ctx context.Context, skillFilter []string) s
// Cap description length to match BuildSummary() truncation (skillDescMaxLen=200 runes).
totalChars := 0
for _, s := range filtered {
descLen := len(s.Description)
if descLen > 200 {
descLen = 200
}
descLen := min(len(s.Description), 200)
totalChars += len(s.Name) + descLen + 10 // +10 for XML tags overhead
}
estimatedTokens := totalChars / 4
+21
View File
@@ -118,6 +118,25 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
runStart := time.Now().UTC()
// Safety net: ensure root traces are ALWAYS finalized, even on panic or goroutine leak.
// Normal-path finalization sets traceFinalized=true; this defer only acts if it wasn't.
var traceFinalized bool
if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil {
defer func() {
if traceFinalized {
return
}
slog.Warn("tracing: safety-net finalizing orphan trace",
"trace_id", traceID, "agent", l.id, "session", req.SessionKey)
safeCtx := context.WithoutCancel(ctx)
if agentSpanID != uuid.Nil {
l.emitAgentSpanEnd(safeCtx, agentSpanID, runStart, nil, context.Canceled)
}
l.traceCollector.FinishTrace(safeCtx, traceID, store.TraceStatusError,
"trace finalized by safety net (likely panic or goroutine leak)", "")
}()
}
// Emit running agent span immediately so it's visible in the trace UI.
if agentSpanID != uuid.Nil {
var agentSpanOpts []spanOption
@@ -183,6 +202,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
// Use background context when the run context is cancelled (/stop command)
// so the DB update still succeeds.
if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil {
traceFinalized = true
traceCtx := ctx
traceStatus := store.TraceStatusError
if ctx.Err() != nil {
@@ -214,6 +234,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
Payload: completedPayload,
})
if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil {
traceFinalized = true
if result != nil {
l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", truncateStr(result.Content, l.traceCollector.PreviewMaxLen()))
} else {
+7
View File
@@ -184,6 +184,9 @@ type Loop struct {
// Memory store for extractive memory fallback (writes directly when LLM flush fails)
memStore store.MemoryStore
// User identity resolver: maps channel contacts to merged tenant users for credential lookups.
userResolver UserIdentityResolver
}
// AgentEvent is emitted during agent execution for WS broadcasting.
@@ -321,6 +324,9 @@ type LoopConfig struct {
MCPStore store.MCPServerStore // for credential lookup
MCPPool *mcpbridge.Pool // user-keyed connection pool
MCPUserCredSrvs []store.MCPAccessInfo // servers needing per-user creds
// User identity resolver for credential lookups (maps channel contacts → tenant users)
UserResolver UserIdentityResolver
}
const defaultMaxTokens = config.DefaultMaxTokens
@@ -419,6 +425,7 @@ func NewLoop(cfg LoopConfig) *Loop {
mcpStore: cfg.MCPStore,
mcpPool: cfg.MCPPool,
mcpUserCredSrvs: cfg.MCPUserCredSrvs,
userResolver: cfg.UserResolver,
}
}
+3 -6
View File
@@ -205,8 +205,8 @@ func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg
tailChars := settings.softTrimTailChars
if hasImportantTail(msg.Content) {
totalBudget := headChars + tailChars
headChars = totalBudget * 7 / 10
tailChars = totalBudget - headChars
tailChars = totalBudget * 7 / 10
headChars = totalBudget - tailChars
}
head := takeHead(msg.Content, headChars)
tail := takeTail(msg.Content, tailChars)
@@ -296,10 +296,7 @@ func estimateMessageChars(m providers.Message) int {
// hasImportantTail checks if the last ~500 chars of content contain error/summary keywords.
func hasImportantTail(content string) bool {
runes := []rune(content)
checkLen := 500
if checkLen > len(runes) {
checkLen = len(runes)
}
checkLen := min(500, len(runes))
tail := string(runes[len(runes)-checkLen:])
return importantTailRe.MatchString(tail)
}
+4
View File
@@ -92,6 +92,9 @@ type ResolverDeps struct {
// Memory store for extractive memory fallback
MemoryStore store.MemoryStore
// Contact store for user identity resolution (channel contacts → tenant users)
ContactStore store.ContactStore
// Tenant store for workspace path resolution
TenantStore store.TenantStore
@@ -417,6 +420,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
MCPStore: deps.MCPStore,
MCPPool: deps.MCPPool,
MCPUserCredSrvs: mcpUserCredSrvs,
UserResolver: newContactResolver(deps.ContactStore),
})
slog.Info("resolved agent from DB", "agent", agentKey, "model", ag.Model, "provider", ag.Provider)
+3 -2
View File
@@ -217,8 +217,9 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string {
lines = append(lines, buildToolCallStyleSection()...)
}
// 2.5. Credentialed CLI context (appended after tooling, before safety) — skip during bootstrap
if !cfg.IsBootstrap && cfg.CredentialCLIContext != "" {
// 2.5. Credentialed CLI context (appended after tooling, before safety) — skip during bootstrap.
// Only inject when exec tool is available — CLI tools require exec to run.
if !cfg.IsBootstrap && cfg.CredentialCLIContext != "" && slices.Contains(cfg.ToolNames, "exec") {
lines = append(lines, cfg.CredentialCLIContext, "")
}
+102
View File
@@ -0,0 +1,102 @@
package agent
import (
"context"
"log/slog"
"strings"
)
// UserIdentityResolver resolves raw user IDs to merged tenant user identities.
// Used by the agent loop to set CredentialUserID in context before tool execution.
type UserIdentityResolver interface {
ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error)
}
// contactStoreResolver wraps a ContactStore for nil-safe resolution.
type contactStoreResolver struct {
store interface {
ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error)
}
}
func (r *contactStoreResolver) ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) {
if r.store == nil {
return "", nil
}
return r.store.ResolveTenantUserID(ctx, channelType, senderID)
}
// newContactResolver creates a UserIdentityResolver from a ContactStore.
// Returns nil if the store is nil (resolver is optional).
func newContactResolver(cs interface {
ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error)
}) UserIdentityResolver {
if cs == nil {
return nil
}
return &contactStoreResolver{store: cs}
}
// resolveCredentialUserID determines the best tenant user identity for
// credential lookups. For DMs, tries resolving the user ID directly.
// For groups, tries the individual sender first, then the group contact.
func (l *Loop) resolveCredentialUserID(ctx context.Context, req RunRequest) string {
if l.userResolver == nil {
return req.UserID
}
channelType := req.ChannelType
// Non-group: try resolving UserID directly (covers unresolved DMs, HTTP API, cron)
if req.PeerKind != "group" {
if channelType != "" && req.UserID != "" {
resolved, err := l.userResolver.ResolveTenantUserID(ctx, channelType, req.UserID)
if err != nil {
slog.Debug("credential_resolve.dm_failed", "user", req.UserID, "channel", channelType, "error", err)
} else if resolved != "" {
return resolved
}
}
return req.UserID
}
// Group: try individual sender first (most specific)
if req.SenderID != "" && channelType != "" {
senderNumeric := req.SenderID
if idx := strings.IndexByte(senderNumeric, '|'); idx > 0 {
senderNumeric = senderNumeric[:idx]
}
resolved, err := l.userResolver.ResolveTenantUserID(ctx, channelType, senderNumeric)
if err != nil {
slog.Debug("credential_resolve.group_sender_failed", "sender", senderNumeric, "channel", channelType, "error", err)
} else if resolved != "" {
return resolved
}
}
// Group: try group contact resolution (group chatID merged to tenant user)
if chatID := extractGroupChatID(req.UserID); chatID != "" && channelType != "" {
resolved, err := l.userResolver.ResolveTenantUserID(ctx, channelType, chatID)
if err != nil {
slog.Debug("credential_resolve.group_contact_failed", "chatID", chatID, "channel", channelType, "error", err)
} else if resolved != "" {
return resolved
}
}
return req.UserID
}
// extractGroupChatID extracts the chat ID from a group composite user ID.
// Format: "group:{channel}:{chatID}" → returns chatID.
// Returns "" if format doesn't match.
func extractGroupChatID(userID string) string {
if !strings.HasPrefix(userID, "group:") {
return ""
}
parts := strings.SplitN(userID, ":", 3)
if len(parts) < 3 {
return ""
}
return parts[2]
}
@@ -0,0 +1,171 @@
package agent
import (
"context"
"fmt"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// mockResolver is a test double that returns pre-configured resolutions.
type mockResolver struct {
mergedMap map[string]string // "channelType:senderID" → resolved tenant user ID
}
func (m *mockResolver) ResolveTenantUserID(_ context.Context, channelType, senderID string) (string, error) {
key := channelType + ":" + senderID
if resolved, ok := m.mergedMap[key]; ok {
return resolved, nil
}
return "", nil
}
func TestResolveCredentialUserID(t *testing.T) {
tests := []struct {
name string
req RunRequest
mergedMap map[string]string // nil signals nil resolver
wantUserID string
}{
// --- DM scenarios ---
{
name: "DM: sender merged to tenant user",
req: RunRequest{UserID: "12345", ChannelType: "telegram", PeerKind: "direct"},
mergedMap: map[string]string{"telegram:12345": "john@co.com"},
wantUserID: "john@co.com",
},
{
name: "DM: sender not merged — returns raw UserID",
req: RunRequest{UserID: "12345", ChannelType: "telegram", PeerKind: "direct"},
mergedMap: map[string]string{},
wantUserID: "12345",
},
{
name: "DM: already resolved by consumer_normal — no double resolve",
req: RunRequest{UserID: "john@co.com", ChannelType: "telegram", PeerKind: "direct"},
mergedMap: map[string]string{},
wantUserID: "john@co.com",
},
// --- Group scenarios ---
{
name: "Group: individual sender merged",
req: RunRequest{UserID: "group:tg:-100456", SenderID: "12345", ChannelType: "telegram", PeerKind: "group"},
mergedMap: map[string]string{"telegram:12345": "john@co.com"},
wantUserID: "john@co.com",
},
{
name: "Group: group contact merged (sender not merged)",
req: RunRequest{UserID: "group:tg:-100456", SenderID: "99999", ChannelType: "telegram", PeerKind: "group"},
mergedMap: map[string]string{"telegram:-100456": "team-acme@co.com"},
wantUserID: "team-acme@co.com",
},
{
name: "Group: both sender and group merged — sender takes priority",
req: RunRequest{UserID: "group:tg:-100456", SenderID: "12345", ChannelType: "telegram", PeerKind: "group"},
mergedMap: map[string]string{
"telegram:12345": "john@co.com",
"telegram:-100456": "team-acme@co.com",
},
wantUserID: "john@co.com", // individual > group
},
{
name: "Group: nobody merged — returns group composite",
req: RunRequest{UserID: "group:tg:-100456", SenderID: "99999", ChannelType: "telegram", PeerKind: "group"},
mergedMap: map[string]string{},
wantUserID: "group:tg:-100456",
},
// --- WS/HTTP/cron scenarios ---
{
name: "WS chat: no channelType — skip resolve, return UserID",
req: RunRequest{UserID: "john@co.com", PeerKind: ""},
mergedMap: map[string]string{},
wantUserID: "john@co.com",
},
{
name: "Cron: no channelType — skip resolve",
req: RunRequest{UserID: "12345"},
mergedMap: map[string]string{},
wantUserID: "12345",
},
// --- Edge cases ---
{
name: "SenderID with pipe suffix stripped",
req: RunRequest{UserID: "group:tg:-100456", SenderID: "12345|username", ChannelType: "telegram", PeerKind: "group"},
mergedMap: map[string]string{"telegram:12345": "john@co.com"},
wantUserID: "john@co.com",
},
{
name: "Nil resolver — returns raw UserID",
req: RunRequest{UserID: "group:tg:-100456", SenderID: "12345", ChannelType: "telegram", PeerKind: "group"},
mergedMap: nil, // signals nil resolver
wantUserID: "group:tg:-100456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
loop := &Loop{}
if tt.mergedMap != nil {
loop.userResolver = &mockResolver{mergedMap: tt.mergedMap}
}
got := loop.resolveCredentialUserID(context.Background(), tt.req)
if got != tt.wantUserID {
t.Errorf("resolveCredentialUserID() = %q, want %q", got, tt.wantUserID)
}
})
}
}
func TestExtractGroupChatID(t *testing.T) {
tests := []struct {
input, want string
}{
{"group:telegram:-100456", "-100456"},
{"group:discord:123", "123"},
{"guild:123:user:456", ""}, // not group: prefix
{"12345", ""}, // plain user ID
{"group:", ""}, // incomplete
{"group:channel", ""}, // missing third part
{"group:tg:-100456", "-100456"},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("input=%s", tt.input), func(t *testing.T) {
got := extractGroupChatID(tt.input)
if got != tt.want {
t.Errorf("extractGroupChatID(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestCredentialUserIDFromContext(t *testing.T) {
t.Run("both set — returns CredentialUserID", func(t *testing.T) {
ctx := store.WithUserID(context.Background(), "raw-user")
ctx = store.WithCredentialUserID(ctx, "resolved-user")
got := store.CredentialUserIDFromContext(ctx)
if got != "resolved-user" {
t.Errorf("got %q, want %q", got, "resolved-user")
}
})
t.Run("only UserID set — falls back to UserID", func(t *testing.T) {
ctx := store.WithUserID(context.Background(), "raw-user")
got := store.CredentialUserIDFromContext(ctx)
if got != "raw-user" {
t.Errorf("got %q, want %q", got, "raw-user")
}
})
t.Run("neither set — returns empty", func(t *testing.T) {
got := store.CredentialUserIDFromContext(context.Background())
if got != "" {
t.Errorf("got %q, want empty", got)
}
})
}
+102
View File
@@ -0,0 +1,102 @@
package whatsapp
import (
"context"
"fmt"
"log/slog"
"go.mau.fi/whatsmeow"
)
// StartQRFlow initiates the QR authentication flow.
// Returns a channel that emits QR code strings and auth events.
// Lazily initializes the whatsmeow client if Start() hasn't been called yet
// (handles timing race between async instance reload and wizard auto-start).
// Serialized with Reauth via reauthMu to prevent races on rapid double-clicks.
func (c *Channel) StartQRFlow(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) {
c.reauthMu.Lock()
defer c.reauthMu.Unlock()
if c.client == nil {
// Lazy init: wizard may request QR before Start() is called.
c.mu.Lock()
if c.client == nil {
if c.ctx == nil {
c.ctx, c.cancel = context.WithCancel(context.Background())
}
deviceStore, err := c.container.GetFirstDevice(ctx)
if err != nil {
c.mu.Unlock()
return nil, fmt.Errorf("whatsapp get device: %w", err)
}
c.client = whatsmeow.NewClient(deviceStore, nil)
c.client.AddEventHandler(c.handleEvent)
}
c.mu.Unlock()
}
if c.IsAuthenticated() {
return nil, nil // caller checks this
}
qrChan, err := c.client.GetQRChannel(ctx)
if err != nil {
return nil, fmt.Errorf("whatsapp get QR channel: %w", err)
}
if !c.client.IsConnected() {
if err := c.client.Connect(); err != nil {
return nil, fmt.Errorf("whatsapp connect for QR: %w", err)
}
}
return qrChan, nil
}
// Reauth clears the current session and prepares for a fresh QR scan.
// Serialized with StartQRFlow via reauthMu to prevent races on rapid double-clicks.
func (c *Channel) Reauth() error {
c.reauthMu.Lock()
defer c.reauthMu.Unlock()
slog.Info("whatsapp: reauth requested", "channel", c.Name())
c.lastQRMu.Lock()
c.waAuthenticated = false
c.lastQRB64 = ""
c.lastQRMu.Unlock()
c.mu.Lock()
defer c.mu.Unlock()
if c.client != nil {
c.client.Disconnect()
}
// Delete device from store to force fresh QR on next connect.
if c.client != nil && c.client.Store.ID != nil {
if err := c.client.Store.Delete(context.Background()); err != nil {
slog.Warn("whatsapp: failed to delete device store", "error", err)
}
}
// Reset context so the new client gets a fresh lifecycle.
if c.cancel != nil {
c.cancel()
}
// Use parentCtx if available so the new lifecycle is still bound to the gateway.
parent := c.parentCtx
if parent == nil {
parent = context.Background()
}
c.ctx, c.cancel = context.WithCancel(parent)
// Re-create client with fresh device store.
deviceStore, err := c.container.GetFirstDevice(context.Background())
if err != nil {
return fmt.Errorf("whatsapp: get fresh device: %w", err)
}
c.client = whatsmeow.NewClient(deviceStore, nil)
c.client.AddEventHandler(c.handleEvent)
return nil
}
+50 -44
View File
@@ -1,6 +1,7 @@
package whatsapp
import (
"database/sql"
"encoding/json"
"fmt"
@@ -10,59 +11,64 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// whatsappCreds maps the credentials JSON from the channel_instances table.
type whatsappCreds struct {
BridgeURL string `json:"bridge_url"`
}
// whatsappInstanceConfig maps the non-secret config JSONB from the channel_instances table.
type whatsappInstanceConfig struct {
DMPolicy string `json:"dm_policy,omitempty"`
GroupPolicy string `json:"group_policy,omitempty"`
AllowFrom []string `json:"allow_from,omitempty"`
BlockReply *bool `json:"block_reply,omitempty"`
DMPolicy string `json:"dm_policy,omitempty"`
GroupPolicy string `json:"group_policy,omitempty"`
RequireMention *bool `json:"require_mention,omitempty"`
HistoryLimit int `json:"history_limit,omitempty"`
AllowFrom []string `json:"allow_from,omitempty"`
BlockReply *bool `json:"block_reply,omitempty"`
}
// Factory creates a WhatsApp channel from DB instance data.
func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
// FactoryWithDB returns a ChannelFactory with DB access for whatsmeow auth state.
// dialect must be "pgx" (PostgreSQL) or "sqlite3" (SQLite/desktop).
func FactoryWithDB(db *sql.DB, pendingStore store.PendingMessageStore, dialect string) channels.ChannelFactory {
return func(name string, creds json.RawMessage, cfg json.RawMessage,
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
var c whatsappCreds
if len(creds) > 0 {
if err := json.Unmarshal(creds, &c); err != nil {
return nil, fmt.Errorf("decode whatsapp credentials: %w", err)
var ic whatsappInstanceConfig
if len(cfg) > 0 {
if err := json.Unmarshal(cfg, &ic); err != nil {
return nil, fmt.Errorf("decode whatsapp config: %w", err)
}
}
}
if c.BridgeURL == "" {
return nil, fmt.Errorf("whatsapp bridge_url is required")
}
var ic whatsappInstanceConfig
if len(cfg) > 0 {
if err := json.Unmarshal(cfg, &ic); err != nil {
return nil, fmt.Errorf("decode whatsapp config: %w", err)
// Detect old bridge_url config and give clear migration error.
if len(cfg) > 0 {
var legacy struct{ BridgeURL string `json:"bridge_url"` }
if json.Unmarshal(cfg, &legacy) == nil && legacy.BridgeURL != "" {
return nil, fmt.Errorf("whatsapp: bridge_url is no longer supported — " +
"WhatsApp now runs natively via whatsmeow. Remove bridge_url from config")
}
}
if len(creds) > 0 {
var legacy struct{ BridgeURL string `json:"bridge_url"` }
if json.Unmarshal(creds, &legacy) == nil && legacy.BridgeURL != "" {
return nil, fmt.Errorf("whatsapp: bridge_url is no longer supported — " +
"WhatsApp now runs natively via whatsmeow. Remove bridge_url from credentials")
}
}
}
waCfg := config.WhatsAppConfig{
Enabled: true,
BridgeURL: c.BridgeURL,
AllowFrom: ic.AllowFrom,
DMPolicy: ic.DMPolicy,
GroupPolicy: ic.GroupPolicy,
BlockReply: ic.BlockReply,
}
waCfg := config.WhatsAppConfig{
Enabled: true,
AllowFrom: ic.AllowFrom,
DMPolicy: ic.DMPolicy,
GroupPolicy: ic.GroupPolicy,
RequireMention: ic.RequireMention,
HistoryLimit: ic.HistoryLimit,
BlockReply: ic.BlockReply,
}
// DB instances default to "pairing" for groups (secure by default).
if waCfg.GroupPolicy == "" {
waCfg.GroupPolicy = "pairing"
}
// DB instances default to "pairing" for groups (secure by default).
if waCfg.GroupPolicy == "" {
waCfg.GroupPolicy = "pairing"
ch, err := New(waCfg, msgBus, pairingSvc, db, pendingStore, dialect)
if err != nil {
return nil, err
}
ch.SetName(name)
return ch, nil
}
ch, err := New(waCfg, msgBus, pairingSvc)
if err != nil {
return nil, err
}
ch.SetName(name)
return ch, nil
}
+137
View File
@@ -0,0 +1,137 @@
package whatsapp
import (
"fmt"
"regexp"
"strings"
)
// markdownToWhatsApp converts Markdown-formatted LLM output to WhatsApp's native
// formatting syntax. WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```.
// Unsupported features are simplified: headers → bold, links → "text url", tables → plain.
func markdownToWhatsApp(text string) string {
if text == "" {
return ""
}
// Pre-process: convert HTML tags from LLM output to Markdown equivalents.
text = htmlTagToWaMd(text)
// Extract and protect fenced code blocks — WhatsApp renders ``` the same way.
codeBlocks := waExtractCodeBlocks(text)
text = codeBlocks.text
// Headers (##, ###, etc.) → *bold text* (WhatsApp has no header concept).
text = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`).ReplaceAllString(text, "*$1*")
// Blockquotes → plain text.
text = regexp.MustCompile(`(?m)^>\s*(.*)$`).ReplaceAllString(text, "$1")
// Links [text](url) → "text url" (WhatsApp doesn't support markdown links).
text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, "$1 $2")
// Bold: **text** or __text__ → *text*
text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "*$1*")
text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "*$1*")
// Strikethrough: ~~text~~ → ~text~
text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "~$1~")
// Inline code: `code` → ```code``` (WhatsApp has no inline code, only blocks).
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "```$1```")
// List items: leading - or * → bullet •
text = regexp.MustCompile(`(?m)^[-*]\s+`).ReplaceAllString(text, "• ")
// Restore code blocks as ``` … ``` preserving original content.
for i, code := range codeBlocks.codes {
// Trim trailing newline from extracted content — we add our own.
code = strings.TrimRight(code, "\n")
text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), "```\n"+code+"\n```")
}
// Collapse 3+ blank lines to 2.
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
return strings.TrimSpace(text)
}
// htmlTagToWaMd converts common HTML tags in LLM output to Markdown equivalents
// so they are then processed by the markdown → WhatsApp pipeline above.
var htmlToWaMdReplacers = []struct {
re *regexp.Regexp
repl string
}{
{regexp.MustCompile(`(?i)<br\s*/?>`), "\n"},
{regexp.MustCompile(`(?i)</?p\s*>`), "\n"},
{regexp.MustCompile(`(?i)<b>([\s\S]*?)</b>`), "**${1}**"},
{regexp.MustCompile(`(?i)<strong>([\s\S]*?)</strong>`), "**${1}**"},
{regexp.MustCompile(`(?i)<i>([\s\S]*?)</i>`), "_${1}_"},
{regexp.MustCompile(`(?i)<em>([\s\S]*?)</em>`), "_${1}_"},
{regexp.MustCompile(`(?i)<s>([\s\S]*?)</s>`), "~~${1}~~"},
{regexp.MustCompile(`(?i)<strike>([\s\S]*?)</strike>`), "~~${1}~~"},
{regexp.MustCompile(`(?i)<del>([\s\S]*?)</del>`), "~~${1}~~"},
{regexp.MustCompile(`(?i)<code>([\s\S]*?)</code>`), "`${1}`"},
{regexp.MustCompile(`(?i)<a\s+href="([^"]+)"[^>]*>([\s\S]*?)</a>`), "[${2}](${1})"},
}
func htmlTagToWaMd(text string) string {
for _, r := range htmlToWaMdReplacers {
text = r.re.ReplaceAllString(text, r.repl)
}
return text
}
type waCodeBlockMatch struct {
text string
codes []string
}
// waExtractCodeBlocks pulls fenced code blocks out of text and replaces them with
// \x00CB{n}\x00 placeholders so other regex passes don't mangle their contents.
func waExtractCodeBlocks(text string) waCodeBlockMatch {
re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
matches := re.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, m := range matches {
codes = append(codes, m[1])
}
i := 0
text = re.ReplaceAllStringFunc(text, func(_ string) string {
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
i++
return placeholder
})
return waCodeBlockMatch{text: text, codes: codes}
}
// chunkText splits text into pieces that fit within maxLen,
// preferring to split at paragraph (\n\n) or line (\n) boundaries.
func chunkText(text string, maxLen int) []string {
if len(text) <= maxLen {
return []string{text}
}
var chunks []string
for len(text) > 0 {
if len(text) <= maxLen {
chunks = append(chunks, text)
break
}
// Find the best split point: paragraph > line > space > hard cut.
cutAt := maxLen
if idx := strings.LastIndex(text[:maxLen], "\n\n"); idx > 0 {
cutAt = idx
} else if idx := strings.LastIndex(text[:maxLen], "\n"); idx > 0 {
cutAt = idx
} else if idx := strings.LastIndex(text[:maxLen], " "); idx > 0 {
cutAt = idx
}
chunks = append(chunks, strings.TrimRight(text[:cutAt], " \n"))
text = strings.TrimLeft(text[cutAt:], " \n")
}
return chunks
}
+49
View File
@@ -0,0 +1,49 @@
package whatsapp
import "testing"
func TestMarkdownToWhatsApp(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"plain text", "hello world", "hello world"},
{"header h1", "# Title", "*Title*"},
{"header h3", "### Sub", "*Sub*"},
{"bold stars", "this is **bold** text", "this is *bold* text"},
{"bold underscores", "this is __bold__ text", "this is *bold* text"},
{"strikethrough", "~~deleted~~", "~deleted~"},
{"inline code", "use `fmt.Println`", "use ```fmt.Println```"},
{"link", "[Go](https://go.dev)", "Go https://go.dev"},
{"unordered list dash", "- item one\n- item two", "• item one\n• item two"},
{"unordered list star", "* item one\n* item two", "• item one\n• item two"},
{"blockquote", "> quoted text", "quoted text"},
{
"fenced code block preserved",
"```go\nfmt.Println(\"hi\")\n```",
"```\nfmt.Println(\"hi\")\n```",
},
{
"code block not mangled by bold regex",
"```\n**not bold**\n```",
"```\n**not bold**\n```",
},
{"collapse blank lines", "a\n\n\n\nb", "a\n\nb"},
{"html bold", "<b>bold</b>", "*bold*"},
{"html italic", "<em>italic</em>", "_italic_"},
{"html strikethrough", "<del>removed</del>", "~removed~"},
{"html br", "line1<br>line2", "line1\nline2"},
{"html link", `<a href="https://x.com">link</a>`, "link https://x.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := markdownToWhatsApp(tt.in)
if got != tt.want {
t.Errorf("markdownToWhatsApp(%q)\n got: %q\nwant: %q", tt.in, got, tt.want)
}
})
}
}
+266
View File
@@ -0,0 +1,266 @@
package whatsapp
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const emptyMessageSentinel = "[empty message]"
// handleIncomingMessage processes an incoming WhatsApp message.
func (c *Channel) handleIncomingMessage(evt *events.Message) {
ctx := context.Background()
ctx = store.WithTenantID(ctx, c.TenantID())
if evt.Info.IsFromMe {
return
}
senderJID := evt.Info.Sender
chatJID := evt.Info.Chat
// WhatsApp uses dual identity: phone JID (@s.whatsapp.net) and LID (@lid).
// Groups may use LID addressing. Normalize to phone JID for consistent
// policy checks, pairing lookups, allowlists, and contact collection.
if evt.Info.AddressingMode == types.AddressingModeLID && !evt.Info.SenderAlt.IsEmpty() {
senderJID = evt.Info.SenderAlt
}
senderID := senderJID.String()
chatID := chatJID.String()
peerKind := "direct"
if chatJID.Server == types.GroupServer {
peerKind = "group"
}
slog.Debug("whatsapp incoming", "peer", peerKind, "sender", senderID, "chat", chatID,
"addressing", evt.Info.AddressingMode, "policy", c.config.GroupPolicy)
// DM/Group policy check.
if peerKind == "direct" {
if !c.checkDMPolicy(ctx, senderID, chatID) {
return
}
} else {
if !c.checkGroupPolicy(ctx, senderID, chatID) {
slog.Info("whatsapp group message rejected by policy", "sender_id", senderID, "chat_id", chatID, "policy", c.config.GroupPolicy)
return
}
}
if !c.IsAllowed(senderID) {
slog.Info("whatsapp message rejected by allowlist", "sender_id", senderID)
return
}
content := extractTextContent(evt.Message)
var mediaList []media.MediaInfo
mediaList = c.downloadMedia(evt)
if content == "" && len(mediaList) == 0 {
return
}
if content == "" {
content = emptyMessageSentinel
}
// Group history + mention detection.
historyLimit := c.config.HistoryLimit
if historyLimit == 0 {
historyLimit = channels.DefaultGroupHistoryLimit
}
if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention {
if !c.isMentioned(evt) {
// Not mentioned — record for context and skip.
senderLabel := evt.Info.PushName
if senderLabel == "" {
senderLabel = senderID
}
c.groupHistory.Record(chatID, channels.HistoryEntry{
Sender: senderLabel,
SenderID: senderID,
Body: content,
Timestamp: evt.Info.Timestamp,
MessageID: string(evt.Info.ID),
}, historyLimit)
return
}
// Mentioned — prepend accumulated group context.
content = c.groupHistory.BuildContext(chatID, content, historyLimit)
c.groupHistory.Clear(chatID)
}
metadata := map[string]string{
"message_id": string(evt.Info.ID),
}
if evt.Info.PushName != "" {
metadata["user_name"] = evt.Info.PushName
}
// Build media tags and bus.MediaFile list.
var mediaFiles []bus.MediaFile
if len(mediaList) > 0 {
mediaTags := media.BuildMediaTags(mediaList)
if mediaTags != "" {
if content != emptyMessageSentinel {
content = mediaTags + "\n\n" + content
} else {
content = mediaTags
}
}
for _, m := range mediaList {
if m.FilePath != "" {
mediaFiles = append(mediaFiles, bus.MediaFile{
Path: m.FilePath, MimeType: m.ContentType,
})
}
}
}
// Annotate with sender identity.
if senderName := metadata["user_name"]; senderName != "" {
content = fmt.Sprintf("[From: %s]\n%s", senderName, content)
}
// Collect contact.
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID,
metadata["user_name"], "", peerKind, "user", "", "")
}
// Typing indicator.
if prevCancel, ok := c.typingCancel.LoadAndDelete(chatID); ok {
if fn, ok := prevCancel.(context.CancelFunc); ok {
fn()
}
}
typingCtx, typingCancel := context.WithCancel(context.Background())
c.typingCancel.Store(chatID, typingCancel)
go c.keepTyping(typingCtx, chatJID)
// Derive userID from senderID.
userID := senderID
if idx := strings.IndexByte(senderID, '|'); idx > 0 {
userID = senderID[:idx]
}
c.Bus().PublishInbound(bus.InboundMessage{
Channel: c.Name(),
SenderID: senderID,
ChatID: chatID,
Content: content,
Media: mediaFiles,
PeerKind: peerKind,
UserID: userID,
AgentID: c.AgentID(),
TenantID: c.TenantID(),
Metadata: metadata,
})
// Schedule temp media file cleanup after agent pipeline has had time to process.
var tmpPaths []string
for _, mf := range mediaFiles {
tmpPaths = append(tmpPaths, mf.Path)
}
scheduleMediaCleanup(c.ctx, tmpPaths, 5*time.Minute)
}
// extractTextContent extracts text from any WhatsApp message variant.
// Includes quoted message context when present (reply-to messages).
func extractTextContent(msg *waE2E.Message) string {
if msg == nil {
return ""
}
var text string
var quotedText string
if msg.GetConversation() != "" {
text = msg.GetConversation()
} else if ext := msg.GetExtendedTextMessage(); ext != nil {
text = ext.GetText()
// Extract quoted (replied-to) message text.
if ci := ext.GetContextInfo(); ci != nil {
if qm := ci.GetQuotedMessage(); qm != nil {
quotedText = extractQuotedText(qm)
}
}
} else if img := msg.GetImageMessage(); img != nil {
text = img.GetCaption()
} else if vid := msg.GetVideoMessage(); vid != nil {
text = vid.GetCaption()
} else if doc := msg.GetDocumentMessage(); doc != nil {
text = doc.GetCaption()
}
if quotedText != "" && text != "" {
return fmt.Sprintf("[Replying to: %s]\n%s", quotedText, text)
}
if quotedText != "" {
return fmt.Sprintf("[Replying to: %s]", quotedText)
}
return text
}
// extractQuotedText extracts plain text from a quoted message (no recursion).
func extractQuotedText(msg *waE2E.Message) string {
if msg == nil {
return ""
}
if msg.GetConversation() != "" {
return msg.GetConversation()
}
if ext := msg.GetExtendedTextMessage(); ext != nil {
return ext.GetText()
}
if img := msg.GetImageMessage(); img != nil && img.GetCaption() != "" {
return img.GetCaption()
}
if vid := msg.GetVideoMessage(); vid != nil && vid.GetCaption() != "" {
return vid.GetCaption()
}
return ""
}
// isMentioned checks if the linked account is @mentioned in a group message.
// WhatsApp uses dual identity: phone JID and LID. Mentions may use either format.
func (c *Channel) isMentioned(evt *events.Message) bool {
c.lastQRMu.RLock()
myJID := c.myJID
myLID := c.myLID
c.lastQRMu.RUnlock()
if myJID.IsEmpty() && myLID.IsEmpty() {
return false // fail closed: unknown identity = not mentioned
}
// Check mentioned JIDs from extended text.
if ext := evt.Message.GetExtendedTextMessage(); ext != nil {
if ci := ext.GetContextInfo(); ci != nil {
for _, jidStr := range ci.GetMentionedJID() {
mentioned, _ := types.ParseJID(jidStr)
if !myJID.IsEmpty() && mentioned.User == myJID.User {
return true
}
if !myLID.IsEmpty() && mentioned.User == myLID.User {
return true
}
}
}
}
return false
}
@@ -0,0 +1,140 @@
package whatsapp
import (
"context"
"log/slog"
"os"
"strings"
"time"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types/events"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
)
// downloadMedia downloads media attachments from a WhatsApp message.
func (c *Channel) downloadMedia(evt *events.Message) []media.MediaInfo {
msg := evt.Message
if msg == nil {
return nil
}
type mediaItem struct {
mediaType string
mimetype string
filename string
download whatsmeow.DownloadableMessage
}
var items []mediaItem
if img := msg.GetImageMessage(); img != nil {
items = append(items, mediaItem{"image", img.GetMimetype(), "", img})
}
if vid := msg.GetVideoMessage(); vid != nil {
items = append(items, mediaItem{"video", vid.GetMimetype(), "", vid})
}
if aud := msg.GetAudioMessage(); aud != nil {
items = append(items, mediaItem{"audio", aud.GetMimetype(), "", aud})
}
if doc := msg.GetDocumentMessage(); doc != nil {
items = append(items, mediaItem{"document", doc.GetMimetype(), doc.GetFileName(), doc})
}
if stk := msg.GetStickerMessage(); stk != nil {
items = append(items, mediaItem{"sticker", stk.GetMimetype(), "", stk})
}
if len(items) == 0 {
return nil
}
var result []media.MediaInfo
for _, item := range items {
data, err := c.client.Download(c.ctx, item.download)
if err != nil {
reason := classifyDownloadError(err)
slog.Warn("whatsapp: media download failed", "type", item.mediaType, "reason", reason, "error", err)
continue
}
if len(data) > 20*1024*1024 { // 20MB limit
slog.Warn("whatsapp: media too large, skipping", "type", item.mediaType,
"size_mb", len(data)/(1024*1024))
continue
}
ext := mimeToExt(item.mimetype)
tmpFile, err := os.CreateTemp("", "goclaw_wa_*"+ext)
if err != nil {
slog.Warn("whatsapp: temp file creation failed", "error", err)
continue
}
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
continue
}
tmpFile.Close()
result = append(result, media.MediaInfo{
Type: item.mediaType,
FilePath: tmpFile.Name(),
ContentType: item.mimetype,
FileName: item.filename,
})
}
return result
}
// mimeToExt maps MIME types to file extensions.
func mimeToExt(mime string) string {
switch {
case strings.HasPrefix(mime, "image/jpeg"):
return ".jpg"
case strings.HasPrefix(mime, "image/png"):
return ".png"
case strings.HasPrefix(mime, "image/webp"):
return ".webp"
case strings.HasPrefix(mime, "video/mp4"):
return ".mp4"
case strings.HasPrefix(mime, "audio/ogg"):
return ".ogg"
case strings.HasPrefix(mime, "audio/mpeg"):
return ".mp3"
case strings.HasPrefix(mime, "application/pdf"):
return ".pdf"
default:
return ".bin"
}
}
// classifyDownloadError returns a human-readable reason for a media download failure.
func classifyDownloadError(err error) string {
msg := err.Error()
switch {
case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"):
return "timeout"
case strings.Contains(msg, "decrypt") || strings.Contains(msg, "cipher"):
return "decrypt_error"
case strings.Contains(msg, "404") || strings.Contains(msg, "not found"):
return "expired"
case strings.Contains(msg, "unsupported"):
return "unsupported"
default:
return "unknown"
}
}
// scheduleMediaCleanup removes temp media files after a delay.
// Uses time.AfterFunc so it does not block and respects the provided context for logging only.
func scheduleMediaCleanup(ctx context.Context, paths []string, delay time.Duration) {
if len(paths) == 0 {
return
}
time.AfterFunc(delay, func() {
for _, p := range paths {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
slog.Debug("whatsapp: temp media cleanup failed", "path", p, "error", err)
}
}
})
}
+131
View File
@@ -0,0 +1,131 @@
package whatsapp
import (
"testing"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"google.golang.org/protobuf/proto"
)
func TestIsMentioned(t *testing.T) {
// Helper to build an events.Message with mentioned JIDs in extended text.
makeEvt := func(mentionedJIDs []string) *events.Message {
return &events.Message{
Message: &waE2E.Message{
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
Text: proto.String("hello @bot"),
ContextInfo: &waE2E.ContextInfo{
MentionedJID: mentionedJIDs,
},
},
},
}
}
tests := []struct {
name string
myJID string // bot's phone JID
myLID string // bot's LID
mentions []string
want bool
}{
{
name: "mentioned by phone JID",
myJID: "1234567890@s.whatsapp.net",
mentions: []string{"1234567890@s.whatsapp.net"},
want: true,
},
{
name: "mentioned by LID",
myLID: "9876543210@lid",
mentions: []string{"9876543210@lid"},
want: true,
},
{
name: "mentioned by JID with device suffix",
myJID: "1234567890@s.whatsapp.net",
mentions: []string{"1234567890:42@s.whatsapp.net"},
want: true,
},
{
name: "mentioned by LID with device suffix",
myLID: "9876543210@lid",
mentions: []string{"9876543210:5@lid"},
want: true,
},
{
name: "dual identity — mentioned via LID when JID also set",
myJID: "1234567890@s.whatsapp.net",
myLID: "9876543210@lid",
mentions: []string{"9876543210@lid"},
want: true,
},
{
name: "dual identity — mentioned via JID when LID also set",
myJID: "1234567890@s.whatsapp.net",
myLID: "9876543210@lid",
mentions: []string{"1234567890@s.whatsapp.net"},
want: true,
},
{
name: "not mentioned — different user",
myJID: "1234567890@s.whatsapp.net",
mentions: []string{"9999999999@s.whatsapp.net"},
want: false,
},
{
name: "not mentioned — empty mentions",
myJID: "1234567890@s.whatsapp.net",
mentions: []string{},
want: false,
},
{
name: "unknown identity — fail closed",
myJID: "",
myLID: "",
mentions: []string{"1234567890@s.whatsapp.net"},
want: false,
},
{
name: "no extended text message",
myJID: "1234567890@s.whatsapp.net",
mentions: nil, // will use plain conversation message
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ch := &Channel{}
// Set bot identity.
if tt.myJID != "" {
jid, _ := types.ParseJID(tt.myJID)
ch.myJID = jid
}
if tt.myLID != "" {
lid, _ := types.ParseJID(tt.myLID)
ch.myLID = lid
}
var evt *events.Message
if tt.mentions == nil {
// Plain conversation message — no extended text.
evt = &events.Message{
Message: &waE2E.Message{
Conversation: proto.String("hello"),
},
}
} else {
evt = makeEvt(tt.mentions)
}
got := ch.isMentioned(evt)
if got != tt.want {
t.Errorf("isMentioned() = %v, want %v", got, tt.want)
}
})
}
}
+182
View File
@@ -0,0 +1,182 @@
package whatsapp
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"time"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
// Send delivers an outbound message to WhatsApp via whatsmeow.
func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error {
if c.client == nil || !c.client.IsConnected() {
return fmt.Errorf("whatsapp not connected")
}
chatJID, err := types.ParseJID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid whatsapp JID %q: %w", msg.ChatID, err)
}
// Send media attachments first.
if len(msg.Media) > 0 {
for i, m := range msg.Media {
caption := m.Caption
if caption == "" && i == 0 && msg.Content != "" {
caption = markdownToWhatsApp(msg.Content)
}
data, readErr := os.ReadFile(m.URL)
if readErr != nil {
return fmt.Errorf("read media file: %w", readErr)
}
waMsg, buildErr := c.buildMediaMessage(data, m.ContentType, caption)
if buildErr != nil {
return fmt.Errorf("build media message: %w", buildErr)
}
if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil {
return fmt.Errorf("send whatsapp media: %w", sendErr)
}
}
// Skip text if caption was used on first media.
if msg.Media[0].Caption == "" && msg.Content != "" {
msg.Content = ""
}
}
// Send text (chunked if exceeding limit).
if msg.Content != "" {
formatted := markdownToWhatsApp(msg.Content)
chunks := chunkText(formatted, maxMessageLen)
for _, chunk := range chunks {
waMsg := &waE2E.Message{
Conversation: proto.String(chunk),
}
if _, err := c.client.SendMessage(c.ctx, chatJID, waMsg); err != nil {
return fmt.Errorf("send whatsapp message: %w", err)
}
}
}
// Stop typing indicator.
if cancel, ok := c.typingCancel.LoadAndDelete(msg.ChatID); ok {
if fn, ok := cancel.(context.CancelFunc); ok {
fn()
}
}
go c.sendPresence(chatJID, types.ChatPresencePaused)
return nil
}
// buildMediaMessage uploads media to WhatsApp and returns the message proto.
func (c *Channel) buildMediaMessage(data []byte, mime, caption string) (*waE2E.Message, error) {
switch {
case strings.HasPrefix(mime, "image/"):
uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaImage)
if err != nil {
return nil, err
}
return &waE2E.Message{
ImageMessage: &waE2E.ImageMessage{
Caption: proto.String(caption),
Mimetype: proto.String(mime),
URL: &uploaded.URL,
DirectPath: &uploaded.DirectPath,
MediaKey: uploaded.MediaKey,
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(data))),
},
}, nil
case strings.HasPrefix(mime, "video/"):
uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaVideo)
if err != nil {
return nil, err
}
return &waE2E.Message{
VideoMessage: &waE2E.VideoMessage{
Caption: proto.String(caption),
Mimetype: proto.String(mime),
URL: &uploaded.URL,
DirectPath: &uploaded.DirectPath,
MediaKey: uploaded.MediaKey,
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(data))),
},
}, nil
case strings.HasPrefix(mime, "audio/"):
uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaAudio)
if err != nil {
return nil, err
}
return &waE2E.Message{
AudioMessage: &waE2E.AudioMessage{
Mimetype: proto.String(mime),
URL: &uploaded.URL,
DirectPath: &uploaded.DirectPath,
MediaKey: uploaded.MediaKey,
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(data))),
},
}, nil
default: // document
uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaDocument)
if err != nil {
return nil, err
}
return &waE2E.Message{
DocumentMessage: &waE2E.DocumentMessage{
Caption: proto.String(caption),
Mimetype: proto.String(mime),
URL: &uploaded.URL,
DirectPath: &uploaded.DirectPath,
MediaKey: uploaded.MediaKey,
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uint64(len(data))),
},
}, nil
}
}
// keepTyping sends "composing" presence repeatedly until ctx is cancelled.
func (c *Channel) keepTyping(ctx context.Context, chatJID types.JID) {
c.sendPresence(chatJID, types.ChatPresenceComposing)
ticker := time.NewTicker(8 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.sendPresence(chatJID, types.ChatPresenceComposing)
}
}
}
// sendPresence sends a WhatsApp chat presence update.
func (c *Channel) sendPresence(to types.JID, state types.ChatPresence) {
if c.client == nil || !c.client.IsConnected() {
return
}
if err := c.client.SendChatPresence(c.ctx, to, state, ""); err != nil {
slog.Debug("whatsapp: presence update failed", "state", state, "error", err)
}
}
+141
View File
@@ -0,0 +1,141 @@
package whatsapp
import (
"context"
"fmt"
"log/slog"
"time"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
)
// checkGroupPolicy evaluates the group policy for a sender.
func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
}
switch groupPolicy {
case "disabled":
return false
case "allowlist":
return c.IsAllowed(senderID)
case "pairing":
if c.HasAllowList() && c.IsAllowed(senderID) {
return true
}
if _, cached := c.approvedGroups.Load(chatID); cached {
return true
}
groupSenderID := fmt.Sprintf("group:%s", chatID)
if c.pairingService != nil {
paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
paired = true
}
if paired {
c.approvedGroups.Store(chatID, true)
return true
}
}
c.sendPairingReply(ctx, groupSenderID, chatID)
return false
default: // "open"
return true
}
}
// checkDMPolicy evaluates the DM policy for a sender.
func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "pairing"
}
switch dmPolicy {
case "disabled":
slog.Debug("whatsapp DM rejected: disabled", "sender_id", senderID)
return false
case "open":
return true
case "allowlist":
if !c.IsAllowed(senderID) {
slog.Debug("whatsapp DM rejected by allowlist", "sender_id", senderID)
return false
}
return true
default: // "pairing"
paired := false
if c.pairingService != nil {
p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
paired = true
} else {
paired = p
}
}
inAllowList := c.HasAllowList() && c.IsAllowed(senderID)
if paired || inAllowList {
return true
}
c.sendPairingReply(ctx, senderID, chatID)
return false
}
}
// sendPairingReply sends a pairing code to the user via WhatsApp.
func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
if c.pairingService == nil {
slog.Warn("whatsapp pairing: no pairing service configured")
return
}
// Debounce.
if lastSent, ok := c.pairingDebounce.Load(senderID); ok {
if time.Since(lastSent.(time.Time)) < pairingDebounceTime {
slog.Info("whatsapp pairing: debounced", "sender_id", senderID)
return
}
}
code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Warn("whatsapp pairing request failed", "sender_id", senderID, "channel", c.Name(), "error", err)
return
}
replyText := fmt.Sprintf(
"GoClaw: access not configured.\n\nYour WhatsApp ID: %s\n\nPairing code: %s\n\nAsk the account owner to approve with:\n goclaw pairing approve %s",
senderID, code, code,
)
if c.client == nil || !c.client.IsConnected() {
slog.Warn("whatsapp not connected, cannot send pairing reply")
return
}
chatJID, parseErr := types.ParseJID(chatID)
if parseErr != nil {
slog.Warn("whatsapp pairing: invalid chatID JID", "chatID", chatID, "error", parseErr)
return
}
waMsg := &waE2E.Message{
Conversation: proto.String(replyText),
}
if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil {
slog.Warn("failed to send whatsapp pairing reply", "error", sendErr)
} else {
c.pairingDebounce.Store(senderID, time.Now())
slog.Info("whatsapp pairing reply sent", "sender_id", senderID, "code", code)
}
}
+243
View File
@@ -0,0 +1,243 @@
package whatsapp
import (
"context"
"encoding/base64"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
qrcode "github.com/skip2/go-qrcode"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/store"
goclawprotocol "github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
const qrSessionTimeout = 3 * time.Minute
// cancelEntry wraps a CancelFunc so it can be stored in sync.Map.CompareAndDelete.
type cancelEntry struct {
cancel context.CancelFunc
}
// QRMethods handles whatsapp.qr.start — delivers QR codes to the UI wizard.
type QRMethods struct {
instanceStore store.ChannelInstanceStore
manager *channels.Manager
activeSessions sync.Map // instanceID (string) -> *cancelEntry
}
func NewQRMethods(instanceStore store.ChannelInstanceStore, manager *channels.Manager) *QRMethods {
return &QRMethods{instanceStore: instanceStore, manager: manager}
}
func (m *QRMethods) Register(router *gateway.MethodRouter) {
router.Register(goclawprotocol.MethodWhatsAppQRStart, m.handleQRStart)
}
func (m *QRMethods) handleQRStart(ctx context.Context, client *gateway.Client, req *goclawprotocol.RequestFrame) {
var params struct {
InstanceID string `json:"instance_id"`
ForceReauth bool `json:"force_reauth"`
}
if req.Params != nil {
_ = json.Unmarshal(req.Params, &params)
}
instID, err := uuid.Parse(params.InstanceID)
if err != nil {
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInvalidRequest, "invalid instance_id"))
return
}
inst, err := m.instanceStore.Get(ctx, instID)
if err != nil || inst.ChannelType != channels.TypeWhatsApp {
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrNotFound, "whatsapp instance not found"))
return
}
qrCtx, cancel := context.WithTimeout(ctx, qrSessionTimeout)
entry := &cancelEntry{cancel: cancel}
// Cancel any previous QR session for this instance.
if prev, loaded := m.activeSessions.Swap(params.InstanceID, entry); loaded {
if prevEntry, ok := prev.(*cancelEntry); ok {
prevEntry.cancel()
}
}
// ACK immediately — QR/done events arrive asynchronously.
client.SendResponse(goclawprotocol.NewOKResponse(req.ID, map[string]any{"status": "started"}))
go m.runQRSession(qrCtx, entry, client, params.InstanceID, inst.Name, params.ForceReauth)
}
func (m *QRMethods) runQRSession(ctx context.Context, entry *cancelEntry,
client *gateway.Client, instanceIDStr, channelName string, forceReauth bool) {
defer entry.cancel()
defer m.activeSessions.CompareAndDelete(instanceIDStr, entry)
// Wait for channel to appear in manager — instance creation triggers an async
// reload, so the channel may not be registered yet when the wizard fires QR start.
var wa *Channel
for attempt := 0; attempt < 10; attempt++ {
if ch, ok := m.manager.GetChannel(channelName); ok {
if w, ok := ch.(*Channel); ok {
wa = w
break
}
}
select {
case <-ctx.Done():
return
case <-time.After(500 * time.Millisecond):
}
}
if wa == nil {
client.SendEvent(goclawprotocol.EventFrame{
Type: goclawprotocol.FrameTypeEvent,
Event: goclawprotocol.EventWhatsAppQRDone,
Payload: map[string]any{
"instance_id": instanceIDStr,
"success": false,
"error": "channel not found",
},
})
return
}
// Already authenticated and no force-reauth → signal connected.
if wa.IsAuthenticated() && !forceReauth {
client.SendEvent(goclawprotocol.EventFrame{
Type: goclawprotocol.FrameTypeEvent,
Event: goclawprotocol.EventWhatsAppQRDone,
Payload: map[string]any{
"instance_id": instanceIDStr,
"success": true,
"already_connected": true,
},
})
return
}
// Force reauth: clear session and prepare for fresh QR.
if forceReauth {
if err := wa.Reauth(); err != nil {
slog.Warn("whatsapp QR: reauth failed", "error", err)
}
}
// Deliver cached QR if available.
if cached := wa.GetLastQRB64(); cached != "" {
client.SendEvent(goclawprotocol.EventFrame{
Type: goclawprotocol.FrameTypeEvent,
Event: goclawprotocol.EventWhatsAppQRCode,
Payload: map[string]any{
"instance_id": instanceIDStr,
"png_b64": cached,
},
})
}
// Start QR flow — get QR channel from whatsmeow.
qrChan, err := wa.StartQRFlow(ctx)
if err != nil {
slog.Warn("whatsapp QR: start flow failed", "error", err)
client.SendEvent(goclawprotocol.EventFrame{
Type: goclawprotocol.FrameTypeEvent,
Event: goclawprotocol.EventWhatsAppQRDone,
Payload: map[string]any{
"instance_id": instanceIDStr,
"success": false,
"error": err.Error(),
},
})
return
}
if qrChan == nil {
// Already authenticated (StartQRFlow returned nil).
client.SendEvent(goclawprotocol.EventFrame{
Type: goclawprotocol.FrameTypeEvent,
Event: goclawprotocol.EventWhatsAppQRDone,
Payload: map[string]any{
"instance_id": instanceIDStr,
"success": true,
"already_connected": true,
},
})
return
}
// Process QR events from whatsmeow.
for {
select {
case <-ctx.Done():
client.SendEvent(goclawprotocol.EventFrame{
Type: goclawprotocol.FrameTypeEvent,
Event: goclawprotocol.EventWhatsAppQRDone,
Payload: map[string]any{
"instance_id": instanceIDStr,
"success": false,
"error": "QR session timed out — restart to try again",
},
})
return
case evt, ok := <-qrChan:
if !ok {
return // channel closed
}
switch evt.Event {
case "code":
png, qrErr := qrcode.Encode(evt.Code, qrcode.Medium, 256)
if qrErr != nil {
slog.Warn("whatsapp: QR PNG encode failed", "error", qrErr)
continue
}
pngB64 := base64.StdEncoding.EncodeToString(png)
wa.cacheQR(pngB64)
client.SendEvent(goclawprotocol.EventFrame{
Type: goclawprotocol.FrameTypeEvent,
Event: goclawprotocol.EventWhatsAppQRCode,
Payload: map[string]any{
"instance_id": instanceIDStr,
"png_b64": pngB64,
},
})
case "success":
client.SendEvent(goclawprotocol.EventFrame{
Type: goclawprotocol.FrameTypeEvent,
Event: goclawprotocol.EventWhatsAppQRDone,
Payload: map[string]any{
"instance_id": instanceIDStr,
"success": true,
},
})
slog.Info("whatsapp QR session completed", "instance", instanceIDStr)
return
case "timeout":
client.SendEvent(goclawprotocol.EventFrame{
Type: goclawprotocol.FrameTypeEvent,
Event: goclawprotocol.EventWhatsAppQRDone,
Payload: map[string]any{
"instance_id": instanceIDStr,
"success": false,
"error": "QR code expired — restart to try again",
},
})
return
}
}
}
}
+153 -342
View File
@@ -2,14 +2,18 @@ package whatsapp
import (
"context"
"encoding/json"
"database/sql"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"go.mau.fi/whatsmeow"
wastore "go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"google.golang.org/protobuf/proto"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
@@ -17,52 +21,118 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const pairingDebounceTime = 60 * time.Second
const (
pairingDebounceTime = 60 * time.Second
maxMessageLen = 4096 // WhatsApp practical message length limit
)
// Channel connects to a WhatsApp bridge via WebSocket.
// The bridge (e.g. whatsapp-web.js based) handles the actual WhatsApp
// protocol; this channel just sends/receives JSON messages over WS.
func init() {
// Set device name shown in WhatsApp's "Linked Devices" screen (once at package init).
wastore.DeviceProps.Os = proto.String("GoClaw")
}
// Channel connects directly to WhatsApp via go.mau.fi/whatsmeow.
// Auth state is stored in PostgreSQL (standard) or SQLite (desktop).
type Channel struct {
*channels.BaseChannel
conn *websocket.Conn
client *whatsmeow.Client
container *sqlstore.Container
config config.WhatsAppConfig
mu sync.Mutex
connected bool
ctx context.Context
cancel context.CancelFunc
parentCtx context.Context // stored from Start() for Reauth() context chain
pairingService store.PairingStore
pairingDebounce sync.Map // senderID → time.Time
approvedGroups sync.Map // chatID → true (in-memory cache for paired groups)
groupHistory *channels.PendingHistory // tracks group messages for context
// QR state
lastQRMu sync.RWMutex
lastQRB64 string // base64-encoded PNG, empty when authenticated
waAuthenticated bool // true once WhatsApp account is connected
myJID types.JID // linked account's phone JID for mention detection
myLID types.JID // linked account's LID — WhatsApp's newer identifier
// typingCancel tracks active typing-refresh loops per chatID.
typingCancel sync.Map // chatID string → context.CancelFunc
// reauthMu serializes Reauth() and StartQRFlow() to prevent race when user clicks reauth rapidly.
reauthMu sync.Mutex
}
// New creates a new WhatsApp channel from config.
func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (*Channel, error) {
if cfg.BridgeURL == "" {
return nil, fmt.Errorf("whatsapp bridge_url is required")
}
// GetLastQRB64 returns the most recent QR PNG (base64).
func (c *Channel) GetLastQRB64() string {
c.lastQRMu.RLock()
defer c.lastQRMu.RUnlock()
return c.lastQRB64
}
// IsAuthenticated reports whether the WhatsApp account is currently authenticated.
func (c *Channel) IsAuthenticated() bool {
c.lastQRMu.RLock()
defer c.lastQRMu.RUnlock()
return c.waAuthenticated
}
// cacheQR stores the latest QR PNG (base64) for late-joining wizard clients.
func (c *Channel) cacheQR(pngB64 string) {
c.lastQRMu.Lock()
c.lastQRB64 = pngB64
c.lastQRMu.Unlock()
}
// New creates a new WhatsApp channel backed by whatsmeow.
// dialect must be "pgx" (PostgreSQL) or "sqlite3" (SQLite/desktop).
func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus,
pairingSvc store.PairingStore, db *sql.DB,
pendingStore store.PendingMessageStore, dialect string) (*Channel, error) {
base := channels.NewBaseChannel(channels.TypeWhatsApp, msgBus, cfg.AllowFrom)
base.ValidatePolicy(cfg.DMPolicy, cfg.GroupPolicy)
container := sqlstore.NewWithDB(db, dialect, nil)
if err := container.Upgrade(context.Background()); err != nil {
return nil, fmt.Errorf("whatsapp sqlstore upgrade: %w", err)
}
return &Channel{
BaseChannel: base,
config: cfg,
pairingService: pairingSvc,
container: container,
groupHistory: channels.MakeHistory("whatsapp", pendingStore, base.TenantID()),
}, nil
}
// Start connects to the WhatsApp bridge WebSocket and begins listening.
// Start initializes the whatsmeow client and connects to WhatsApp.
func (c *Channel) Start(ctx context.Context) error {
slog.Info("starting whatsapp channel", "bridge_url", c.config.BridgeURL)
slog.Info("starting whatsapp channel (whatsmeow)")
c.MarkStarting("Initializing WhatsApp connection")
c.parentCtx = ctx
c.ctx, c.cancel = context.WithCancel(ctx)
if err := c.connect(); err != nil {
// Don't fail hard — reconnect loop will keep trying
slog.Warn("initial whatsapp bridge connection failed, will retry", "error", err)
deviceStore, err := c.container.GetFirstDevice(ctx)
if err != nil {
return fmt.Errorf("whatsapp get device: %w", err)
}
go c.listenLoop()
c.client = whatsmeow.NewClient(deviceStore, nil)
c.client.AddEventHandler(c.handleEvent)
if c.client.Store.ID == nil {
// Not paired yet — QR flow will be triggered by qr_methods.go.
slog.Info("whatsapp: not paired yet, waiting for QR scan", "channel", c.Name())
c.MarkDegraded("Awaiting QR scan", "Scan QR code to authenticate",
channels.ChannelFailureKindAuth, false)
} else {
if err := c.client.Connect(); err != nil {
slog.Warn("whatsapp: initial connect failed", "error", err)
c.MarkDegraded("Connection failed", err.Error(),
channels.ChannelFailureKindNetwork, true)
}
}
c.SetRunning(true)
return nil
@@ -78,333 +148,74 @@ func (c *Channel) Stop(_ context.Context) error {
if c.cancel != nil {
c.cancel()
}
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
c.connected = false
c.SetRunning(false)
return nil
}
// Send delivers an outbound message to the WhatsApp bridge.
func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("whatsapp bridge not connected")
if c.client != nil {
c.client.Disconnect()
}
payload := map[string]any{
"type": "message",
"to": msg.ChatID,
"content": msg.Content,
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal whatsapp message: %w", err)
}
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
return fmt.Errorf("send whatsapp message: %w", err)
}
return nil
}
// connect establishes the WebSocket connection to the bridge.
func (c *Channel) connect() error {
dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second
conn, _, err := dialer.Dial(c.config.BridgeURL, nil)
if err != nil {
return fmt.Errorf("dial whatsapp bridge %s: %w", c.config.BridgeURL, err)
}
c.mu.Lock()
c.conn = conn
c.connected = true
c.mu.Unlock()
slog.Info("whatsapp bridge connected", "url", c.config.BridgeURL)
return nil
}
// listenLoop reads messages from the bridge with automatic reconnection.
func (c *Channel) listenLoop() {
backoff := time.Second
for {
select {
case <-c.ctx.Done():
return
default:
// Cancel all active typing goroutines.
c.typingCancel.Range(func(key, value any) bool {
if fn, ok := value.(context.CancelFunc); ok {
fn()
}
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
// Not connected — attempt reconnect with backoff
slog.Info("attempting whatsapp bridge reconnect", "backoff", backoff)
select {
case <-c.ctx.Done():
return
case <-time.After(backoff):
}
if err := c.connect(); err != nil {
slog.Warn("whatsapp bridge reconnect failed", "error", err)
backoff = min(backoff*2, 30*time.Second)
continue
}
backoff = time.Second // reset on success
continue
}
_, message, err := conn.ReadMessage()
if err != nil {
slog.Warn("whatsapp read error, will reconnect", "error", err)
c.mu.Lock()
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
c.connected = false
c.mu.Unlock()
continue
}
var msg map[string]any
if err := json.Unmarshal(message, &msg); err != nil {
slog.Warn("invalid whatsapp message JSON", "error", err)
continue
}
msgType, _ := msg["type"].(string)
if msgType == "message" {
c.handleIncomingMessage(msg)
}
}
}
// handleIncomingMessage processes a message received from the bridge.
// Expected format: {"type":"message","from":"...","chat":"...","content":"...","id":"...","from_name":"...","media":[...]}
func (c *Channel) handleIncomingMessage(msg map[string]any) {
ctx := context.Background()
ctx = store.WithTenantID(ctx, c.TenantID())
senderID, ok := msg["from"].(string)
if !ok || senderID == "" {
return
}
chatID, _ := msg["chat"].(string)
if chatID == "" {
chatID = senderID
}
// WhatsApp groups have chatID ending in "@g.us"
peerKind := "direct"
if strings.HasSuffix(chatID, "@g.us") {
peerKind = "group"
}
// DM/Group policy check
if peerKind == "direct" {
if !c.checkDMPolicy(ctx, senderID, chatID) {
return
}
} else {
if !c.checkGroupPolicy(ctx, senderID, chatID) {
slog.Debug("whatsapp group message rejected by policy", "sender_id", senderID)
return
}
}
// Allowlist check
if !c.IsAllowed(senderID) {
slog.Debug("whatsapp message rejected by allowlist", "sender_id", senderID)
return
}
content, _ := msg["content"].(string)
if content == "" {
content = "[empty message]"
}
var media []string
if mediaData, ok := msg["media"].([]any); ok {
media = make([]string, 0, len(mediaData))
for _, m := range mediaData {
if path, ok := m.(string); ok {
media = append(media, path)
}
}
}
metadata := make(map[string]string)
if messageID, ok := msg["id"].(string); ok {
metadata["message_id"] = messageID
}
if userName, ok := msg["from_name"].(string); ok {
metadata["user_name"] = userName
}
slog.Debug("whatsapp message received",
"sender_id", senderID,
"chat_id", chatID,
"preview", channels.Truncate(content, 50),
)
// Collect contact for processed messages.
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind, "user", "", "")
}
// Annotate with sender identity so the agent knows who is messaging.
if senderName := metadata["user_name"]; senderName != "" {
content = fmt.Sprintf("[From: %s]\n%s", senderName, content)
}
c.HandleMessage(senderID, chatID, content, media, metadata, peerKind)
}
// checkGroupPolicy evaluates the group policy for a sender, with pairing support.
func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
}
switch groupPolicy {
case "disabled":
return false
case "allowlist":
return c.IsAllowed(senderID)
case "pairing":
if c.IsAllowed(senderID) {
return true
}
if _, cached := c.approvedGroups.Load(chatID); cached {
return true
}
groupSenderID := fmt.Sprintf("group:%s", chatID)
if c.pairingService != nil {
paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
paired = true
}
if paired {
c.approvedGroups.Store(chatID, true)
return true
}
}
c.sendPairingReply(ctx, groupSenderID, chatID)
return false
default: // "open"
c.typingCancel.Delete(key)
return true
}
}
// checkDMPolicy evaluates the DM policy for a sender, handling pairing flow.
func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "pairing"
}
switch dmPolicy {
case "disabled":
slog.Debug("whatsapp DM rejected: disabled", "sender_id", senderID)
return false
case "open":
return true
case "allowlist":
if !c.IsAllowed(senderID) {
slog.Debug("whatsapp DM rejected by allowlist", "sender_id", senderID)
return false
}
return true
default: // "pairing"
paired := false
if c.pairingService != nil {
p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
paired = true
} else {
paired = p
}
}
inAllowList := c.HasAllowList() && c.IsAllowed(senderID)
if paired || inAllowList {
return true
}
c.sendPairingReply(ctx, senderID, chatID)
return false
}
}
// sendPairingReply sends a pairing code to the user via the WS bridge.
func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
if c.pairingService == nil {
return
}
// Debounce
if lastSent, ok := c.pairingDebounce.Load(senderID); ok {
if time.Since(lastSent.(time.Time)) < pairingDebounceTime {
return
}
}
code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("whatsapp pairing request failed", "sender_id", senderID, "error", err)
return
}
replyText := fmt.Sprintf(
"GoClaw: access not configured.\n\nYour WhatsApp ID: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s",
senderID, code, code,
)
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
slog.Warn("whatsapp bridge not connected, cannot send pairing reply")
return
}
payload, _ := json.Marshal(map[string]any{
"type": "message",
"to": chatID,
"content": replyText,
})
if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
slog.Warn("failed to send whatsapp pairing reply", "error", err)
} else {
c.pairingDebounce.Store(senderID, time.Now())
slog.Info("whatsapp pairing reply sent", "sender_id", senderID, "code", code)
c.SetRunning(false)
c.MarkStopped("Stopped")
return nil
}
// handleEvent dispatches whatsmeow events.
func (c *Channel) handleEvent(evt any) {
switch v := evt.(type) {
case *events.Message:
c.handleIncomingMessage(v)
case *events.Connected:
c.handleConnected()
case *events.Disconnected:
c.handleDisconnected()
case *events.LoggedOut:
c.handleLoggedOut(v)
case *events.PairSuccess:
slog.Info("whatsapp: pair success", "channel", c.Name())
}
}
// handleConnected processes the Connected event.
func (c *Channel) handleConnected() {
c.lastQRMu.Lock()
c.waAuthenticated = true
c.lastQRB64 = ""
if c.client.Store.ID != nil {
c.myJID = *c.client.Store.ID
c.myLID = c.client.Store.GetLID()
slog.Info("whatsapp: connected", "jid", c.myJID.String(),
"lid", c.myLID.String(), "channel", c.Name())
}
c.lastQRMu.Unlock()
c.MarkHealthy("WhatsApp authenticated and connected")
}
// handleDisconnected processes the Disconnected event.
func (c *Channel) handleDisconnected() {
c.lastQRMu.Lock()
c.waAuthenticated = false
c.lastQRMu.Unlock()
c.MarkDegraded("WhatsApp disconnected", "Waiting for reconnect",
channels.ChannelFailureKindNetwork, true)
// whatsmeow auto-reconnects — no manual reconnect loop needed.
}
// handleLoggedOut processes the LoggedOut event.
func (c *Channel) handleLoggedOut(evt *events.LoggedOut) {
slog.Warn("whatsapp: logged out", "reason", evt.Reason, "channel", c.Name())
c.lastQRMu.Lock()
c.waAuthenticated = false
c.lastQRMu.Unlock()
c.MarkDegraded("WhatsApp logged out", "Re-scan QR to reconnect",
channels.ChannelFailureKindAuth, false)
}
+8 -6
View File
@@ -132,12 +132,14 @@ type SlackConfig struct {
}
type WhatsAppConfig struct {
Enabled bool `json:"enabled"`
BridgeURL string `json:"bridge_url"`
AllowFrom FlexibleStringSlice `json:"allow_from"`
DMPolicy string `json:"dm_policy,omitempty"` // "open" (default), "allowlist", "disabled"
GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled"
BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit)
Enabled bool `json:"enabled"`
AuthDir string `json:"auth_dir,omitempty"` // optional: SQLite auth dir override (desktop)
AllowFrom FlexibleStringSlice `json:"allow_from"`
DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default for DB instances), "open", "allowlist", "disabled"
GroupPolicy string `json:"group_policy,omitempty"` // "pairing" (default for DB instances), "open" (default for config), "allowlist", "disabled"
RequireMention *bool `json:"require_mention,omitempty"` // only respond in groups when bot is @mentioned (default false)
HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 200, 0=disabled)
BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit)
}
type ZaloConfig struct {
+2 -4
View File
@@ -120,7 +120,7 @@ func (c *Config) applyEnvOverrides() {
envStr("GOCLAW_LARK_APP_SECRET", &c.Channels.Feishu.AppSecret)
envStr("GOCLAW_LARK_ENCRYPT_KEY", &c.Channels.Feishu.EncryptKey)
envStr("GOCLAW_LARK_VERIFICATION_TOKEN", &c.Channels.Feishu.VerificationToken)
envStr("GOCLAW_WHATSAPP_BRIDGE_URL", &c.Channels.WhatsApp.BridgeURL)
// WhatsApp no longer needs bridge_url — runs natively via whatsmeow.
envStr("GOCLAW_SLACK_BOT_TOKEN", &c.Channels.Slack.BotToken)
envStr("GOCLAW_SLACK_APP_TOKEN", &c.Channels.Slack.AppToken)
envStr("GOCLAW_SLACK_USER_TOKEN", &c.Channels.Slack.UserToken)
@@ -144,9 +144,7 @@ func (c *Config) applyEnvOverrides() {
if c.Channels.Feishu.AppID != "" && c.Channels.Feishu.AppSecret != "" {
c.Channels.Feishu.Enabled = true
}
if c.Channels.WhatsApp.BridgeURL != "" {
c.Channels.WhatsApp.Enabled = true
}
// WhatsApp is enabled via config or DB instances (no bridge_url needed).
if c.Channels.Slack.BotToken != "" && c.Channels.Slack.AppToken != "" {
c.Channels.Slack.Enabled = true
}
+5
View File
@@ -116,6 +116,11 @@ func clientCanReceiveEvent(c *Client, event bus.Event) bool {
return false
}
// WhatsApp QR events: delivered directly to the requesting client, not broadcast.
if strings.HasPrefix(event.Name, "whatsapp.") {
return false
}
// Skill dep events → broadcast (non-sensitive, skill names only).
if strings.HasPrefix(event.Name, "skill.") {
return true
+12 -1
View File
@@ -16,6 +16,7 @@ import (
// ACPProvider implements Provider by orchestrating ACP-compatible agent subprocesses.
// It delegates to a ProcessPool that manages agent lifecycle over JSON-RPC 2.0 stdio.
type ACPProvider struct {
name string // provider name (default: "acp")
pool *acp.ProcessPool
bridge *acp.ToolBridge
defaultModel string
@@ -26,6 +27,15 @@ type ACPProvider struct {
// ACPOption configures an ACPProvider.
type ACPOption func(*ACPProvider)
// WithACPName overrides the provider name (default: "acp").
func WithACPName(name string) ACPOption {
return func(p *ACPProvider) {
if name != "" {
p.name = name
}
}
}
// WithACPModel sets the default model/agent name.
func WithACPModel(model string) ACPOption {
return func(p *ACPProvider) {
@@ -47,6 +57,7 @@ func WithACPPermMode(mode string) ACPOption {
// NewACPProvider creates a provider that orchestrates ACP agents as subprocesses.
func NewACPProvider(binary string, args []string, workDir string, idleTTL time.Duration, denyPatterns []*regexp.Regexp, opts ...ACPOption) *ACPProvider {
p := &ACPProvider{
name: "acp",
defaultModel: "claude",
}
for _, opt := range opts {
@@ -70,7 +81,7 @@ func NewACPProvider(binary string, args []string, workDir string, idleTTL time.D
return p
}
func (p *ACPProvider) Name() string { return "acp" }
func (p *ACPProvider) Name() string { return p.name }
func (p *ACPProvider) DefaultModel() string { return p.defaultModel }
// Chat sends a prompt and returns the complete response (non-streaming).
+12 -1
View File
@@ -38,6 +38,7 @@ func resolveAnthropicModel(model, defaultModel string) string {
// AnthropicProvider implements Provider using the Anthropic Claude API via net/http.
type AnthropicProvider struct {
name string // provider name (default: "anthropic")
apiKey string
baseURL string
defaultModel string
@@ -48,6 +49,7 @@ type AnthropicProvider struct {
// NewAnthropicProvider creates a new Anthropic provider.
func NewAnthropicProvider(apiKey string, opts ...AnthropicOption) *AnthropicProvider {
p := &AnthropicProvider{
name: "anthropic",
apiKey: apiKey,
baseURL: anthropicAPIBase,
defaultModel: defaultClaudeModel,
@@ -62,6 +64,15 @@ func NewAnthropicProvider(apiKey string, opts ...AnthropicOption) *AnthropicProv
type AnthropicOption func(*AnthropicProvider)
// WithAnthropicName overrides the provider name (default: "anthropic").
func WithAnthropicName(name string) AnthropicOption {
return func(p *AnthropicProvider) {
if name != "" {
p.name = name
}
}
}
func WithAnthropicModel(model string) AnthropicOption {
return func(p *AnthropicProvider) { p.defaultModel = model }
}
@@ -74,7 +85,7 @@ func WithAnthropicBaseURL(baseURL string) AnthropicOption {
}
}
func (p *AnthropicProvider) Name() string { return "anthropic" }
func (p *AnthropicProvider) Name() string { return p.name }
func (p *AnthropicProvider) DefaultModel() string { return p.defaultModel }
func (p *AnthropicProvider) SupportsThinking() bool { return true }
+12 -1
View File
@@ -39,6 +39,7 @@ const OptTenantID = "tenant_id"
// It acts as a thin proxy: CLI manages session history, tool execution, and context.
// GoClaw only forwards the latest user message and streams back the response.
type ClaudeCLIProvider struct {
name string // provider name (default: "claude-cli")
cliPath string // path to claude binary (default: "claude")
defaultModel string // default: "sonnet"
baseWorkDir string // base dir for agent workspaces
@@ -54,6 +55,15 @@ type ClaudeCLIProvider struct {
// ClaudeCLIOption configures the provider.
type ClaudeCLIOption func(*ClaudeCLIProvider)
// WithClaudeCLIName overrides the provider name (default: "claude-cli").
func WithClaudeCLIName(name string) ClaudeCLIOption {
return func(p *ClaudeCLIProvider) {
if name != "" {
p.name = name
}
}
}
// WithClaudeCLIModel sets the default model alias.
func WithClaudeCLIModel(model string) ClaudeCLIOption {
return func(p *ClaudeCLIProvider) {
@@ -110,6 +120,7 @@ func NewClaudeCLIProvider(cliPath string, opts ...ClaudeCLIOption) *ClaudeCLIPro
cliPath = "claude"
}
p := &ClaudeCLIProvider{
name: "claude-cli",
cliPath: cliPath,
defaultModel: "sonnet",
baseWorkDir: defaultCLIWorkDir(),
@@ -122,7 +133,7 @@ func NewClaudeCLIProvider(cliPath string, opts ...ClaudeCLIOption) *ClaudeCLIPro
return p
}
func (p *ClaudeCLIProvider) Name() string { return "claude-cli" }
func (p *ClaudeCLIProvider) Name() string { return p.name }
func (p *ClaudeCLIProvider) DefaultModel() string { return p.defaultModel }
// Close cleans up temp files (per-session MCP configs, hooks settings). Implements io.Closer.
+10
View File
@@ -112,6 +112,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onC
}
cmd := exec.CommandContext(ctx, p.cliPath, args...)
cmd.WaitDelay = 5 * time.Second // force-close pipes if process lingers after kill
cmd.Dir = workDir
cmd.Env = filterCLIEnv(os.Environ())
if stdin != nil {
@@ -151,6 +152,9 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onC
var contentBuf strings.Builder
for scanner.Scan() {
if ctx.Err() != nil {
break // context cancelled (abort) → exit immediately
}
line := scanner.Bytes()
if len(line) == 0 {
continue
@@ -201,6 +205,12 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onC
}
}
// Context cancelled (abort): best-effort reap (bounded by WaitDelay), then return.
if ctx.Err() != nil {
_ = cmd.Wait()
return nil, ctx.Err()
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("claude-cli: stream read error: %w", err)
}
+1 -1
View File
@@ -28,7 +28,7 @@ func IsDefaultChannelInstance(name string) bool {
if strings.HasSuffix(name, "/default") {
return true
}
// Legacy Telegram default uses bare name "telegram"
// Legacy config-based defaults that were seeded with bare channel-type names.
switch name {
case "telegram", "discord", "feishu", "zalo_oa", "whatsapp":
return true
+41
View File
@@ -14,6 +14,7 @@ import (
const (
ConfigTypeFileWriter = "file_writer" // Group file write access
ConfigTypeHeartbeat = "heartbeat" // Heartbeat config access
ConfigTypeCron = "cron" // Cron job management access
)
// ConfigPermission represents an allow/deny rule for agent configuration.
@@ -74,3 +75,43 @@ func CheckFileWriterPermission(ctx context.Context, permStore ConfigPermissionSt
}
return nil
}
// CheckCronPermission returns an error if the caller is in a group context
// and does not have cron or file_writer permission. Returns nil if allowed.
// Fail-open: returns nil on DB errors or missing context (cron, subagent).
func CheckCronPermission(ctx context.Context, permStore ConfigPermissionStore) error {
if permStore == nil {
return nil
}
userID := UserIDFromContext(ctx)
if !strings.HasPrefix(userID, "group:") && !strings.HasPrefix(userID, "guild:") {
return nil // not a group context
}
agentID := AgentIDFromContext(ctx)
if agentID == uuid.Nil {
return nil // no agent context
}
senderID := SenderIDFromContext(ctx)
if senderID == "" {
return nil // system context (cron, subagent)
}
numericID := strings.SplitN(senderID, "|", 2)[0]
// Check cron-specific permission first.
allowed, err := permStore.CheckPermission(ctx, agentID, userID, ConfigTypeCron, numericID)
if err != nil {
return nil // fail-open
}
if allowed {
return nil
}
// Fall back to file_writer (implies full mutation access).
allowed, err = permStore.CheckPermission(ctx, agentID, userID, ConfigTypeFileWriter, numericID)
if err != nil {
return nil // fail-open
}
if !allowed {
return fmt.Errorf("permission denied: only users with cron or file_writer permission can manage cron jobs in group chats")
}
return nil
}
+20
View File
@@ -39,6 +39,9 @@ const (
TenantSlugKey contextKey = "goclaw_tenant_slug"
// RoleKey is the context key for the caller's permission role (e.g. "admin", "operator", "viewer").
RoleKey contextKey = "goclaw_role"
// CredentialUserIDKey holds the resolved tenant user identity for credential lookups.
// Falls back to UserIDFromContext if not set.
CredentialUserIDKey contextKey = "goclaw_credential_user_id"
)
// WithShellDenyGroups returns a new context with shell deny group overrides.
@@ -73,6 +76,23 @@ func UserIDFromContext(ctx context.Context) string {
return ""
}
// WithCredentialUserID returns a new context with the resolved tenant user identity for credential lookups.
func WithCredentialUserID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, CredentialUserIDKey, id)
}
// CredentialUserIDFromContext returns the resolved identity for credential lookups.
// Falls back to RunContext.CredentialUserID, then UserIDFromContext.
func CredentialUserIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(CredentialUserIDKey).(string); ok && v != "" {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil && rc.CredentialUserID != "" {
return rc.CredentialUserID
}
return UserIDFromContext(ctx)
}
// WithAgentID returns a new context with the given agent UUID.
func WithAgentID(ctx context.Context, id uuid.UUID) context.Context {
return context.WithValue(ctx, AgentIDKey, id)
+23
View File
@@ -564,3 +564,26 @@ func (s *PGTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.
}
return res.RowsAffected()
}
// RecoverStaleRunningTraces marks traces stuck in "running" since before cutoff as "error".
// Also recovers their stuck spans. Called on startup to fix orphans from crashes.
func (s *PGTracingStore) RecoverStaleRunningTraces(ctx context.Context, cutoff time.Time) (int64, error) {
// Recover stuck spans first.
_, err := s.db.ExecContext(ctx,
`UPDATE spans SET status = 'error', error = 'recovered: server restart',
end_time = NOW(), duration_ms = EXTRACT(EPOCH FROM (NOW() - start_time))::int * 1000
WHERE status = 'running' AND start_time < $1`, cutoff)
if err != nil {
return 0, fmt.Errorf("recover stale spans: %w", err)
}
res, err := s.db.ExecContext(ctx,
`UPDATE traces SET status = 'error',
error = 'recovered: stuck in running state (server restart)',
end_time = NOW(), duration_ms = EXTRACT(EPOCH FROM (NOW() - start_time))::int * 1000
WHERE status = 'running' AND start_time < $1`, cutoff)
if err != nil {
return 0, fmt.Errorf("recover stale running traces: %w", err)
}
return res.RowsAffected()
}
+7 -6
View File
@@ -21,12 +21,13 @@ type runContextKey struct{}
// accessor functions (which fall back to individual keys when RunContext is absent).
type RunContext struct {
// Identity
AgentID uuid.UUID
AgentKey string
TenantID uuid.UUID
UserID string
AgentType string
SenderID string
AgentID uuid.UUID
AgentKey string
TenantID uuid.UUID
UserID string
CredentialUserID string // resolved tenant user for credential lookups (empty = use UserID)
AgentType string
SenderID string
// Flags
SelfEvolve bool
+23
View File
@@ -251,6 +251,29 @@ func (s *SQLiteTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff t
return res.RowsAffected()
}
// RecoverStaleRunningTraces marks traces stuck in "running" since before cutoff as "error".
// Also recovers their stuck spans. Called on startup to fix orphans from crashes.
func (s *SQLiteTracingStore) RecoverStaleRunningTraces(ctx context.Context, cutoff time.Time) (int64, error) {
// Recover stuck spans first.
_, err := s.db.ExecContext(ctx,
`UPDATE spans SET status = 'error', error = 'recovered: server restart',
end_time = datetime('now'), duration_ms = CAST((julianday('now') - julianday(start_time)) * 86400000 AS INTEGER)
WHERE status = 'running' AND start_time < ?`, cutoff)
if err != nil {
return 0, fmt.Errorf("recover stale spans: %w", err)
}
res, err := s.db.ExecContext(ctx,
`UPDATE traces SET status = 'error',
error = 'recovered: stuck in running state (server restart)',
end_time = datetime('now'), duration_ms = CAST((julianday('now') - julianday(start_time)) * 86400000 AS INTEGER)
WHERE status = 'running' AND start_time < ?`, cutoff)
if err != nil {
return 0, fmt.Errorf("recover stale running traces: %w", err)
}
return res.RowsAffected()
}
// ListCodexPoolSpans is not supported in SQLite (Codex pool is a standard-edition feature).
func (s *SQLiteTracingStore) ListCodexPoolSpans(_ context.Context, _, _ uuid.UUID, _ []string, _ int) ([]store.CodexPoolSpan, error) {
return nil, nil
+3
View File
@@ -159,6 +159,9 @@ type TracingStore interface {
// Maintenance
DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
// RecoverStaleRunningTraces marks traces stuck in "running" since before cutoff as "error".
// Returns count of recovered traces. Called on startup to fix orphans from crashes.
RecoverStaleRunningTraces(ctx context.Context, cutoff time.Time) (int64, error)
// ListCodexPoolSpans returns recent LLM call spans for agents using Codex OAuth pool providers.
ListCodexPoolSpans(ctx context.Context, agentID, tenantID uuid.UUID, poolProviders []string, limit int) ([]CodexPoolSpan, error)
+75 -7
View File
@@ -42,8 +42,71 @@ func parseCommandBinary(command string) (binary string, args []string, err error
return words[0], words[1:], nil
}
// detectUnquotedShellOperators scans a command string for shell metacharacters
// that appear OUTSIDE of single or double quotes. This prevents false positives
// when argument values contain characters like | (e.g. --jq '.[0] | .name').
// Returns the list of detected operators, or nil if the command is clean.
func detectUnquotedShellOperators(command string) []string {
unquoted := extractUnquotedSegments(command)
if unquoted == "" {
return nil
}
return detectShellOperators(unquoted)
}
// extractUnquotedSegments returns a string containing only the characters
// from command that are outside of single-quoted and double-quoted segments.
// Backslash escaping is handled both inside double quotes (\") and outside
// quotes (\' \" \\) to match go-shellwords parsing behavior — without this,
// \" outside quotes would incorrectly enter double-quote mode and hide
// subsequent shell operators from detection.
func extractUnquotedSegments(command string) string {
var buf strings.Builder
buf.Grow(len(command))
inSingle := false
inDouble := false
for i := 0; i < len(command); i++ {
ch := command[i]
switch {
case inSingle:
if ch == '\'' {
inSingle = false
}
case inDouble:
if ch == '\\' && i+1 < len(command) {
i++ // skip escaped character inside double quotes
} else if ch == '"' {
inDouble = false
}
default:
switch ch {
case '\\':
// Backslash outside quotes escapes the next character, preventing
// it from being treated as a quote delimiter. Both the backslash
// and the escaped character are emitted as unquoted content so
// that operator detection still sees them (e.g. \; remains visible).
buf.WriteByte(ch)
if i+1 < len(command) {
i++
buf.WriteByte(command[i])
}
case '\'':
inSingle = true
case '"':
inDouble = true
default:
buf.WriteByte(ch)
}
}
}
return buf.String()
}
// detectShellOperators scans a raw command string for shell metacharacters.
// Returns the list of detected operators, or nil if the command is clean.
// NOTE: This function does not respect quoting — use detectUnquotedShellOperators
// for credentialed exec where argument values may contain metacharacters.
func detectShellOperators(command string) []string {
matches := shellOperatorPattern.FindAllString(command, -1)
if len(matches) == 0 {
@@ -102,17 +165,20 @@ func matchesBinaryDeny(args []string, denyPatternsJSON json.RawMessage) string {
// executeCredentialed runs a CLI command in Direct Exec Mode (no shell).
// Credentials are injected as env vars into the child process only.
// rawCommand is the original command string before shell-word parsing (preserves quoting).
func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCLIBinary,
binary string, args []string, cwd string, sandboxKey string) *Result {
binary string, args []string, cwd string, sandboxKey string, rawCommand string) *Result {
// Step 0: Reject NUL bytes (defense-in-depth — also checked in Execute()).
rawCommand := binary + " " + strings.Join(args, " ")
if strings.ContainsRune(rawCommand, '\x00') {
return ErrorResult("command contains invalid NUL byte")
}
// Step 1: Check for shell operators (early detection for clear error)
if ops := detectShellOperators(rawCommand); len(ops) > 0 {
// Step 1: Check for shell operators in the ORIGINAL command (preserves quoting).
// We check the raw command string (before shell-word parsing) so that characters
// inside quoted argument values (e.g. | in --jq '.[0] | ...') are not falsely flagged.
// Only top-level (unquoted) shell operators indicate actual command chaining attempts.
if ops := detectUnquotedShellOperators(rawCommand); len(ops) > 0 {
return credentialedShellOperatorError(rawCommand, ops)
}
@@ -296,17 +362,19 @@ func (t *ExecTool) lookupCredentialedBinary(ctx context.Context, command string)
agentIDPtr = &agentID
}
// Pass userID for per-user credential resolution (LEFT JOIN, zero extra queries).
userID := store.UserIDFromContext(ctx)
// Uses CredentialUserIDFromContext to pick up merged tenant user identity
// (falls back to UserIDFromContext when not set).
userID := store.CredentialUserIDFromContext(ctx)
cred, err := t.secureCLIStore.LookupByBinary(ctx, binary, agentIDPtr, userID)
if err != nil {
slog.Warn("secure_cli.lookup: query failed", "binary", binary, "agent_id", agentID, "error", err)
return nil, "", nil
}
if cred == nil {
slog.Warn("secure_cli.lookup: no credential found", "binary", binary, "agent_id", agentID)
slog.Debug("secure_cli.lookup: no credential found", "binary", binary, "agent_id", agentID)
return nil, "", nil
}
slog.Info("secure_cli.lookup: found credential", "binary", binary, "cred_id", cred.ID, "env_size", len(cred.EncryptedEnv))
slog.Debug("secure_cli.lookup: found credential", "binary", binary, "cred_id", cred.ID, "env_size", len(cred.EncryptedEnv))
return cred, binary, args
}
+122
View File
@@ -0,0 +1,122 @@
package tools
import "testing"
func TestDetectShellOperators(t *testing.T) {
tests := []struct {
name string
command string
want int // number of detected operators
}{
{"clean command", "gh api repos/foo/bar", 0},
{"pipe operator", "gh api foo | jq .", 1},
{"semicolon", "echo a; echo b", 1},
{"ampersand", "cmd1 && cmd2", 1},
{"redirect", "cmd > /tmp/out", 1},
{"backtick", "echo `whoami`", 1},
{"subshell", "echo $(whoami)", 1},
{"multiple operators", "cmd1 | cmd2 && cmd3", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ops := detectShellOperators(tt.command)
if len(ops) != tt.want {
t.Errorf("detectShellOperators(%q) = %v (len %d), want len %d", tt.command, ops, len(ops), tt.want)
}
})
}
}
func TestExtractUnquotedSegments(t *testing.T) {
tests := []struct {
name string
command string
want string
}{
{"no quotes", "gh api foo", "gh api foo"},
{"single quoted pipe", "gh --jq '.[0] | .name'", "gh --jq "},
{"double quoted pipe", `gh --jq ".[0] | .name"`, "gh --jq "},
{"mixed quotes", `gh --jq '.[0] | .a' --format "b | c"`, "gh --jq --format "},
{"escaped quote in double", `gh "say \"hello\""`, "gh "},
{"empty single quotes", "gh ''", "gh "},
{"unquoted metachar", "gh api foo | jq", "gh api foo | jq"},
// Backslash escape outside quotes: \" should NOT start double-quoting
{"escaped dquote outside", `gh api \"foo | bar\"`, `gh api \"foo | bar\"`},
{"escaped squote outside", `gh api \'foo | bar\'`, `gh api \'foo | bar\'`},
{"double backslash", `gh api \\arg`, `gh api \\arg`},
{"backslash at end", `gh api foo\`, `gh api foo\`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractUnquotedSegments(tt.command)
if got != tt.want {
t.Errorf("extractUnquotedSegments(%q) = %q, want %q", tt.command, got, tt.want)
}
})
}
}
func TestDetectUnquotedShellOperators(t *testing.T) {
tests := []struct {
name string
command string
want int
}{
// Should NOT detect (inside quotes)
{"pipe in single quotes", "gh api repos/foo --jq '.[0] | .name'", 0},
{"pipe in double quotes", `gh api repos/foo --jq ".[0] | .name"`, 0},
{"semicolon in quotes", `echo 'a; b'`, 0},
{"backtick in single quotes", "echo 'hello `world`'", 0},
{"complex jq", `gh api repos/org/repo/commits --jq '.[0] | "SHA: \(.sha)\nAuthor: \(.commit.author.name)"'`, 0},
// Should detect (outside quotes)
{"unquoted pipe", "gh api foo | jq .", 1},
{"unquoted semicolon", "echo a; echo b", 1},
{"mixed: quoted safe + unquoted unsafe", "gh --jq '.[0] | .x' | cat", 1},
{"redirect after quotes", "gh api foo --jq '.x' > out.json", 1},
// Escaped quotes outside quotes: operators after \" must still be detected
// (backslash prevents " from starting a quoted section)
{"escaped dquote then pipe", `gh \"arg\" | env`, 1},
{"escaped dquote with content pipe", `gh api \"foo | bar\"`, 1},
{"escaped squote then pipe", `gh api \'foo | bar\'`, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ops := detectUnquotedShellOperators(tt.command)
if len(ops) != tt.want {
t.Errorf("detectUnquotedShellOperators(%q) = %v (len %d), want len %d", tt.command, ops, len(ops), tt.want)
}
})
}
}
func TestParseCommandBinary(t *testing.T) {
tests := []struct {
name string
command string
wantBinary string
wantArgs int
wantErr bool
}{
{"simple", "gh api foo", "gh", 2, false},
{"with quotes", "gh api --jq '.[0] | .name'", "gh", 3, false},
{"empty", "", "", 0, true},
{"single binary", "gh", "gh", 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
binary, args, err := parseCommandBinary(tt.command)
if (err != nil) != tt.wantErr {
t.Errorf("parseCommandBinary(%q) err = %v, wantErr %v", tt.command, err, tt.wantErr)
return
}
if !tt.wantErr {
if binary != tt.wantBinary {
t.Errorf("binary = %q, want %q", binary, tt.wantBinary)
}
if len(args) != tt.wantArgs {
t.Errorf("args len = %d, want %d (args: %v)", len(args), tt.wantArgs, args)
}
}
})
}
}
+3 -3
View File
@@ -142,10 +142,10 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *Result {
return ErrorResult("action parameter is required")
}
// Group write permission check for mutation actions
// Group cron permission check for mutation actions
if t.permStore != nil && (action == "add" || action == "update" || action == "remove") {
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
return ErrorResult("permission denied: only file writers can manage cron jobs in group chats")
if err := store.CheckCronPermission(ctx, t.permStore); err != nil {
return ErrorResult("permission denied: only users with cron or file_writer permission can manage cron jobs in group chats")
}
}
+24 -18
View File
@@ -31,30 +31,31 @@ func DefaultDenyPatterns() []*regexp.Regexp {
// ExecTool executes shell commands, optionally inside a sandbox container.
type ExecTool struct {
workspace string
workspace string
timeout time.Duration
pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths)
denyExemptions []string // substrings that exempt a command from deny
pathDenyPatterns []*regexp.Regexp // always-on path-based denials (DenyPaths)
pathDenyRoots []string // raw deny roots for nested workspace exemptions
denyExemptions []string // substrings that exempt a command from deny
restrict bool
sandboxMgr sandbox.Manager // nil = no sandbox, execute on host
approvalMgr *ExecApprovalManager // nil = no approval needed
agentID string // for approval request context
secureCLIStore store.SecureCLIStore // nil = no credentialed exec
secureCLIStore store.SecureCLIStore // nil = no credentialed exec
}
// NewExecTool creates an exec tool that runs commands directly on the host.
func NewExecTool(workspace string, restrict bool) *ExecTool {
return &ExecTool{
workspace: workspace,
timeout: 60 * time.Second,
restrict: restrict,
timeout: 60 * time.Second,
restrict: restrict,
}
}
// NewSandboxedExecTool creates an exec tool that routes commands through a sandbox container.
func NewSandboxedExecTool(workspace string, restrict bool, mgr sandbox.Manager) *ExecTool {
return &ExecTool{
workspace: workspace,
workspace: workspace,
timeout: 300 * time.Second, // sandbox allows longer timeout
restrict: restrict,
sandboxMgr: mgr,
@@ -70,6 +71,7 @@ func (t *ExecTool) DenyPaths(paths ...string) {
for _, p := range paths {
escaped := regexp.QuoteMeta(p)
t.pathDenyPatterns = append(t.pathDenyPatterns, regexp.MustCompile(escaped))
t.pathDenyRoots = append(t.pathDenyRoots, p)
}
}
@@ -155,8 +157,15 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
allPatterns := make([]*regexp.Regexp, 0, len(groupPatterns)+len(t.pathDenyPatterns))
allPatterns = append(allPatterns, groupPatterns...)
allPatterns = append(allPatterns, t.pathDenyPatterns...)
exemptions := append([]string{}, t.denyExemptions...)
exemptions = append(exemptions, t.dynamicPathExemptions(ctx)...)
// Check for dangerous commands (applies to both host and sandbox).
wordFields := parseExecCommandWords(normalizedCommand)
pathBaseDir := ToolWorkspaceFromCtx(ctx)
if pathBaseDir == "" {
pathBaseDir = t.workspace
}
for _, pattern := range allPatterns {
if pattern.MatchString(normalizedCommand) {
// Check if exemption applies. Only exempt if EVERY field that
@@ -167,23 +176,20 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
// path traversal ("..") to prevent exemption escape.
exempt := false
trimmed := strings.TrimSpace(normalizedCommand)
fields := strings.Fields(trimmed)
fields := wordFields
if len(fields) == 0 {
fields = strings.Fields(trimmed)
}
matchingFields := 0
exemptFields := 0
for _, field := range fields {
clean := strings.Trim(field, `"'`)
clean := strings.TrimSpace(field)
if !pattern.MatchString(clean) {
continue // field doesn't trigger this deny pattern
}
matchingFields++
if strings.Contains(clean, "..") {
continue // path traversal — never exempt
}
for _, ex := range t.denyExemptions {
if strings.HasPrefix(clean, ex) {
exemptFields++
break
}
if matchesAnyPathExemption(clean, exemptions, pathBaseDir) {
exemptFields++
}
}
// Exempt only if at least one field matched AND all matched fields are exempt.
@@ -235,7 +241,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
}
}
sandboxKey := ToolSandboxKeyFromCtx(ctx)
return t.executeCredentialed(ctx, cred, binary, cmdArgs, cwd, sandboxKey)
return t.executeCredentialed(ctx, cred, binary, cmdArgs, cwd, sandboxKey, command)
}
// Exec approval check (matching TS exec-approval.ts pipeline)
+223 -32
View File
@@ -2,7 +2,10 @@ package tools
import (
"context"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
)
@@ -19,10 +22,10 @@ func TestBase64DecodeShellDeny(t *testing.T) {
}
allowed := []string{
"base64 -w0 file.txt", // encode, no pipe to shell
"base64 -d file.txt", // decode without pipe to shell
"echo hello | base64", // encode
"base64 --decode file.txt", // decode without pipe to shell
"base64 -w0 file.txt", // encode, no pipe to shell
"base64 -d file.txt", // decode without pipe to shell
"echo hello | base64", // encode
"base64 --decode file.txt", // decode without pipe to shell
}
for _, cmd := range denied {
@@ -98,7 +101,7 @@ func TestDestructiveOpsGaps(t *testing.T) {
)
mustAllow(t, patterns,
"halting the process", // "halt" inside word
"halting the process", // "halt" inside word
"initialize", // "init" inside word
"initial setup", // "init" inside word
"init_db", // no space+digit after init
@@ -171,12 +174,18 @@ func TestExecute_RejectsNULByte(t *testing.T) {
}
func TestPathExemptions(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(t.TempDir(), "workspace")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := &ExecTool{
workspace: "/workspace",
workspace: workspace,
restrict: false,
}
tool.DenyPaths("/app/data", ".goclaw/")
tool.AllowPathExemptions(".goclaw/skills-store/", "/app/data/skills-store/")
tool.DenyPaths(dataDir, ".goclaw/")
tool.AllowPathExemptions(".goclaw/skills-store/", filepath.Join(dataDir, "skills-store")+"/")
cases := []struct {
name string
@@ -334,30 +343,24 @@ func TestPathExemptions(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
normalizedCmd := normalizeCommand(tc.cmd)
normalizedCmd := strings.ReplaceAll(normalizeCommand(tc.cmd), "/app/data", dataDir)
denied := false
for _, pattern := range allPatterns {
if !pattern.MatchString(normalizedCmd) {
continue
}
// Replicate per-field exemption logic from Execute()
fields := strings.Fields(strings.TrimSpace(normalizedCmd))
fields := parseExecCommandWords(strings.TrimSpace(normalizedCmd))
matchingFields := 0
exemptFields := 0
for _, field := range fields {
clean := strings.Trim(field, `"'`)
clean := strings.TrimSpace(field)
if !pattern.MatchString(clean) {
continue
}
matchingFields++
if strings.Contains(clean, "..") {
continue // traversal — never exempt
}
for _, ex := range tool.denyExemptions {
if strings.HasPrefix(clean, ex) {
exemptFields++
break
}
if matchesAnyPathExemption(clean, tool.denyExemptions, workspace) {
exemptFields++
}
}
exempt := matchingFields > 0 && exemptFields == matchingFields
@@ -381,11 +384,16 @@ func TestPathExemptions(t *testing.T) {
// path and an exempt path in different arguments is correctly denied.
// Per-field matching ensures the non-exempt field causes denial.
func TestPathExemptions_MixedArgs(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(t.TempDir(), "workspace")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := &ExecTool{}
tool.DenyPaths("/app/data")
tool.AllowPathExemptions("/app/data/skills-store/")
tool.DenyPaths(dataDir)
tool.AllowPathExemptions(filepath.Join(dataDir, "skills-store") + "/")
cmd := "cat /app/data/config.json /app/data/skills-store/tool.py"
cmd := "cat " + filepath.Join(dataDir, "config.json") + " " + filepath.Join(dataDir, "skills-store", "tool.py")
normalizedCmd := normalizeCommand(cmd)
denied := false
@@ -393,23 +401,17 @@ func TestPathExemptions_MixedArgs(t *testing.T) {
if !pattern.MatchString(normalizedCmd) {
continue
}
fields := strings.Fields(strings.TrimSpace(normalizedCmd))
fields := parseExecCommandWords(strings.TrimSpace(normalizedCmd))
matchingFields := 0
exemptFields := 0
for _, field := range fields {
clean := strings.Trim(field, `"'`)
clean := strings.TrimSpace(field)
if !pattern.MatchString(clean) {
continue
}
matchingFields++
if strings.Contains(clean, "..") {
continue
}
for _, ex := range tool.denyExemptions {
if strings.HasPrefix(clean, ex) {
exemptFields++
break
}
if matchesAnyPathExemption(clean, tool.denyExemptions, workspace) {
exemptFields++
}
}
if matchingFields == 0 || exemptFields != matchingFields {
@@ -422,6 +424,195 @@ func TestPathExemptions_MixedArgs(t *testing.T) {
}
}
func TestExecute_AllowsCurrentWorkspaceNestedUnderDeniedRoot(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "teams", "team-123")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir)
target := filepath.Join(workspace, ".uploads", "report.png")
ctx := WithToolWorkspace(context.Background(), workspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' " + target,
})
if strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected nested workspace path to bypass dataDir deny, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, target) {
t.Fatalf("expected command output to include workspace path, got: %s", result.ForLLM)
}
}
func TestExecute_AllowsCurrentTeamWorkspaceNestedUnderDeniedRoot(t *testing.T) {
dataDir := t.TempDir()
teamWorkspace := filepath.Join(dataDir, "tenants", "acme", "teams", "team-123")
if err := os.MkdirAll(teamWorkspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir)
target := filepath.Join(teamWorkspace, "report.png")
ctx := WithToolWorkspace(context.Background(), t.TempDir())
ctx = WithToolTeamWorkspace(ctx, teamWorkspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' " + target,
})
if strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected nested team workspace path to bypass dataDir deny, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, target) {
t.Fatalf("expected command output to include team workspace path, got: %s", result.ForLLM)
}
}
func TestExecute_DoesNotExemptOtherDataDirPaths(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "teams", "team-123")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir)
ctx := WithToolWorkspace(context.Background(), workspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' " + filepath.Join(dataDir, "config.json"),
})
if !strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected unrelated dataDir path to remain denied, got: %s", result.ForLLM)
}
}
func TestExecute_DoesNotExemptWorkspaceLocalDotGoclawPaths(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "teams", "team-123")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir, ".goclaw/")
ctx := WithToolWorkspace(context.Background(), workspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' " + filepath.Join(workspace, ".goclaw", "secrets.json"),
})
if !strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected workspace-local .goclaw path to remain denied, got: %s", result.ForLLM)
}
}
func TestExecute_AllowsQuotedAndPrefixedUploadArguments(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "teams", "team-123")
if err := os.MkdirAll(filepath.Join(workspace, ".uploads"), 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
target := filepath.Join(workspace, ".uploads", "Quarterly Report.png")
if err := os.WriteFile(target, []byte("ok"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir)
ctx := WithToolWorkspace(context.Background(), workspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' file=@\"" + target + "\"",
})
if strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected quoted/prefixed upload argument to bypass deny, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "file=@"+target) {
t.Fatalf("expected output to contain prefixed path, got: %s", result.ForLLM)
}
}
func TestExecute_DoesNotExemptSymlinkEscapeInsideTeamWorkspace(t *testing.T) {
if err := os.MkdirAll(t.TempDir(), 0755); err != nil {
t.Fatalf("TempDir setup error = %v", err)
}
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, "personal")
teamWorkspace := filepath.Join(dataDir, "teams", "team-123")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.MkdirAll(teamWorkspace, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
protected := filepath.Join(dataDir, "config.json")
if err := os.WriteFile(protected, []byte("secret"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
linkPath := filepath.Join(teamWorkspace, "leak.txt")
if err := os.Symlink(protected, linkPath); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir)
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithToolTeamWorkspace(ctx, teamWorkspace)
result := tool.Execute(ctx, map[string]any{
"command": "printf '%s' " + linkPath,
})
if !strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected symlink escape to remain denied, got: %s", result.ForLLM)
}
}
func TestExecute_AllowsLegacyWorkspaceUploadsLayout(t *testing.T) {
dataDir := t.TempDir()
workspace := filepath.Join(dataDir, ".goclaw", "goclaw-workspace", "ws", "system")
legacyUploads := filepath.Join(workspace, "uploads")
if err := os.MkdirAll(legacyUploads, 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
target := filepath.Join(legacyUploads, "Quarterly Report.png")
if err := os.WriteFile(target, []byte("ok"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
tool := NewExecTool("/workspace", false)
tool.DenyPaths(dataDir, ".goclaw/")
ctx := WithToolWorkspace(context.Background(), workspace)
result := tool.Execute(ctx, map[string]any{
"command": "cp \"" + target + "\" /tmp/partner.png",
})
if strings.Contains(result.ForLLM, "command denied by safety policy") {
t.Fatalf("expected legacy uploads layout to bypass deny, got: %s", result.ForLLM)
}
}
func TestPathAliasVariants_AppWorkspaceMirror(t *testing.T) {
got := pathAliasVariants("/app/workspace/glm-thuc-bo/ws/user/.uploads")
want := "/app/.goclaw/glm-thuc-bo/ws/user/.uploads"
if !slices.Contains(got, want) {
t.Fatalf("expected mirror variant %q in %v", want, got)
}
}
func TestIsNestedUnderDeniedRoot_RelativeDotGoclaw(t *testing.T) {
tool := &ExecTool{pathDenyRoots: []string{".goclaw/"}}
if !tool.isNestedUnderDeniedRoot("/app/.goclaw/glm-thuc-bo/ws/user/.uploads") {
t.Fatal("expected absolute .goclaw path to be treated as nested under relative deny root")
}
}
func TestLimitedBuffer(t *testing.T) {
t.Run("under limit", func(t *testing.T) {
lb := &limitedBuffer{max: 100}
+284
View File
@@ -0,0 +1,284 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
shellwords "github.com/mattn/go-shellwords"
)
// dynamicPathExemptions builds runtime exemptions for the active user's workspace
// upload directories and team workspace root. Only exempts paths that are nested
// under a denied root — paths outside deny roots don't need exemptions.
func (t *ExecTool) dynamicPathExemptions(ctx context.Context) []string {
var exemptions []string
seen := make(map[string]struct{}, 4)
workspace := ToolWorkspaceFromCtx(ctx)
teamWorkspace := ToolTeamWorkspaceFromCtx(ctx)
var dirs []string
if teamWorkspace != "" {
dirs = append(dirs, teamWorkspace)
}
if workspace != "" && filepath.Clean(workspace) != filepath.Clean(teamWorkspace) {
dirs = append(dirs, filepath.Join(workspace, ".uploads"))
dirs = append(dirs, filepath.Join(workspace, "uploads"))
}
for _, dir := range dirs {
if dir == "" || strings.Contains(dir, "..") {
continue
}
for _, variant := range pathAliasVariants(filepath.Clean(dir)) {
if !t.isNestedUnderDeniedRoot(variant) {
continue
}
for _, ex := range []string{variant, variant + string(filepath.Separator)} {
if _, ok := seen[ex]; ok {
continue
}
seen[ex] = struct{}{}
exemptions = append(exemptions, ex)
}
}
}
return exemptions
}
// pathAliasVariants returns the path plus any known runtime alias mappings.
// On the claw server, /app/workspace is symlinked to /app/.goclaw at runtime,
// so both forms may appear in LLM-generated commands for the same physical path.
func pathAliasVariants(path string) []string {
variants := []string{path}
for _, mapping := range [][2]string{
{"/app/workspace", "/app/.goclaw"},
{"/app/.goclaw", "/app/workspace"},
} {
from, to := mapping[0], mapping[1]
if path == from {
variants = append(variants, to)
continue
}
if strings.HasPrefix(path, from+string(filepath.Separator)) {
variants = append(variants, to+strings.TrimPrefix(path, from))
}
}
return variants
}
// isNestedUnderDeniedRoot checks whether path sits inside any of the configured
// deny roots. Supports both absolute roots (prefix match) and relative roots
// (e.g. ".goclaw/" — checked as a path component marker anywhere in path).
func (t *ExecTool) isNestedUnderDeniedRoot(path string) bool {
for _, root := range t.pathDenyRoots {
cleanRoot := filepath.Clean(root)
if cleanRoot == "." || cleanRoot == string(filepath.Separator) {
continue
}
if !filepath.IsAbs(cleanRoot) {
marker := string(filepath.Separator) + cleanRoot + string(filepath.Separator)
if strings.Contains(path, marker) {
return true
}
continue
}
if path == cleanRoot {
continue
}
if strings.HasPrefix(path, cleanRoot+string(filepath.Separator)) {
return true
}
}
return false
}
// matchesPathExemption checks if a resolved path falls under any exemption prefix.
func matchesPathExemption(path string, exemptions []string) bool {
sep := string(filepath.Separator)
for _, ex := range exemptions {
if ex == "" {
continue
}
if path == ex {
return true
}
if strings.HasSuffix(ex, sep) {
if strings.HasPrefix(path, ex) {
return true
}
continue
}
if strings.HasPrefix(path, ex+sep) {
return true
}
}
return false
}
// parseExecCommandWords splits a shell command into words using go-shellwords,
// handling quoted strings correctly. The command is first segmented by shell
// operators (;|&<>) to avoid cross-segment quoting confusion.
func parseExecCommandWords(command string) []string {
var words []string
for _, segment := range splitExecCommandSegments(command) {
parser := shellwords.NewParser()
parser.ParseBacktick = false
parser.ParseEnv = false
segmentWords, err := parser.Parse(segment)
if err != nil || len(segmentWords) == 0 {
words = append(words, strings.Fields(segment)...)
continue
}
words = append(words, segmentWords...)
}
if len(words) == 0 {
return strings.Fields(command)
}
return words
}
// splitExecCommandSegments splits a command string at shell operators (;|&<>)
// while respecting single and double quotes. Each segment can then be safely
// parsed by go-shellwords independently.
func splitExecCommandSegments(command string) []string {
var segments []string
start := 0
inSingle := false
inDouble := false
for i := 0; i < len(command); i++ {
ch := command[i]
switch {
case inSingle:
if ch == '\'' {
inSingle = false
}
case inDouble:
if ch == '\\' && i+1 < len(command) {
i++
} else if ch == '"' {
inDouble = false
}
default:
switch ch {
case '\\':
if i+1 < len(command) {
i++
}
case '\'':
inSingle = true
case '"':
inDouble = true
case ';', '|', '&', '<', '>', '\n', '\r':
if segment := strings.TrimSpace(command[start:i]); segment != "" {
segments = append(segments, segment)
}
start = i + 1
}
}
}
if tail := strings.TrimSpace(command[start:]); tail != "" {
segments = append(segments, tail)
}
return segments
}
// extractPathCandidates extracts potential file paths from a shell word,
// stripping prefixes like "file=@" or "--input=" to find the actual path.
func extractPathCandidates(word string) []string {
if word == "" {
return nil
}
queue := []string{word}
seen := make(map[string]struct{}, 4)
var out []string
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if current == "" {
continue
}
if _, ok := seen[current]; ok {
continue
}
seen[current] = struct{}{}
if looksLikePathCandidate(current) {
out = append(out, current)
}
for _, sep := range []string{"=", "@"} {
if idx := strings.Index(current, sep); idx >= 0 && idx+1 < len(current) {
queue = append(queue, current[idx+1:])
}
}
}
return out
}
// looksLikePathCandidate returns true if the string looks like a filesystem path.
func looksLikePathCandidate(s string) bool {
if s == "" {
return false
}
if filepath.IsAbs(s) {
return true
}
return strings.HasPrefix(s, "./") ||
strings.HasPrefix(s, "../") ||
strings.HasPrefix(s, ".uploads/") ||
strings.HasPrefix(s, ".goclaw/") ||
strings.HasPrefix(s, "teams/") ||
strings.HasPrefix(s, "tenants/") ||
strings.HasPrefix(s, "~/") ||
strings.Contains(s, string(filepath.Separator))
}
// canonicalizeExecPath resolves a path to its canonical absolute form,
// following symlinks where possible. Falls back to ancestor-based resolution
// for paths that don't fully exist yet.
func canonicalizeExecPath(path, baseDir string) (string, error) {
if strings.HasPrefix(path, "~/") {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
path = filepath.Join(homeDir, strings.TrimPrefix(path, "~/"))
}
if !filepath.IsAbs(path) {
path = filepath.Join(baseDir, path)
}
absPath, _ := filepath.Abs(filepath.Clean(path))
if real, err := filepath.EvalSymlinks(absPath); err == nil {
return real, nil
}
return resolveThroughExistingAncestors(absPath)
}
// matchesAnyPathExemption checks if any path candidate extracted from a shell
// word matches any exemption after canonicalization. Rejects path traversal.
func matchesAnyPathExemption(word string, exemptions []string, baseDir string) bool {
for _, candidate := range extractPathCandidates(word) {
if strings.Contains(candidate, "..") {
continue
}
realCandidate, err := canonicalizeExecPath(candidate, baseDir)
if err != nil {
continue
}
for _, exemption := range exemptions {
realExemption, err := canonicalizeExecPath(exemption, baseDir)
if err != nil {
continue
}
if matchesPathExemption(realCandidate, []string{realExemption}) {
return true
}
}
}
return false
}
+21
View File
@@ -101,6 +101,7 @@ func (c *Collector) SetExporter(exp SpanExporter) {
func (c *Collector) Start() {
c.wg.Add(1)
go c.flushLoop()
go c.recoverStaleTraces() // fix orphan traces from previous crash
slog.Info("tracing collector started")
}
@@ -224,6 +225,26 @@ func (c *Collector) flushLoop() {
}
}
// recoverStaleTraces marks "running" traces older than 30 min as "error".
// Called once on startup to fix orphans left by a previous crash or stuck goroutine.
func (c *Collector) recoverStaleTraces() {
const staleThreshold = 30 * time.Minute
cutoff := time.Now().UTC().Add(-staleThreshold)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
recovered, err := c.store.RecoverStaleRunningTraces(ctx, cutoff)
if err != nil {
slog.Warn("tracing: failed to recover stale running traces", "error", err)
return
}
if recovered > 0 {
slog.Info("tracing: recovered stale running traces on startup",
"count", recovered, "older_than", cutoff.Format(time.RFC3339))
}
}
// pruneOldTraces deletes traces and spans older than traceRetention.
func (c *Collector) pruneOldTraces() {
cutoff := time.Now().UTC().Add(-traceRetention)
+51 -10
View File
@@ -15,6 +15,7 @@ import (
type Manager struct {
mu sync.Mutex
browser *rod.Browser
launcher *launcher.Launcher // retained for PID-based cleanup on crash
refs *RefStore
pages map[string]*rod.Page // targetID → page
console map[string][]ConsoleMessage // targetID → console messages
@@ -107,12 +108,7 @@ func (m *Manager) Start(ctx context.Context) error {
}
// Connection dead — clean up and reconnect
m.logger.Info("browser connection lost, reconnecting")
m.closeTenantContextsLocked()
m.browser = nil
m.pages = make(map[string]*rod.Page)
m.console = make(map[string][]ConsoleMessage)
m.pageTenants = make(map[string]string)
m.refs = NewRefStore()
m.cleanupDeadBrowserLocked()
}
var controlURL string
@@ -126,23 +122,45 @@ func (m *Manager) Start(ctx context.Context) error {
controlURL = u
m.logger.Info("connecting to remote Chrome", "cdp", controlURL, "remote", m.remoteURL)
} else {
// Local Chrome — launch via rod launcher
// Local Chrome — launch via rod launcher with stability flags
launchCtx, launchCancel := context.WithTimeout(ctx, 30*time.Second)
defer launchCancel()
l := launcher.New().
Context(launchCtx).
Leakless(true).
Headless(m.headless).
Set("disable-gpu").
Set("no-first-run").
Set("no-default-browser-check")
Set("no-default-browser-check").
Set("disable-dev-shm-usage").
Set("disable-software-rasterizer").
Set("disable-extensions").
Set("disable-background-networking").
Set("disable-renderer-backgrounding").
Set("disable-background-timer-throttling").
Set("disable-backgrounding-occluded-windows")
u, err := l.Launch()
if err != nil {
return fmt.Errorf("launch Chrome: %w", err)
}
controlURL = u
m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless)
m.launcher = l
m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless, "pid", l.PID())
}
b := rod.New().ControlURL(controlURL)
connectCtx, connectCancel := context.WithTimeout(ctx, 15*time.Second)
defer connectCancel()
b := rod.New().Context(connectCtx).ControlURL(controlURL)
if err := b.Connect(); err != nil {
// If local launch succeeded but connect failed, kill the orphan process
if m.launcher != nil {
m.launcher.Kill()
m.launcher.Cleanup()
m.launcher = nil
}
return fmt.Errorf("connect to Chrome: %w", err)
}
@@ -182,6 +200,12 @@ func (m *Manager) Stop(ctx context.Context) error {
if m.remoteURL == "" {
// Local Chrome — close the browser process
err = m.browser.Close()
// Force-kill via launcher if retained
if m.launcher != nil {
m.launcher.Kill()
m.launcher.Cleanup()
m.launcher = nil
}
}
// Remote Chrome — just drop the connection; sidecar stays alive
@@ -203,6 +227,23 @@ func (m *Manager) closeTenantContextsLocked() {
m.tenantCtxs = make(map[string]*rod.Browser)
}
// cleanupDeadBrowserLocked resets all state and kills any orphan Chrome process.
// Must be called with mu held.
func (m *Manager) cleanupDeadBrowserLocked() {
m.closeTenantContextsLocked()
if m.launcher != nil {
m.launcher.Kill()
m.launcher.Cleanup()
m.launcher = nil
}
m.browser = nil
m.pages = make(map[string]*rod.Page)
m.console = make(map[string][]ConsoleMessage)
m.pageTenants = make(map[string]string)
m.pageLastUsed = make(map[string]time.Time)
m.refs = NewRefStore()
}
// MasterTenantID is the well-known master tenant UUID string.
// Pages opened without a tenant context or by the master tenant use the main browser directly.
const MasterTenantID = "0193a5b0-7000-7000-8000-000000000001"
+4
View File
@@ -99,6 +99,10 @@ const (
EventZaloPersonalQRCode = "zalo.personal.qr.code"
EventZaloPersonalQRDone = "zalo.personal.qr.done"
// WhatsApp QR login events (client-scoped, not broadcast).
EventWhatsAppQRCode = "whatsapp.qr.code"
EventWhatsAppQRDone = "whatsapp.qr.done"
// Tenant access revocation — forces affected user's UI to logout.
EventTenantAccessRevoked = "tenant.access.revoked"
)
+3
View File
@@ -183,4 +183,7 @@ const (
// Zalo Personal
MethodZaloPersonalQRStart = "zalo.personal.qr.start"
MethodZaloPersonalContacts = "zalo.personal.contacts"
// WhatsApp
MethodWhatsAppQRStart = "whatsapp.qr.start"
)
+7 -4
View File
@@ -161,7 +161,6 @@
"extraHint": "Only enabled and signed-in OpenAI Codex OAuth aliases can participate.",
"emptyExtras": "Sign in another OpenAI Codex OAuth alias to unlock pooling.",
"noReadyExtras": "Sign in and enable another OpenAI Codex OAuth alias to add it here.",
"clickToAdd": "Click to add",
"extraSelectableHint": "Only enabled, signed-in OpenAI Codex OAuth aliases can enter the active pool.",
"selectedAccountsLabel": "Accounts in Pool",
"emptySelected": "No extra pool members selected yet.",
@@ -607,10 +606,14 @@
"userIdPlaceholder": "Enter user ID (e.g. telegram:386246614)",
"warning": "When sharing is enabled, all shared users can read and write files in the same workspace directory. Memory sharing is controlled separately. Context files remain isolated per user.",
"memoryGroupLabel": "Memory & Knowledge Graph",
"memoryGroupDescription": "Control memory and knowledge graph isolation between users",
"folderGroupLabel": "Workspace Folders",
"shareMemory": "Shared Memory & KG",
"shareMemoryTip": "All users access the same memory and knowledge graph. Independent of workspace folder sharing. When off, each user has their own isolated memory space.",
"shareMemoryNote": "Toggling this does not migrate data — existing per-user memories become inaccessible in shared mode."
"shareMemory": "Shared Memory",
"shareMemoryTip": "All users access the same memory store. When off, each user has their own isolated memory space. KG sharing is controlled separately below.",
"shareMemoryNote": "Toggling this does not migrate data — existing per-user memories become inaccessible in shared mode.",
"shareKG": "Shared Knowledge Graph",
"shareKGTip": "All users access the same knowledge graph. When off, each user has their own isolated KG with global canonical fallback for read operations.",
"shareKGNote": "Toggling this does not migrate data — existing per-user KG entities become inaccessible in shared mode."
},
"compaction": {
"title": "Compaction",
+20 -1
View File
@@ -223,7 +223,6 @@
"help": "For webhook mode"
},
"webhook_secret": { "label": "Webhook Secret" },
"bridge_url": { "label": "Bridge URL" },
"api_server": {
"label": "API Server URL",
"help": "Custom Telegram Bot API server for large file uploads (up to 2GB). Leave empty for default."
@@ -416,6 +415,10 @@
"zaloPersonal": {
"createLabel": "Create & Authenticate",
"formBanner": "After creating, you'll authenticate via QR code and configure allowed users."
},
"whatsapp": {
"createLabel": "Create & Scan QR",
"formBanner": "After creating, scan the QR code with WhatsApp to authenticate."
}
},
"fallback": {
@@ -464,5 +467,21 @@
"retry": "Retry",
"close": "Close",
"skip": "Skip"
},
"whatsapp": {
"loginSuccessLoading": "✅ WhatsApp connected! Loading channel...",
"waitingForQr": "Waiting for QR code...",
"scanHint": "Open WhatsApp → tap You → Linked Devices → Link a Device",
"initializing": "Initializing...",
"skip": "Skip",
"retry": "Retry",
"close": "Close",
"reauthTitle": "Link WhatsApp — {{name}}",
"reauthDescription": "Scan the QR code with WhatsApp to link your device.",
"alreadyLinked": "✅ Device already linked",
"alreadyLinkedDetail": "WhatsApp is connected. To link a different device, click Re-link — this will log out the current session.",
"relinkDevice": "Re-link Device",
"connectedSuccess": "✅ WhatsApp connected successfully!",
"tabQrCode": "QR Code"
}
}
+7 -4
View File
@@ -161,7 +161,6 @@
"extraHint": "Chỉ các bí danh OpenAI Codex OAuth đã bật và đã đăng nhập mới được tham gia.",
"emptyExtras": "Đăng nhập thêm một bí danh OpenAI Codex OAuth để bật pooling.",
"noReadyExtras": "Đăng nhập và bật thêm một bí danh OpenAI Codex OAuth để thêm vào đây.",
"clickToAdd": "Bấm để thêm",
"extraSelectableHint": "Chỉ các bí danh OpenAI Codex OAuth đã bật và đã đăng nhập mới được vào active pool.",
"selectedAccountsLabel": "Tài khoản trong pool",
"emptySelected": "Chưa chọn pool member bổ sung nào.",
@@ -607,10 +606,14 @@
"userIdPlaceholder": "Nhập user ID (vd: telegram:386246614)",
"warning": "Khi bật chia sẻ, tất cả người dùng được chia sẻ có thể đọc và ghi file trong cùng thư mục workspace. Chia sẻ bộ nhớ được cài đặt riêng. File ngữ cảnh vẫn được cô lập theo từng người dùng.",
"memoryGroupLabel": "Memory & Knowledge Graph",
"memoryGroupDescription": "Kiểm soát cô lập memory và knowledge graph giữa các người dùng",
"folderGroupLabel": "Thư mục Workspace",
"shareMemory": "Chia sẻ Memory & KG",
"shareMemoryTip": "Tất cả người dùng truy cập chung Memory và Knowledge Graph. Độc lập với việc chia sẻ thư mục workspace. Khi tắt, mỗi người dùng có vùng nhớ riêng.",
"shareMemoryNote": "Bật/tắt không di chuyển dữ liệu — memory per-user trước đó sẽ không truy cập được trong chế độ shared."
"shareMemory": "Chia sẻ Memory",
"shareMemoryTip": "Tất cả người dùng truy cập chung Memory. Khi tắt, mỗi người dùng có vùng nhớ riêng. Chia sẻ KG được cài đặt riêng bên dưới.",
"shareMemoryNote": "Bật/tắt không di chuyển dữ liệu — memory per-user trước đó sẽ không truy cập được trong chế độ shared.",
"shareKG": "Chia sẻ Knowledge Graph",
"shareKGTip": "Tất cả người dùng truy cập chung Knowledge Graph. Khi tắt, mỗi người dùng có KG riêng với fallback đọc từ global canonical.",
"shareKGNote": "Bật/tắt không di chuyển dữ liệu — KG per-user trước đó sẽ không truy cập được trong chế độ shared."
},
"compaction": {
"title": "Nén ngữ cảnh",
+20 -1
View File
@@ -213,7 +213,6 @@
"encrypt_key": { "label": "Khóa mã hóa", "help": "Cho chế độ webhook" },
"verification_token": { "label": "Token xác minh", "help": "Cho chế độ webhook" },
"webhook_secret": { "label": "Webhook Secret" },
"bridge_url": { "label": "URL Bridge" },
"api_server": { "label": "URL máy chủ API", "help": "Máy chủ API Bot Telegram tùy chỉnh cho tải file lớn (lên đến 2GB). Để trống cho mặc định." },
"proxy": { "label": "Proxy HTTP", "help": "Định tuyến lưu lượng bot qua proxy HTTP" },
"dm_policy": { "label": "Chính sách DM" },
@@ -331,6 +330,10 @@
"zaloPersonal": {
"createLabel": "Tạo & Xác thực",
"formBanner": "Sau khi tạo, bạn sẽ xác thực bằng mã QR và cấu hình người dùng được phép."
},
"whatsapp": {
"createLabel": "Tạo & Quét QR",
"formBanner": "Sau khi tạo, quét mã QR bằng WhatsApp để xác thực."
}
},
"fallback": {
@@ -379,5 +382,21 @@
"retry": "Thử lại",
"close": "Đóng",
"skip": "Bỏ qua"
},
"whatsapp": {
"loginSuccessLoading": "✅ Đã kết nối WhatsApp! Đang tải channel...",
"waitingForQr": "Đang chờ mã QR...",
"scanHint": "Mở WhatsApp → nhấn Bạn → Thiết bị đã liên kết → Liên kết thiết bị",
"initializing": "Đang khởi tạo...",
"skip": "Bỏ qua",
"retry": "Thử lại",
"close": "Đóng",
"reauthTitle": "Liên kết WhatsApp — {{name}}",
"reauthDescription": "Quét mã QR bằng WhatsApp để liên kết thiết bị.",
"alreadyLinked": "✅ Thiết bị đã được liên kết",
"alreadyLinkedDetail": "WhatsApp đã kết nối. Để liên kết thiết bị khác, nhấn Liên kết lại — thao tác này sẽ đăng xuất phiên hiện tại.",
"relinkDevice": "Liên kết lại",
"connectedSuccess": "✅ Kết nối WhatsApp thành công!",
"tabQrCode": "Mã QR"
}
}
+7 -4
View File
@@ -161,7 +161,6 @@
"extraHint": "只有已启用且已登录的 OpenAI Codex OAuth 别名才能参与。",
"emptyExtras": "再登录一个 OpenAI Codex OAuth 别名即可启用 pooling。",
"noReadyExtras": "请先登录并启用另一个 OpenAI Codex OAuth 别名,然后再添加到这里。",
"clickToAdd": "点击添加",
"extraSelectableHint": "只有已启用且已登录的 OpenAI Codex OAuth 别名才能进入 active pool。",
"selectedAccountsLabel": "池中的账户",
"emptySelected": "还没有选择额外的池成员。",
@@ -607,10 +606,14 @@
"userIdPlaceholder": "输入用户ID(例如 telegram:386246614)",
"warning": "启用共享后,所有共享用户可以读写同一工作区目录中的文件。记忆共享单独控制。上下文文件仍按用户隔离。",
"memoryGroupLabel": "记忆与知识图谱",
"memoryGroupDescription": "控制用户之间的记忆和知识图谱隔离",
"folderGroupLabel": "工作区文件夹",
"shareMemory": "共享记忆与知识图谱",
"shareMemoryTip": "所有用户访问相同的记忆和知识图谱。与工作区文件夹共享无关。关闭时,每个用户拥有独立的记忆空间。",
"shareMemoryNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立记忆将无法访问。"
"shareMemory": "共享记忆",
"shareMemoryTip": "所有用户访问相同的记忆存储。关闭时,每个用户拥有独立的记忆空间。知识图谱共享在下方单独控制。",
"shareMemoryNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立记忆将无法访问。",
"shareKG": "共享知识图谱",
"shareKGTip": "所有用户访问相同的知识图谱。关闭时,每个用户拥有独立的知识图谱,读取操作会回退到全局规范数据。",
"shareKGNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立知识图谱实体将无法访问。"
},
"compaction": {
"title": "压缩",
+20 -1
View File
@@ -213,7 +213,6 @@
"encrypt_key": { "label": "加密密钥", "help": "用于Webhook模式" },
"verification_token": { "label": "验证Token", "help": "用于Webhook模式" },
"webhook_secret": { "label": "Webhook Secret" },
"bridge_url": { "label": "Bridge URL" },
"api_server": { "label": "API 服务器 URL", "help": "自定义 Telegram Bot API 服务器用于大文件上传(最大 2GB)。留空使用默认服务器。" },
"proxy": { "label": "HTTP 代理", "help": "通过 HTTP 代理路由Bot流量" },
"dm_policy": { "label": "私聊策略" },
@@ -331,6 +330,10 @@
"zaloPersonal": {
"createLabel": "创建并认证",
"formBanner": "创建后,您将通过二维码进行认证并配置允许的用户。"
},
"whatsapp": {
"createLabel": "创建并扫码",
"formBanner": "创建后,请用 WhatsApp 扫描二维码完成认证。"
}
},
"fallback": {
@@ -379,5 +382,21 @@
"retry": "重试",
"close": "关闭",
"skip": "跳过"
},
"whatsapp": {
"loginSuccessLoading": "✅ WhatsApp 已连接!正在加载 channel...",
"waitingForQr": "正在等待二维码...",
"scanHint": "打开 WhatsApp → 点击【您】→【已连接的设备】→【连接设备】",
"initializing": "初始化中...",
"skip": "跳过",
"retry": "重试",
"close": "关闭",
"reauthTitle": "连接 WhatsApp — {{name}}",
"reauthDescription": "使用 WhatsApp 扫描二维码以连接设备。",
"alreadyLinked": "✅ 设备已连接",
"alreadyLinkedDetail": "WhatsApp 已连接。如需连接其他设备,请点击【重新连接】——这将退出当前会话。",
"relinkDevice": "重新连接",
"connectedSuccess": "✅ WhatsApp 连接成功!",
"tabQrCode": "二维码"
}
}
+6 -2
View File
@@ -25,7 +25,11 @@ export function formatUserLabel(userId: string, resolve?: Resolver): string {
}
}
// Fallback: prefix numeric IDs with #, string IDs with @
if (/^\d+$/.test(userId)) return `#${userId}`;
// Fallback: prefix numeric IDs with #
if (/^-?\d+$/.test(userId)) return `#${userId}`;
// Truncate long opaque IDs (e.g. oc_295eb80d325c976cbeb4a779e2010518)
if (userId.length > 20) return `${userId.slice(0, 10)}…${userId.slice(-4)}`;
return userId;
}
@@ -52,23 +52,40 @@ export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSec
</div>
<div>
<h3 className="text-sm font-semibold">{t(`${s}.memoryGroupLabel`)}</h3>
<p className="text-xs text-muted-foreground">{t(`${s}.shareMemoryTip`)}</p>
<p className="text-xs text-muted-foreground">{t(`${s}.memoryGroupDescription`)}</p>
</div>
</div>
<div className={`rounded-lg border p-3 sm:p-4 ${value.share_memory ? "border-orange-400/60 bg-orange-50/30 dark:border-orange-500/30 dark:bg-orange-950/10" : ""}`}>
<div className="flex items-center justify-between">
<InfoLabel tip={t(`${s}.shareMemoryTip`)}>{t(`${s}.shareMemory`)}</InfoLabel>
<Switch
checked={value.share_memory ?? false}
onCheckedChange={(v) => onChange({ ...value, share_memory: v })}
/>
<div className="space-y-2">
<div className={`rounded-lg border p-3 sm:p-4 ${value.share_memory ? "border-orange-400/60 bg-orange-50/30 dark:border-orange-500/30 dark:bg-orange-950/10" : ""}`}>
<div className="flex items-center justify-between">
<InfoLabel tip={t(`${s}.shareMemoryTip`)}>{t(`${s}.shareMemory`)}</InfoLabel>
<Switch
checked={value.share_memory ?? false}
onCheckedChange={(v) => onChange({ ...value, share_memory: v })}
/>
</div>
{value.share_memory && (
<p className="mt-2 text-xs text-orange-600 dark:text-orange-400">
{t(`${s}.shareMemoryNote`)}
</p>
)}
</div>
<div className={`rounded-lg border p-3 sm:p-4 ${value.share_knowledge_graph ? "border-orange-400/60 bg-orange-50/30 dark:border-orange-500/30 dark:bg-orange-950/10" : ""}`}>
<div className="flex items-center justify-between">
<InfoLabel tip={t(`${s}.shareKGTip`)}>{t(`${s}.shareKG`)}</InfoLabel>
<Switch
checked={value.share_knowledge_graph ?? false}
onCheckedChange={(v) => onChange({ ...value, share_knowledge_graph: v })}
/>
</div>
{value.share_knowledge_graph && (
<p className="mt-2 text-xs text-orange-600 dark:text-orange-400">
{t(`${s}.shareKGNote`)}
</p>
)}
</div>
{value.share_memory && (
<p className="mt-2 text-xs text-orange-600 dark:text-orange-400">
{t(`${s}.shareMemoryNote`)}
</p>
)}
</div>
</section>
+7 -3
View File
@@ -68,9 +68,7 @@ export const credentialsSchema: Record<string, FieldDef[]> = {
{ key: "webhook_secret", label: "Webhook Secret", type: "password" },
],
zalo_personal: [],
whatsapp: [
{ key: "bridge_url", label: "Bridge URL", type: "text", required: true, placeholder: "http://bridge:3000" },
],
whatsapp: [],
};
// --- Config schemas ---
@@ -151,6 +149,7 @@ export const configSchema: Record<string, FieldDef[]> = {
whatsapp: [
{ key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" },
{ key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "pairing" },
{ key: "require_mention", label: "Require @Mention in Groups", type: "boolean", help: "Only respond in group chats when the bot is explicitly @mentioned" },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "WhatsApp user IDs" },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" },
],
@@ -223,4 +222,9 @@ export const wizardConfig: Partial<Record<string, WizardConfig>> = {
formBanner: "wizard.zaloPersonal.formBanner",
excludeConfigFields: ["allow_from"],
},
whatsapp: {
steps: ["auth"],
createLabel: "wizard.whatsapp.createLabel",
formBanner: "wizard.whatsapp.formBanner",
},
};
@@ -48,11 +48,14 @@ export interface ReauthDialogProps {
import { ZaloAuthStep, ZaloConfigStep, ZaloEditConfig } from "./zalo/zalo-wizard-steps";
import { ZaloPersonalQRDialog } from "./zalo/zalo-personal-qr-dialog";
import { WhatsAppAuthStep } from "./whatsapp/whatsapp-wizard-steps";
import { WhatsAppReauthDialog } from "./whatsapp/whatsapp-reauth-dialog";
// --- Component registries ---
export const wizardAuthSteps: Record<string, ComponentType<WizardAuthStepProps>> = {
zalo_personal: ZaloAuthStep,
whatsapp: WhatsAppAuthStep,
};
export const wizardConfigSteps: Record<string, ComponentType<WizardConfigStepProps>> = {
@@ -66,6 +69,7 @@ export const wizardEditConfigs: Record<string, ComponentType<WizardEditConfigPro
/** Re-auth dialogs for re-authentication from the channels table */
export const reauthDialogs: Record<string, ComponentType<ReauthDialogProps>> = {
zalo_personal: ZaloPersonalQRDialog,
whatsapp: WhatsAppReauthDialog,
};
/** Set of channel types that support re-authentication from the table */
@@ -0,0 +1,69 @@
import { useState, useCallback } from "react";
import { useWsCall } from "@/hooks/use-ws-call";
import { useWsEvent } from "@/hooks/use-ws-event";
export type QrStatus = "idle" | "waiting" | "done" | "connected" | "error";
export function useWhatsAppQrLogin(instanceId: string | null) {
const [qrPng, setQrPng] = useState<string | null>(null);
const [status, setStatus] = useState<QrStatus>("idle");
const [errorMsg, setErrorMsg] = useState("");
const { call: startQR, loading } = useWsCall("whatsapp.qr.start");
const start = useCallback(async (forceReauth = false) => {
if (!instanceId) return;
setStatus("waiting");
setQrPng(null);
setErrorMsg("");
try {
await startQR({ instance_id: instanceId, force_reauth: forceReauth });
} catch (err) {
setStatus("error");
setErrorMsg(err instanceof Error ? err.message : "Failed to start QR session");
}
}, [startQR, instanceId]);
/** Logout current WhatsApp session and start a fresh QR scan flow. */
const triggerReauth = useCallback(() => start(true), [start]);
const reset = useCallback(() => {
setStatus("idle");
setQrPng(null);
setErrorMsg("");
}, []);
useWsEvent(
"whatsapp.qr.code",
useCallback(
(payload: unknown) => {
const p = payload as { instance_id: string; png_b64: string };
if (p.instance_id !== instanceId) return;
setQrPng(p.png_b64);
setStatus("waiting");
},
[instanceId],
),
);
useWsEvent(
"whatsapp.qr.done",
useCallback(
(payload: unknown) => {
const p = payload as { instance_id: string; success: boolean; already_connected?: boolean; error?: string };
if (p.instance_id !== instanceId) return;
if (p.success) {
// Distinguish: already connected before any QR vs. freshly authenticated via QR scan.
setStatus(p.already_connected ? "connected" : "done");
} else {
setStatus("error");
setErrorMsg(p.error ?? "QR authentication failed");
}
},
[instanceId],
),
);
return {
qrPng, status, errorMsg, loading, start, reset, retry: start, triggerReauth,
};
}
@@ -0,0 +1,118 @@
// Re-authentication dialog for WhatsApp — triggered from the channels table.
// QR code scan only.
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useWhatsAppQrLogin } from "./use-whatsapp-qr-login";
import type { ReauthDialogProps } from "../channel-wizard-registry";
export function WhatsAppReauthDialog({
open,
onOpenChange,
instanceId,
instanceName,
onSuccess,
}: ReauthDialogProps) {
const { t } = useTranslation("channels");
const {
qrPng, status, errorMsg, loading, start, reset, retry, triggerReauth,
} = useWhatsAppQrLogin(instanceId);
// Auto-start QR when dialog opens; intentionally omits `start` from deps
// because we only want to trigger on open/close transitions, not on identity changes.
useEffect(() => {
if (open && status === "idle") start();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset state when dialog closes
useEffect(() => {
if (!open) reset();
}, [open, reset]);
// Auto-close after a fresh QR scan completes (not "already connected")
useEffect(() => {
if (status !== "done") return;
onSuccess();
const id = setTimeout(() => onOpenChange(false), 1500);
return () => clearTimeout(id);
}, [status, onSuccess, onOpenChange]);
return (
<Dialog open={open} onOpenChange={(v) => { if (!loading) onOpenChange(v); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{t("whatsapp.reauthTitle", { name: instanceName })}</DialogTitle>
<DialogDescription>
{t("whatsapp.scanHint")}
</DialogDescription>
</DialogHeader>
{/* Already connected state */}
{status === "connected" && (
<div className="flex flex-col items-center gap-4 py-2">
<div className="text-center space-y-2">
<p className="text-sm text-green-600 font-medium">{t("whatsapp.alreadyLinked")}</p>
<p className="text-xs text-muted-foreground">
{t("whatsapp.alreadyLinkedDetail")}
</p>
</div>
<div className="flex justify-end gap-2 w-full">
<Button variant="outline" onClick={() => onOpenChange(false)}>{t("whatsapp.close")}</Button>
<Button variant="destructive" onClick={triggerReauth} disabled={loading}>{t("whatsapp.relinkDevice")}</Button>
</div>
</div>
)}
{/* Done state */}
{status === "done" && (
<div className="flex flex-col items-center gap-4 py-4">
<p className="text-sm text-green-600 font-medium">{t("whatsapp.connectedSuccess")}</p>
</div>
)}
{/* QR scan flow */}
{status !== "connected" && status !== "done" && (
<>
<div className="flex flex-col items-center gap-4 py-4 min-h-[200px]">
{status === "error" && (
<p className="text-sm text-destructive">{errorMsg}</p>
)}
{status === "waiting" && !qrPng && (
<p className="text-sm text-muted-foreground">{t("whatsapp.waitingForQr")}</p>
)}
{status === "waiting" && qrPng && (
<>
<img
src={`data:image/png;base64,${qrPng}`}
alt="WhatsApp QR Code"
className="w-52 h-52 border rounded"
/>
<p className="text-xs text-muted-foreground text-center">
{t("whatsapp.scanHint")}
</p>
</>
)}
{status === "idle" && (
<p className="text-sm text-muted-foreground">{t("whatsapp.initializing")}</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>{t("whatsapp.close")}</Button>
{status === "error" && (
<Button onClick={() => retry()} disabled={loading}>{t("whatsapp.retry")}</Button>
)}
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,72 @@
// WhatsApp wizard step components for the channel create wizard.
// QR auth is driven directly by whatsmeow's GetQRChannel(), delivered via WS events.
// Registered in channel-wizard-registry.tsx.
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { useWhatsAppQrLogin } from "./use-whatsapp-qr-login";
import type { WizardAuthStepProps } from "../channel-wizard-registry";
/** QR code authentication step for WhatsApp — displayed in create wizard after instance creation. */
export function WhatsAppAuthStep({ instanceId, onComplete, onSkip }: WizardAuthStepProps) {
const { t } = useTranslation("channels");
const { qrPng, status, errorMsg, loading, start, retry, reset } = useWhatsAppQrLogin(instanceId);
// Auto-start QR on mount
useEffect(() => {
start();
return () => reset();
}, [start, reset]);
// Signal completion to parent when bridge confirms connection
useEffect(() => {
if (status === "done") onComplete();
}, [status, onComplete]);
return (
<>
<div className="flex flex-col items-center gap-4 py-4 min-h-0">
{status === "done" && (
<p className="text-sm text-green-600 font-medium">
{t("whatsapp.loginSuccessLoading")}
</p>
)}
{status === "error" && (
<p className="text-sm text-destructive">{errorMsg}</p>
)}
{status === "waiting" && !qrPng && (
<p className="text-sm text-muted-foreground">
{t("whatsapp.waitingForQr")}
</p>
)}
{status === "waiting" && qrPng && (
<>
<img
src={`data:image/png;base64,${qrPng}`}
alt="WhatsApp QR Code"
className="w-52 h-52 border rounded"
/>
<p className="text-xs text-muted-foreground text-center">
{t("whatsapp.scanHint")}
</p>
</>
)}
{status === "idle" && (
<p className="text-sm text-muted-foreground">{t("whatsapp.initializing")}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onSkip} disabled={loading}>
{t("whatsapp.skip")}
</Button>
{status === "error" && (
<Button onClick={() => retry()} disabled={loading}>
{t("whatsapp.retry")}
</Button>
)}
</DialogFooter>
</>
);
}
@@ -58,7 +58,14 @@ export function useChatMessages(sessionKey: string, agentId: string) {
if (sessionKey === prevKeyRef.current) return;
const wasEmpty = !prevKeyRef.current;
prevKeyRef.current = sessionKey;
if (wasEmpty) { skipNextHistoryRef.current = true; return; } // new-chat send flow, don't reset
if (wasEmpty) {
// Only skip history when a send is in flight (expectingRunRef). Selecting
// an existing conversation from the sidebar must still load messages.
if (expectingRunRef.current) {
skipNextHistoryRef.current = true;
}
return;
}
setStreamText(null); setThinkingText(null); setToolStream([]);
setIsRunning(false); setActivity(null); setBlockReplies([]); setTeamTasks([]);
+21 -3
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { Network, Trash2, Search, GitFork, Sparkles, RefreshCw, LayoutGrid, Share2, Merge } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -42,6 +42,24 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
const graphData = useKGGraph(agentId, userId);
const showSkeleton = useDeferredLoading(loading && entities.length === 0);
// Filter graph data by search query (client-side)
const filteredGraphData = useMemo(() => {
if (!appliedQuery) return graphData;
const q = appliedQuery.toLowerCase();
const matchedIds = new Set<string>();
const matched = graphData.entities.filter((e) => {
const hit = e.name.toLowerCase().includes(q)
|| e.entity_type.toLowerCase().includes(q)
|| (e.description ?? "").toLowerCase().includes(q);
if (hit) matchedIds.add(e.id);
return hit;
});
const relations = graphData.relations.filter(
(r) => matchedIds.has(r.source_entity_id) && matchedIds.has(r.target_entity_id),
);
return { entities: matched, relations };
}, [graphData, appliedQuery]);
const handleSearch = () => setAppliedQuery(searchQuery.trim());
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSearch();
@@ -130,8 +148,8 @@ export function KGEntitiesTab({ agentId, userId }: KGEntitiesTabProps) {
<div className="min-h-0 flex-1">
{viewMode === "graph" ? (
<KGGraphView
entities={graphData.entities}
relations={graphData.relations}
entities={filteredGraphData.entities}
relations={filteredGraphData.relations}
onEntityClick={setViewEntity}
/>
) : showSkeleton ? (
+26 -1
View File
@@ -1,6 +1,6 @@
import { useRef, useMemo, useCallback, useState, useEffect, useLayoutEffect } from "react";
import ForceGraph2D, { type ForceGraphMethods } from "react-force-graph-2d";
import { Maximize2 } from "lucide-react";
import { Maximize2, ZoomIn, ZoomOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { useUiStore } from "@/stores/use-ui-store";
@@ -30,6 +30,21 @@ export function KGGraphView({ entities: allEntities, relations: allRelations, on
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [nodeLimit, setNodeLimit] = useState(DEFAULT_NODE_LIMIT);
const [ready, setReady] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1);
const ZOOM_STEP = 1.5;
const handleZoomIn = useCallback(() => {
const fg = graphRef.current;
if (!fg) return;
fg.zoom(Math.min((fg.zoom() ?? 1) * ZOOM_STEP, 8), 300);
}, []);
const handleZoomOut = useCallback(() => {
const fg = graphRef.current;
if (!fg) return;
fg.zoom(Math.max((fg.zoom() ?? 1) / ZOOM_STEP, 0.1), 300);
}, []);
// Double-click detection refs
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -223,6 +238,7 @@ export function KGGraphView({ entities: allEntities, relations: allRelations, on
linkDirectionalParticleSpeed={0.004}
onNodeClick={handleNodeClick}
onBackgroundClick={handleBackgroundClick}
onZoom={(transform) => setZoomLevel(transform.k)}
onEngineStop={handleEngineStop}
backgroundColor="transparent"
d3AlphaDecay={0.04}
@@ -243,6 +259,15 @@ export function KGGraphView({ entities: allEntities, relations: allRelations, on
<span>· {t("kg.graphView.limitNote", { limit: nodeLimit, total: totalCount })}</span>
)}
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={handleZoomOut}>
<ZoomOut className="h-3 w-3" />
</Button>
<span className="w-9 text-center">{Math.round(zoomLevel * 100)}%</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={handleZoomIn}>
<ZoomIn className="h-3 w-3" />
</Button>
</div>
<select
value={nodeLimit}
onChange={(e) => setNodeLimit(Number(e.target.value))}
+50 -38
View File
@@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Activity, GitFork, RefreshCw, Square, Bot, User, Users, Clock, Network, Globe } from "lucide-react";
import { Activity, GitFork, RefreshCw, Square, Bot, User, Users, Clock, Network, Globe, CheckCircle2, XCircle, Loader2, CircleDot, CircleDashed } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -28,6 +28,12 @@ import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import { toast } from "@/stores/use-toast-store";
/** Strip media placeholder tags like <media:image> from preview text */
function cleanPreview(text: string): string {
if (!text) return text;
return text.replace(/<media:\w+>/g, "[media]");
}
/** Parse session_key to extract source type: Direct, Group, Cron, Team, WS */
function parseSourceType(sessionKey: string): { type: string; topic?: string } {
if (!sessionKey) return { type: "unknown" };
@@ -168,15 +174,14 @@ export function TracesPage() {
/>
) : (
<div className="rounded-md border overflow-x-auto">
<table className="w-full min-w-[700px] text-sm">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.duration")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.tokens")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.spans")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.time")}</th>
<th className="px-4 py-3 text-left font-medium max-w-[40%]">{t("columns.name")}</th>
<th className="px-3 py-3 text-center font-medium w-10"></th>
<th className="px-4 py-3 text-left font-medium whitespace-nowrap">{t("columns.tokens")}</th>
<th className="px-4 py-3 text-center font-medium whitespace-nowrap">{t("columns.spans")}</th>
<th className="px-4 py-3 text-right font-medium whitespace-nowrap">{t("columns.time")}</th>
</tr>
</thead>
<tbody>
@@ -192,14 +197,20 @@ export function TracesPage() {
className="cursor-pointer border-b last:border-0 hover:bg-muted/30"
onClick={() => setSelectedTraceId(trace.id)}
>
<td className="max-w-[360px] px-4 py-2.5">
<div className="flex items-center gap-1.5 text-sm font-medium">
<td className="px-4 py-2.5 max-w-[300px] lg:max-w-[400px]">
<div className="flex items-center gap-1.5 text-sm font-medium min-w-0">
{trace.parent_trace_id && (
<GitFork className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{agentName || trace.name || t("unnamed")}</span>
<span className="shrink-0 text-muted-foreground">·</span>
<span className="shrink-0 text-xs text-muted-foreground">{userLabel}</span>
{userLabel && (
<>
<span className="shrink-0 text-muted-foreground">·</span>
<span className="truncate text-xs text-muted-foreground max-w-[140px]">{userLabel}</span>
</>
)}
</div>
<div className="mt-0.5 flex items-center gap-1">
<Badge variant="outline" className="shrink-0 gap-0.5 text-[10px] px-1.5 py-0">
<SourceIcon className="h-2.5 w-2.5" />
{t(`source.${source.type}`)}
@@ -210,16 +221,16 @@ export function TracesPage() {
{trace.channel}
</Badge>
)}
{trace.input_preview && (
<span className="truncate text-xs text-muted-foreground">
{cleanPreview(trace.input_preview)}
</span>
)}
</div>
{trace.input_preview && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{trace.input_preview}
</p>
)}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1.5">
<StatusBadge status={trace.status} />
<td className="px-3 py-2.5 text-center">
<div className="flex items-center justify-center gap-1">
<StatusIcon status={trace.status} />
{(trace.status === "running") && (
<Button
variant="destructive"
@@ -233,10 +244,7 @@ export function TracesPage() {
)}
</div>
</td>
<td className="px-4 py-2.5 text-muted-foreground">
{formatDuration(trace.duration_ms || computeDurationMs(trace.start_time, trace.end_time))}
</td>
<td className="px-4 py-2.5 text-muted-foreground">
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
<div>{formatTokens(trace.total_input_tokens)} / {formatTokens(trace.total_output_tokens)}</div>
{(trace.metadata?.total_cache_read_tokens ?? 0) > 0 && (
<div className="text-xs text-green-400">
@@ -244,11 +252,12 @@ export function TracesPage() {
</div>
)}
</td>
<td className="px-4 py-2.5 text-muted-foreground">
<td className="px-4 py-2.5 text-center text-muted-foreground">
{trace.span_count}
</td>
<td className="px-4 py-2.5 text-muted-foreground">
{formatDate(trace.start_time, tz)}
<td className="px-4 py-2.5 text-right text-muted-foreground whitespace-nowrap">
<div>{formatDate(trace.start_time, tz)}</div>
<div className="text-xs">{formatDuration(trace.duration_ms || computeDurationMs(trace.start_time, trace.end_time))}</div>
</td>
</tr>
);
@@ -280,15 +289,18 @@ export function TracesPage() {
);
}
function StatusBadge({ status }: { status: string }) {
const variant =
status === "ok" || status === "success" || status === "completed"
? "success"
: status === "error" || status === "failed"
? "destructive"
: status === "running" || status === "pending"
? "info"
: "secondary";
return <Badge variant={variant} className="text-xs">{status || "unknown"}</Badge>;
function StatusIcon({ status }: { status: string }) {
if (status === "ok" || status === "success" || status === "completed") {
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
}
if (status === "error" || status === "failed") {
return <XCircle className="h-4 w-4 text-destructive" />;
}
if (status === "running") {
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />;
}
if (status === "pending") {
return <CircleDashed className="h-4 w-4 text-muted-foreground" />;
}
return <CircleDot className="h-4 w-4 text-muted-foreground" />;
}
+1
View File
@@ -74,6 +74,7 @@ export interface WorkspaceSharingConfig {
shared_group?: boolean;
shared_users?: string[];
share_memory?: boolean;
share_knowledge_graph?: boolean;
}
export type ChatGPTOAuthRoutingStrategy =