Matrix thread delivery encodes per-user DM targets as `room:@user:server`
— the `room:` wrapper says "channel" but the embedded `@` id marker
says "direct". The previous extractRequesterPeer gated the `@`/`!`/`#`
heuristic on `!inferredKind`, so the prefix-derived kind won and a
direct-kinded peer binding on the same user id was rejected by the
kind-safety check in resolveFirstBoundAccountId. Cross-agent spawns
whose target was bound as a direct Matrix peer could fall back to the
caller account and send from the wrong identity.
The fix removes the `!inferredKind` guard so id-embedded kind markers
always have the final say — they are a more reliable signal than the
delivery-target wrapper, because channel-side prefixes can wrap either
a room or a user id.
Regression test: sessions_spawn classifies Matrix `room:@user` targets
as direct, not channel — the lifecycle suite now configures a
conflicting `channel`-kinded binding on the same user id and asserts
the `direct`-kinded binding wins when the caller's `agentTo` is
`room:@user:example.org`.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
MS Teams inbound context sets OriginatingTo to `conversation:<id>` while
route bindings key on the bare conversationId. The previous hand-rolled
prefix list (`room:`, `channel:`, `chat:`, `group:`, `user:`, `dm:`)
missed `conversation:` (and any future channel-specific namespacing), so
Teams cross-agent subagent spawns fell back to channel-only/caller
account and posted from the wrong identity.
extractRequesterPeer now uses a generic `^[a-z][a-z0-9_-]*:` regex to
peel any lowercase-alpha token-colon prefix, looping until the raw peer
id surfaces. Real-world peer ids never start with a lowercase-alpha
token followed by `:` (Matrix uses `!`/`@`, IRC `#`, Slack/Discord/LINE
alphanumerics, numeric Telegram/WhatsApp, or email-style `user@server`),
so this is safe. Known prefixes are mapped to ChatType for peerKind
inference (`conversation:`/`room:`/`channel:`/`chat:`/`thread:`/`topic:`
→ channel, `group:`/`team:` → group, `user:`/`dm:`/`pm:` → direct).
Regression test: sessions_spawn strips `conversation:` prefix for
Teams-style targets — a binding keyed on the raw conversation id
resolves correctly when the caller's `agentTo` is `conversation:<id>`.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses three further review findings:
1. Parse non-Matrix peer kinds before wildcard account lookup (P1)
Channels like LINE emit `line:group:<id>` / `line:room:<id>` targets,
but the Matrix-style heuristic only recognized `@`, `!`, and `#` so
the kind was lost and wildcard bindings with a declared kind were
skipped. subagent-spawn now uses a single `extractRequesterPeer`
helper that tries the channel plugin first, then peels the channel
namespace, then loops stripping kind-prefixes (`room:`/`channel:` →
`channel`, `group:` → `group`, `user:`/`dm:` → `direct`, `chat:` →
`channel`) — capturing the kind from the prefix as authoritative —
before falling back to the id-only heuristic for Matrix/IRC shapes.
2. Allow wildcard bindings in peerless bound-account fallback (P2)
Peerless callers (cron delivery resolution passes only
`channelId`/`agentId`) were blocked by the strict wildcard kind
safety, silently regressing configs that only declare wildcard peer
bindings. Peerless callers now accept wildcard bindings as a last-
resort fallback — the kind-safety rule only applies when the caller
supplies a peer to verify against.
3. Treat `group` and `channel` peer kinds as equivalent (P1)
Routing elsewhere (`peerKindMatches` in `src/routing/resolve-route.ts`)
intentionally accepts group/channel as compatible so a binding
declared as `peer.kind: "group"` resolves for callers inferred as
`channel` (Matrix rooms, Mattermost/Slack channels) and vice versa.
`resolveFirstBoundAccountId` now applies the same semantics in both
the wildcard and exact-id kind-cross-check branches.
Regression coverage in src/routing/bound-account-read.test.ts:
- Treats group and channel peer kinds as equivalent (both directions).
- Accepts a wildcard peer binding as fallback for peerless callers.
- Skips wildcard peer bindings when the caller's peerKind is unknown.
Regression coverage in openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts:
- sessions_spawn peels channel prefix then kind prefix for
`<channel>:<kind>:<id>` targets (LINE `line:group:<id>` shape) and
correctly picks the `group`-kinded binding over a `direct`-kinded
wildcard binding for the same agent.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses two new Codex P1s on the bound-account lookup path:
1. Strip channel prefix before generic target-kind prefixes
(src/agents/subagent-spawn.ts)
normalizeRequesterPeerId stripped a single generic prefix before the
`<channel>:` namespace, so LINE delivery targets of the form
`line:group:<id>` ended up as `group:<id>` instead of `<id>` and the
exact peer-id binding was missed. The helper now peels the channel
namespace first, then loops over generic prefixes (room:, channel:,
chat:, user:, dm:, group:) until the raw peer id surfaces.
2. Enforce peer-kind safety for wildcard bindings when caller kind is
unknown (src/routing/bound-account-read.ts)
A `peer.id: "*"` binding with an incompatible kind (for example
direct/*) could still be accepted when the caller did not supply a
peerKind — channels whose plugin does not implement inferTargetChatType
(such as Matrix) could then have room-originated spawns resolve to
the wrong DM-bound account, which is worse than preserving the caller
account. Wildcard matches now require both sides to declare a
peer.kind and for the kinds to agree; otherwise the binding is
skipped. Exact peer-id matches still require id equality, with a
kind cross-check only when both sides declare a kind (peer ids are
channel-unique).
To preserve the existing Matrix-room happy path (the Matrix plugin
does not expose inferTargetChatType), inferPeerKindForChannel gains a
conservative fallback that recognizes `!`-prefixed room ids as
"channel" and `@`-prefixed user ids as "direct", matching the
existing `normalizeId` convention in extensions/matrix.
Regression coverage:
- src/routing/bound-account-read.test.ts:
- Skips wildcard peer bindings when caller's peerKind is unknown.
- Matches exact peer id even when caller's peerKind is unknown.
- Updated prefers-wildcard-over-channel-only test to pass an explicit
peerKind reflecting the new strict requirement.
- src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts:
- sessions_spawn peels channel prefix then kind prefix for
`<channel>:<kind>:<id>` targets — LINE-style `line:group:<id>`
resolves to the binding configured on the raw peer id.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Delivery targets on Matrix (and other channels that namespace the `to`
field) arrive in `kind:<id>` form — for example `room:!abc:example.org`
— while route bindings store the raw peer id on `match.peer.id`
(`!abc:example.org`). Passing `ctx.agentTo` directly to
`resolveFirstBoundAccountId` caused exact peer matches to silently fail
and the lookup to fall through to channel-only or caller-account
fallback, so cross-agent spawns could still post as the wrong identity
when only a peer-specific binding was configured.
- src/agents/subagent-spawn.ts: strip known delivery-target prefixes
(`room:`, `channel:`, `chat:`, `user:`, `dm:`, `group:`, and the
channel-namespaced `${channelId}:`) from `requesterTo` before handing
it to `resolveFirstBoundAccountId`. The inferred `peerKind` still uses
the original `requesterTo` so channel plugins can apply their own
inference on the wire format.
Regression test in lifecycle suite:
- sessions_spawn strips channel-side prefixes from agentTo before the
bound-account lookup — a binding on the raw room id resolves correctly
even when the caller's `agentTo` is `room:<id>`.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
An agent with bindings that differed only by peer kind (for example
direct/* and channel/*, or the same peer id across kinds) could pick the
wrong sender account in resolveFirstBoundAccountId because the lookup
compared peer.id only and dropped peer.kind. Combined with the peerId
now always being forwarded from subagent spawns, an unrelated binding
could win purely by config order and route child messages from the
wrong identity.
- src/routing/bound-account-read.ts: preserve peer.kind in the
normalized match and accept an optional peerKind on
resolveFirstBoundAccountId. When both caller and binding declare a
kind, they must match or the binding is skipped. If either side omits
the kind, kind is not used as a filter (preserves prior behavior for
callers that do not know the kind, such as cron delivery resolution).
- src/agents/subagent-spawn.ts: derive peerKind for the lookup via the
active channel plugin's inferTargetChatType helper and pass it
through. Same-agent spawns still short-circuit the lookup entirely.
Regression coverage in src/routing/bound-account-read.test.ts:
- Filters bindings by peer kind when caller supplies peerKind —
direct/* and channel/* wildcards resolve to distinct accounts.
- Skips peer-specific bindings whose kind does not match the caller's
peerKind, falling through to the channel-only binding.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Preserve requester account for same-agent subagent spawns
(src/agents/subagent-spawn.ts)
resolveRequesterOriginForChild previously overwrote the caller's active
inbound accountId with a fresh binding lookup even when the target
agent equaled the requester. In multi-account channels where the same
agent has multiple bindings, that could switch the child to a
different account from the one the parent is actively using. Now the
lookup only runs when targetAgentId differs from requesterAgentId;
same-agent spawns keep the caller's accountId unchanged.
2. Skip wildcard peer matches when peerId is absent
(src/routing/bound-account-read.ts)
A wildcard peer binding (peer.id: "*") represents "match any peer", so
it is only meaningful when the caller supplies a peer. For peerless
callers (e.g. cron delivery resolution) a wildcard binding no longer
beats a channel-only binding. It is instead used as a last-resort
peer-ish fallback (see #3 below).
3. Preserve old first-match semantics for peerless callers with only
peer-specific bindings (src/routing/bound-account-read.ts)
The previous iteration skipped peer-specific bindings outright when no
peerId was supplied, which silently dropped the account for operators
whose only binding was peer-specific (for example cron jobs targeting
a specific room). Peerless callers now fall back to the first
peer-specific or wildcard binding they find (peerlessPeerSpecificFallback)
after channel-only bindings, restoring the prior cron semantics
without overriding explicit channel-only configuration.
Final three-tier precedence summary:
- peerId supplied:
exact peer match > wildcard peer > channel-only.
Non-matching peer-specific bindings are skipped.
- peerId absent:
channel-only > first peer-specific or wildcard binding found.
Regression coverage:
- src/routing/bound-account-read.test.ts (new): six unit tests covering
exact peer match, wildcard-wins-over-channel-only for peer-present
callers, channel-only-wins-over-wildcard for peerless callers,
peer-specific fallback for peerless callers with no channel-only
binding, skipping non-matching peer-specific bindings, and the
agent-on-different-channel no-match case.
- src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts:
new test asserting that a same-agent sessions_spawn (no explicit
agentId) preserves the caller's accountId rather than re-resolving via
bindings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A Matrix-bound parent session spawning a subagent for another agent could
seed the child's deliveryContext.accountId with the caller's account
before target bindings were consulted, causing child posts to come from
the wrong Matrix identity.
Changes:
- src/agents/subagent-spawn.ts: add resolveRequesterOriginForChild(...)
that prefers the target agent's bound account via
resolveFirstBoundAccountId({ cfg, channelId, agentId: targetAgentId,
peerId: requesterTo }) before falling back to the caller's accountId.
- src/routing/bound-account-read.ts: resolveFirstBoundAccountId now
accepts an optional peerId and uses three-tier precedence matching
resolve-route.ts semantics:
1. exact peer match wins immediately
2. wildcard peer match (peer.id: "*") wins over channel-only
3. channel-only binding is the final fallback
The existing cron delivery-target caller is unaffected; it passes no
peerId so it still gets channel-only matching.
Regression coverage in
openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts:
- Matrix room-bound route uses the target agent's bound account over the
caller's account.
- Peer-specific binding wins over channel-only binding for the same
agent.
- Non-matching peer falls back to the channel-only binding.
- Wildcard peer binding (peer.id: "*") matches any peer and beats
channel-only binding.
- Exact peer binding wins over a wildcard peer binding for the same
agent.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The macOS restart helper emitted by `openclaw update` (darwin branch of
`prepareRestartScript`) wrote the gateway restart script with every
`launchctl` stderr redirected to `/dev/null` and the final fallback
`kickstart` chained with `|| true`. When bootstrap/kickstart failed
(plist-on-disk race, schema rejection, stale job, bootout recovery
edge cases), the script exited 0, the updater declared success, and
the gateway silently stayed offline.
The reporter saw a ~25 minute production outage before noticing the
messages going unanswered across Telegram/Discord/Feishu.
Route stderr to `~/.openclaw/logs/update-restart.log` via `exec 2>>`,
drop `2>/dev/null` on every launchctl call, and remove the `|| true`
swallow on the fallback kickstart so a genuine failure exits non-zero
and leaves a durable audit trail. Log directory creation is best-effort
via `mkdir -p ... 2>/dev/null || true` since it normally already exists
from the gateway's own logging path. Self-cleanup of the script file
via `rm -f "$0"` is retained because the log, not the script, is the
useful artifact after the fact.
Adds a targeted regression test `captures macOS launchctl stderr to
~/.openclaw/logs/update-restart.log` alongside the existing darwin
restart-script test. The existing test's assertions about the
kickstart/enable/bootstrap fallback chain + self-cleanup all still pass.
Fixes#68486
The stale-gateway cleanup filter already refused to kill process.pid —
acknowledging the invariant that terminating a process whose death
cascades into the caller is never safe. That invariant was applied only
to the caller itself, not to its ancestors, which is why the
openclaw-weixin sidecar triggered an unbounded restart loop: the
sidecar's cleanup SIGTERM'd its parent gateway, the supervisor
restarted the gateway, the gateway re-spawned the sidecar, the cleanup
ran again.
Complete the invariant by excluding the full self+ancestor PID set in
both the lsof (Unix) and PowerShell/netstat (Windows) cleanup paths.
Walk uses process.ppid unconditionally (Node built-in, no spawn) and
/proc/<pid>/status on Linux for transitive ancestors, with graceful
degradation where /proc is unavailable.
The `lint:tmp:no-raw-channel-fetch` allowlist pins exact line numbers
(scripts/check-no-raw-channel-fetch.mjs:63-65). The previous commit
added `import { logVerbose } from "openclaw/plugin-sdk/runtime-env";`
on line 8 of `extensions/slack/src/monitor/media.ts`, shifting the
three allowlisted raw `fetch()` callsites from 96/115/120 → 97/116/121.
Updates the allowlist to match the new positions. No behavior change —
the same callsites remain allowlisted.