refactor(plugin-sdk)!: retire deprecated compatibility facades (#162333)

Retire the five SDK compatibility facades under the approved September 30 owner decision, and migrate in-repository callers onto their focused contracts. Keep implementations with their canonical owners and remove forwarding exports that become unused after the cutover.

BREAKING CHANGE: remove openclaw/plugin-sdk/channel-lifecycle, channel-message, channel-reply-pipeline, config-runtime, and infra-runtime. Use channel-outbound/channel-inbound, config-contracts and focused configuration/infra entrypoints. The new system-event-runtime entrypoint supplies public snapshot inspection and consumption. Update affected external plugins before upgrading the host; this retirement does not certify universal external migration.

Package exports, SDK entry inventories, compatibility tombstones, migration docs, and surface budgets move together. Canonical API comparison confirms exactly five removed entrypoints, 869 removed export paths, 35 focused additions, and no retained-export signature changes. Net production reduction: 1,320 lines.
This commit is contained in:
Peter Steinberger 2026-10-01 04:12:01 -07:00 • committed by GitHub
parent b5b1029990
commit e649be315d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
182 changed files with 1003 additions and 2495 deletions

View file

@ -2343,7 +2343,6 @@ src/infra/exec-approval-policy-snapshot.ts 2
src/infra/exec-approval-reply.ts 2
src/infra/exec-approvals-allowlist.ts 7
src/infra/exec-approvals-config.ts 4
src/infra/exec-approvals-socket.ts 1
src/infra/exec-approvals-store.ts 1
src/infra/exec-argv-analysis.ts 1
src/infra/exec-host.ts 3

View file

@ -37,15 +37,14 @@ Runtime and config helpers live under matching focused `*-runtime` subpaths
`text-utility-runtime`, `runtime-store`, `system-event-runtime`, `heartbeat-runtime`,
`channel-activity-runtime`, etc.). Prefer `config-contracts`,
`plugin-config-runtime`, `runtime-config-snapshot`, and `config-mutation`
instead of the broad `config-runtime` compatibility barrel.
instead of the removed broad `config-runtime` compatibility barrel.
<Info>
`openclaw/plugin-sdk/channel-lifecycle`, small channel helper facades,
`openclaw/plugin-sdk/config-runtime`, and `openclaw/plugin-sdk/infra-runtime`
are deprecated compatibility shims for older plugins. New code should import
narrower generic primitives instead. The compatibility registry records a
`removeAfter` date of 2026-10-01 for the `config-runtime`, `infra-runtime`,
and `channel-lifecycle` subpaths.
`openclaw/plugin-sdk/channel-lifecycle`, `channel-message`,
`channel-reply-pipeline`, `config-runtime`, and `infra-runtime` were removed
with explicit SDK-owner approval on September 30, 2026. Migrate to the
[focused public contracts](/plugins/sdk-migration/how-to-migrate) before
upgrading; replacement paths do not export every legacy helper or named type.
</Info>
Repo-internal entry points (per bundled plugin package root):

View file

@ -464,21 +464,19 @@ preview, and reply pipeline options.
### Migrating from channel-message
`openclaw/plugin-sdk/channel-message` is a deprecated compatibility entrypoint.
It retains its published outbound exports and three dispatch aliases. New outbound
helpers are exported only from `openclaw/plugin-sdk/channel-outbound`.
`openclaw/plugin-sdk/channel-message` has been removed. Import its former
outbound exports from `openclaw/plugin-sdk/channel-outbound`.
Migrate those aliases to `openclaw/plugin-sdk/channel-inbound`:
| Deprecated alias | Replacement |
| Removed alias | Replacement |
| ---------------------------------- | ----------------------------------- |
| `hasFinalChannelTurnDispatch` | `hasFinalInboundReplyDispatch` |
| `hasVisibleChannelTurnDispatch` | `hasVisibleInboundReplyDispatch` |
| `resolveChannelTurnDispatchCounts` | `resolveInboundReplyDispatchCounts` |
Follow the dated removal-eligibility window in [Migration](/plugins/sdk-migration).
This subpath is not tied to the next Plugin SDK major, and eligibility does not
itself remove an export. External imports do not emit a runtime warning; update
plugin imports rather than waiting for one.
The SDK owner approved early retirement on September 30, 2026. See the
[removal timeline](/plugins/sdk-migration/removal-timeline) and update plugin
imports before upgrading to a host containing this removal.
## Related

View file

@ -23,10 +23,11 @@ from a single entry point:
- **`openclaw/plugin-sdk/infra-runtime`** - a broad barrel mixing system
events, heartbeat state, delivery queues, fetch/proxy helpers, file helpers,
approval types, and unrelated utilities.
- **`openclaw/plugin-sdk/config-runtime`** - a broad config barrel retained
for compatibility, including deprecated direct `loadConfig` and
`writeConfigFile` exports. Those methods were removed from the injected
plugin runtime, not from this retained barrel.
- **`openclaw/plugin-sdk/config-runtime`** - a removed broad config barrel,
including its deprecated direct `loadConfig` and `writeConfigFile` exports.
- **`openclaw/plugin-sdk/channel-lifecycle`**, **`channel-message`**, and
**`channel-reply-pipeline`** - removed channel compatibility facades. Use
the focused outbound and inbound contracts, checking each named export.
- **`openclaw/extension-api`** - a removed bridge that gave plugins direct
access to host-side helpers like the embedded agent runner.
- **`api.registerEmbeddedExtensionFactory(...)`** - a removed embedded-runner-only
@ -34,12 +35,13 @@ from a single entry point:
tool-result middleware instead (see [Migrate embedded tool-result extensions
to middleware](/plugins/sdk-migration/how-to-migrate#how-to-migrate)).
The root SDK, compat barrel, extension bridge, and embedded extension factory
have been removed. `infra-runtime` and `config-runtime` remain only for their
separately recorded later windows. New plugins should use focused subpaths.
The root SDK, compat barrel, extension bridge, embedded extension factory, and
five channel/config/infrastructure compatibility facades have been removed.
The latter retirement received explicit SDK-owner approval on September 30,
2026; see the [removal timeline](/plugins/sdk-migration/removal-timeline).
<Warning>
Plugins importing the removed root, compat, or extension surfaces no longer
Plugins importing the removed SDK or extension surfaces no longer
load. Follow the [import path mappings](/plugins/sdk-migration/import-paths) before upgrading.
</Warning>
@ -124,7 +126,7 @@ The anchors from the single-page version still resolve here.
[Import path reference](/plugins/sdk-migration/import-paths) — which typed-public subpath replaces each legacy import.
- <a id="import-path-reference"></a>[Import path reference](/plugins/sdk-migration/import-paths#import-path-reference)
- <a id="retained-channel-facade-mappings"></a>[Retained channel facade mappings](/plugins/sdk-migration/import-paths#retained-channel-facade-mappings)
- <a id="retained-channel-facade-mappings"></a>[Removed channel facade mappings](/plugins/sdk-migration/import-paths#retained-channel-facade-mappings)
### Removed surfaces and replacements

View file

@ -112,8 +112,8 @@ would expose a historical failure as new pending work.
must receive config from their boundary, and long-lived runtime modules
allow zero ambient `loadConfig()` calls.
New plugin code should avoid the broad `openclaw/plugin-sdk/config-runtime`
barrel. Use the narrow subpath for the job:
The broad `openclaw/plugin-sdk/config-runtime` barrel has been removed.
Use the narrow subpath for the job:
| Need | Import |
| --- | --- |
@ -135,19 +135,15 @@ would expose a historical failure as new pending work.
resolver preserves channel/account precedence and channel defaults;
`markdown-table-runtime` is a private, JavaScript-only host export.
Check named types separately. `config-contracts` does not export `TtsMode`,
`TtsPersonaConfig`, `TtsPersonaFallbackPolicy`, or `SessionResetMode`;
`session-store-runtime` does not export `SessionResetMode` either. Existing
callers needing those names must keep retained type imports or explicitly
adapt their types. Talk config, cron-store operations, context-visibility
config resolution, and dangerous-name checks also lack a complete modern
typed-public mapping. Missing public contracts require an SDK-owner decision,
not an import of the private focused implementation.
The named types `TtsMode`, `TtsPersonaConfig`, `TtsPersonaFallbackPolicy`,
and `SessionResetMode` move unchanged to `config-contracts`. Talk config,
cron-store operations, context-visibility config resolution, and
dangerous-name checks lack a complete modern typed-public mapping.
Adapt plugin-owned behavior or request a focused
public contract; do not import the private host implementation.
Bundled plugins and their tests are scanner-guarded against the broad
barrel so imports and mocks stay local to the behavior they need. The
barrel still exists for external compatibility, but new code should not
depend on it.
barrel so imports and mocks stay local to the behavior they need.
</Step>
@ -240,13 +236,16 @@ would expose a historical failure as new pending work.
grep -r "plugin-sdk/compat" my-plugin/
grep -r "plugin-sdk/infra-runtime" my-plugin/
grep -r "plugin-sdk/config-runtime" my-plugin/
grep -r "plugin-sdk/channel-lifecycle" my-plugin/
grep -r "plugin-sdk/channel-message" my-plugin/
grep -r "plugin-sdk/channel-reply-pipeline" my-plugin/
grep -r "openclaw/extension-api" my-plugin/
```
</Step>
<Step title="Replace with focused imports">
Check the exported name and typed-public contract as well as the import
path. Some functions are renamed; not every retained helper or named type
path. Some functions are renamed; not every removed helper or named type
has a modern public replacement:
```typescript
@ -265,7 +264,7 @@ would expose a historical failure as new pending work.
The explicit alias preserves existing `createChannelReplyPipeline(...)`
call sites. The modern export is `createChannelMessageReplyPipeline`;
see [Retained channel facade mappings](/plugins/sdk-migration/import-paths#retained-channel-facade-mappings)
see [Removed channel facade mappings](/plugins/sdk-migration/import-paths#retained-channel-facade-mappings)
for the remaining functions and named types.
For host-side helpers, use the injected plugin runtime instead of
@ -295,13 +294,13 @@ would expose a historical failure as new pending work.
</Step>
<Step title="Replace broad infra-runtime imports">
`openclaw/plugin-sdk/infra-runtime` still exists for external
compatibility, but new code should use the supported surface it actually
needs:
`openclaw/plugin-sdk/infra-runtime` has been removed. Use the supported
surface for each operation:
| Need | Typed-public import or injected API |
| --- | --- |
| New system event producers | `api.runtime.system.enqueueSystemEvent` |
| System event snapshot inspection and consumption | `openclaw/plugin-sdk/system-event-runtime` |
| Heartbeat wake requests | `api.runtime.system.requestHeartbeat` |
| Channel activity telemetry | `api.runtime.channel.activity.record` and `.get` |
| `createDedupeCache`, `resolveGlobalDedupeCache` | `openclaw/plugin-sdk/dedupe-runtime` |
@ -315,21 +314,17 @@ would expose a historical failure as new pending work.
OpenClaw no longer uses `commandRequiresSecurityAuditSuppressionApproval`
internally: suppression reads and writes follow ordinary exec policy. The
deprecated SDK export preserves its shipped signature and results, including
`true` for suppression writes, until the
[infra-runtime compatibility surface is retired](/plugins/sdk-migration/removal-timeline).
Existing plugins can retain the call during that window. Plugins adopting
ordinary exec policy should remove it; there is no replacement command-text
detector.
SDK export was removed with the compatibility barrel. Remove this call when
adopting ordinary exec policy; there is no replacement command-text detector.
These are symbol-specific mappings, not replacements for the whole barrel.
Private-local entries such as `heartbeat-runtime`, `delivery-queue-runtime`,
`fetch-runtime`, `runtime-fetch`, and `file-lock` are JavaScript-only host
exports, not typed third-party APIs. Heartbeat event/summary/visibility
helpers, pending-delivery drain, transport readiness, concurrency, and file
locking do not have equivalent modern typed-public mappings here. Retain
existing compatibility imports for those operations pending an SDK-owner
decision.
locking do not have equivalent modern typed-public mappings here. Adapt
plugin-owned behavior or request a focused public contract for the missing
host capability.
`fetchWithSsrFGuard` is not a drop-in replacement for dispatcher-aware fetch:
it takes an options object and returns `{ response, finalUrl, release, ... }`,
@ -343,11 +338,13 @@ would expose a historical failure as new pending work.
`stringifyNonErrorCause`, `ErrorKind`, or `detectErrorKind`; the last helper
preserves legacy substring classification. The numeric and random mappings
likewise do not cover every timer, expiry, hex, fraction, or integer helper.
Keep unsupported retained imports until their public contract is resolved.
Adapt those operations explicitly; the removed imports no longer load.
System event snapshot inspection and consume helpers remain available only
through the deprecated `openclaw/plugin-sdk/infra-runtime` compatibility
surface; there is no modern public replacement. Current snapshots carry an
Import system event snapshot inspection and consume helpers from
`openclaw/plugin-sdk/system-event-runtime`: use `peekSystemEventEntries`
to inspect and `consumeSelectedSystemEventEntries` to consume selected
snapshots. Replace the legacy `consumeSystemEventEntries` alias with
`consumeSelectedSystemEventEntries`. Current snapshots carry an
opaque `id` for one queued occurrence. Preserve it through copies and
serialization when returning a snapshot to consume. Legacy ID-less callers
retain structural matching, which can be ambiguous after queue churn. Do

View file

@ -1,13 +1,13 @@
---
summary: "Which typed-public SDK subpath replaces each legacy import, including the retained channel facades"
summary: "Which typed-public SDK subpath replaces each legacy import, including the removed channel facades"
read_when:
- You are replacing a broad SDK barrel import with a focused subpath
- You need the retained channel facade to channel-outbound mappings
- You need the removed channel facade to channel-outbound mappings
title: "Import path reference"
sidebarTitle: "Import paths"
---
How to pick the narrowest documented subpath, and the per-export mappings for the retained channel facades. Part of the [Plugin SDK migration](/plugins/sdk-migration) guide.
How to pick the narrowest documented subpath, and the per-export mappings for the removed channel facades. Part of the [Plugin SDK migration](/plugins/sdk-migration) guide.
## Import path reference
@ -38,9 +38,11 @@ Use the narrowest import that matches the job. If you cannot find an export,
check the source at `src/plugin-sdk/` or ask maintainers which generic
contract should own it.
### Retained channel facade mappings
<a id="retained-channel-facade-mappings" />
The retained channel facades are not interchangeable with `channel-outbound`.
### Removed channel facade mappings
The removed channel facades are not interchangeable with `channel-outbound`.
Migrate each function and type separately.
For `openclaw/plugin-sdk/channel-reply-pipeline`, use these exports from
@ -52,39 +54,34 @@ For `openclaw/plugin-sdk/channel-reply-pipeline`, use these exports from
| `resolveChannelSourceReplyDeliveryMode` | `resolveChannelMessageSourceReplyDeliveryMode` |
| `createReplyPrefixContext`, `createReplyPrefixOptions`, `createTypingCallbacks` | Same names |
These functions share their implementations with the retained facade. The named
types do not all move with them: `channel-outbound` does not export
These functions preserve the former facade's implementations. Their named
types are also available unchanged from `channel-outbound`:
`ChannelReplyPipeline`, `CreateTypingCallbacksParams`, `ReplyPrefixContext`,
`ReplyPrefixContextBundle`, `ReplyPrefixOptions`, or `TypingCallbacks`.
`SourceReplyDeliveryMode` is available from the typed-public
`openclaw/plugin-sdk/reply-runtime` subpath. Callers that still need the other
named imports must retain their compatibility type imports until an SDK owner
approves a public replacement; do not import the internal `channel-reply-core`
source file.
`ReplyPrefixContextBundle`, `ReplyPrefixOptions`, `SourceReplyDeliveryMode`,
and `TypingCallbacks`.
From `openclaw/plugin-sdk/channel-lifecycle`, these functions move unchanged to
`channel-outbound`: `createAccountStatusSink`, `createChannelRunQueue`,
`keepHttpServerTaskAlive`, `runPassiveAccountLifecycle`, `waitUntilAbort`,
`createDraftStreamLoop`, `createFinalizableDraftLifecycle`,
`createFinalizableDraftStreamControlsForState`, and `takeMessageIdAfterStop`.
Other lifecycle helpers need more than a path change:
| Retained helper | Migration limit |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `deliverFinalizableDraftPreview` | Adapt to `defineFinalizableLivePreviewAdapter` and `deliverWithFinalizableLivePreviewAdapter`. Move preview callbacks into `adapter` and handle an object result with `kind` and optional `liveState`, not the legacy string result. The adapter can return `preview-retained`; the legacy wrapper maps that kind to `normal-skipped`. |
| `createFinalizableDraftStreamControls` | `createFinalizableDraftStreamControlsForState` requires a shared `{ stopped, final }` object instead of custom state getter/marker callbacks. |
| `clearFinalizableDraftMessage` | Adopting `createFinalizableDraftLifecycle` changes cleanup ownership: it serializes clears and retains failed deletions for retry. `takeMessageIdAfterStop` only takes the ID; it does not delete the message. |
| `createRunStateMachine`, `createArmableStallWatchdog` | No modern public equivalents. Keep retained imports pending an SDK-owner decision. |
`createFinalizableDraftStreamControls`,
`createFinalizableDraftStreamControlsForState`, `clearFinalizableDraftMessage`,
`takeMessageIdAfterStop`, `createRunStateMachine`, and `createArmableStallWatchdog`.
The named types `ChannelRunQueue`, `ChannelRunQueueParams`,
`ChannelRunQueueTaskContext`, `DraftPreviewFinalizerDraft`,
`DraftPreviewFinalizerResult`, `DraftStreamLoop`, `FinalizableDraftStreamState`,
`ArmableStallWatchdog`, and `StallWatchdogTimeoutMeta` are not exported by
`channel-outbound`. Nor does it export `deliverFinalizableLivePreview`,
`LivePreviewFinalizerDraft`, or `LivePreviewFinalizerResult`, despite the legacy
finalizer annotations recommending them. Keep needed compatibility type imports;
inferred factory results are not necessarily identical to caller-implemented
legacy interfaces.
`ChannelRunQueueTaskContext`, `DraftStreamLoop`, `FinalizableDraftStreamState`,
`ArmableStallWatchdog`, and `StallWatchdogTimeoutMeta` also move unchanged to
`channel-outbound`.
Replace `deliverFinalizableDraftPreview` with
`defineFinalizableLivePreviewAdapter` and `deliverWithFinalizableLivePreviewAdapter`.
Move preview callbacks into `adapter` and handle an object result with `kind`
and optional `liveState`, not the legacy string result. The adapter can return
`preview-retained`; the removed wrapper mapped that kind to `normal-skipped`.
The legacy `DraftPreviewFinalizerDraft` and `DraftPreviewFinalizerResult` names
are removed. Adapt type usage to the focused live-preview contracts and account
for the result-shape change.
For `openclaw/plugin-sdk/channel-message`, move outbound exports unchanged to
`channel-outbound`, but migrate its three dispatch aliases to

View file

@ -40,25 +40,30 @@ Upgrade affected plugins to scoped row and transcript-identity APIs before
upgrading the host. The `session-store-runtime` subpath and `resolveStorePath`
remain supported; see the [removed session APIs and replacements](/plugins/sdk-migration/removed-surfaces#removed-session-and-transcript-file-apis).
| Removal gate | Tier | SDK subpaths |
| ----------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `2026-10-01` | Earlier compatibility deprecations | `channel-lifecycle`, `channel-message`, `channel-reply-pipeline`, `config-runtime`, `infra-runtime` |
| `next-plugin-sdk-major` | Major-version compatibility gate | `inbound-reply-dispatch`; `api.runtime.state.openSyncKeyedStore` and `PluginStateSyncKeyedStore` |
| `2026-10-01` | Media legacy projection | `agent-media-payload`, plus the non-subpath `MsgContext Media*` fields, channel inbound media payload builders, `buildMediaPayload`, hook media aliases, and `{{Media*}}` templates |
| Removal gate | Tier | SDK subpaths |
| ----------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `next-plugin-sdk-major` | Major-version compatibility gate | `inbound-reply-dispatch`; `api.runtime.state.openSyncKeyedStore` and `PluginStateSyncKeyedStore` |
| `2026-10-01` | Media legacy projection | `agent-media-payload`, plus the non-subpath `MsgContext Media*` fields, channel inbound media payload builders, `buildMediaPayload`, hook media aliases, and `{{Media*}}` templates |
The five September 1 subpaths remain available in 2026.8.2 under an approved
retention exception; that release’s registry still labels them `deprecated`.
For 2026.9.1, the release maintainer approved renewing their `removeAfter` date
from `2026-09-01` to `2026-10-01` on September 2, 2026. The registry keeps them
`removal-pending` with the same replacement mappings. Removal awaits verification
that supported external plugins have migrated. `infra-runtime` additionally retains
system-event snapshot inspection and consumption until a modern public replacement
exists. This changes compatibility tracking only, not the exported SDK or runtime
behavior.
The five compatibility subpaths `channel-lifecycle`, `channel-message`,
`channel-reply-pipeline`, `config-runtime`, and `infra-runtime` were retired
early by explicit SDK-owner approval on September 30, 2026. That decision
supersedes their October 1 gate and external-migration retention blocker; it
does not certify that every external plugin has migrated. Their public exports
and compatibility aliases are removed. Upgrade affected plugins before loading
them on a host containing this removal.
These subpaths remained available in 2026.8.2 under an approved retention
exception. On September 2, 2026, the release maintainer renewed their
`removeAfter` date from September 1 to October 1 for 2026.9.1. The September 30
decision replaces that window. See [channel import mappings](/plugins/sdk-migration/import-paths#retained-channel-facade-mappings)
and [config and infrastructure migration](/plugins/sdk-migration/how-to-migrate)
for replacements and behavioral differences. System-event snapshot inspection
and consumption now use `openclaw/plugin-sdk/system-event-runtime`.
Bundled-plugin migration does not prove that every external caller can use a
path-only replacement. Migrate the functions with verified typed-public mappings;
keep retained imports where a named type or required behavior still lacks a
public replacement and ask the SDK owner to resolve that gap. Run
adapt the caller where a removed named type or required behavior lacks a
public replacement. Do not substitute a private-local host export. Run
`pnpm plugins:boundary-report` to see the dates, gates, and blockers for the
surfaces your plugin uses.

View file

@ -18,6 +18,19 @@ access to bundled-only SDK modules. Private-local build mappings remain for
repository owners, and production-private JavaScript exports support official
plugin runtimes. Neither provides typed third-party SDK access.
### Channel, config, and infrastructure compatibility facades
`channel-lifecycle`, `channel-message`, `channel-reply-pipeline`,
`config-runtime`, and `infra-runtime` were removed with SDK-owner approval on
September 30, 2026. Channel imports move to focused outbound and inbound
contracts; config access uses supplied config, snapshots, and mutation helpers;
infrastructure imports move to the matching focused runtime or injected API.
System-event snapshot inspection and consumption use `system-event-runtime`.
Some legacy helpers and named types require caller changes rather than an
import-path substitution. See the [channel mappings](/plugins/sdk-migration/import-paths#retained-channel-facade-mappings)
and [config and infrastructure migration steps](/plugins/sdk-migration/how-to-migrate).
### Process-global API-provider publication
`registerApiProvider(...)` and `unregisterApiProviders(...)` were removed from

View file

@ -35,7 +35,7 @@ after the mutation returns.
Use `current()`, a passed-in `cfg`, `mutateConfigFile(...)`, or
`replaceConfigFile(...)` for runtime config access and writes.
For direct SDK imports, prefer the focused config subpaths over the broad `openclaw/plugin-sdk/config-runtime` compatibility barrel: `config-contracts` for types, `runtime-config-snapshot` for current process snapshots, and `config-mutation` for writes. Read entry-scoped values from `api.pluginConfig`; use a supplied tool context only for its runtime-wide config snapshot, and keep plugin-specific merging at that boundary. Bundled plugin tests should mock these focused subpaths directly instead of mocking the broad compatibility barrel.
For direct SDK imports, use `config-contracts` for types, `runtime-config-snapshot` for current process snapshots, and `config-mutation` for writes. The broad `openclaw/plugin-sdk/config-runtime` compatibility barrel has been removed. Read entry-scoped values from `api.pluginConfig`; use a supplied tool context only for its runtime-wide config snapshot, and keep plugin-specific merging at that boundary. Bundled plugin tests should mock these focused subpaths directly.
When using the direct `config-mutation` import to replace a source snapshot, pass
the edited config as `sourceConfig` to `replaceConfigFile`, retaining its `snapshot`,

View file

@ -166,12 +166,17 @@ labeled private-local below. Production-private JavaScript exports remain
available for official plugin runtimes. The maintained list is
`scripts/lib/plugin-sdk-deprecated-public-subpaths.json`; CI rejects bundled
imports of these compatibility-only subpaths. The broad domain barrels
`plugin-sdk/agent-runtime`, `plugin-sdk/channel-lifecycle`,
`plugin-sdk/conversation-runtime`, `plugin-sdk/hook-runtime`,
`plugin-sdk/agent-runtime`, `plugin-sdk/conversation-runtime`, `plugin-sdk/hook-runtime`,
`plugin-sdk/media-runtime`, `plugin-sdk/plugin-runtime`, and
`plugin-sdk/security-runtime` are likewise deprecated in favor of focused
subpaths.
The `channel-lifecycle`, `channel-message`, `channel-reply-pipeline`,
`config-runtime`, and `infra-runtime` compatibility subpaths were removed with
SDK-owner approval on September 30, 2026. Use the
[migration guide](/plugins/sdk-migration) for the focused replacements and
per-export differences.
OpenClaw's Vitest-backed test-helper subpaths are repo-local only and are no
longer package exports: `agent-runtime-test-contracts`,
`channel-contract-testing`, `channel-target-testing`, `channel-test-helpers`,
@ -210,16 +215,13 @@ new code; see the per-row notes below.
| `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers |
| `plugin-sdk/access-groups` | Private-local after July 2026; Access-group allowlist parsing and redacted group diagnostics helpers |
| `plugin-sdk/channel-pairing` | `createChannelPairingController` |
| `plugin-sdk/channel-reply-pipeline` | Retained compatibility facade. `channel-outbound` exports `createChannelMessageReplyPipeline` and `resolveChannelMessageSourceReplyDeliveryMode`; other function names are unchanged, but named types do not all move. See [retained channel mappings](/plugins/sdk-migration#retained-channel-facade-mappings). |
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` |
| `plugin-sdk/channel-config-schema` | Shared channel config schema primitives plus Zod and direct JSON/TypeBox builders |
| `plugin-sdk/bundled-channel-config-schema` | Private-local after July 2026; Bundled OpenClaw channel config schemas for maintained bundled plugins only |
| `plugin-sdk/chat-channel-ids` | Private-local after July 2026; `BUNDLED_CHAT_CHANNEL_IDS`, `BUNDLED_CHAT_CHANNEL_ENVELOPE_PREFIXES`, `ChatChannelId`. Canonical bundled/official chat channel ids plus formatter labels/aliases for plugins that need to recognize envelope-prefixed text without hardcoding their own table. |
| `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-ingress-runtime` | Experimental high-level channel ingress runtime resolver, implicit-mention policy resolver, and route fact builders for migrated channel receive paths. Prefer this over assembling effective allowlists, command allowlists, and legacy projections in each plugin. See [Channel ingress API](/plugins/sdk-channel-ingress). |
| `plugin-sdk/channel-lifecycle` | Retained compatibility facade. Selected functions move unchanged to `channel-outbound`; other helpers require behavioral migration or an owner-approved public replacement. Named types do not all move. See [retained channel mappings](/plugins/sdk-migration#retained-channel-facade-mappings). |
| `plugin-sdk/channel-outbound` | Message lifecycle contracts plus reply pipeline options, receipts, live preview/streaming, lifecycle helpers, outbound identity, payload planning, durable sends, and message-send context helpers. See [Channel outbound API](/plugins/sdk-channel-outbound). |
| `plugin-sdk/channel-message` | Retained compatibility facade. Move outbound exports to `channel-outbound` and its three dispatch aliases to their renamed exports in `channel-inbound`. See [retained channel mappings](/plugins/sdk-migration#retained-channel-facade-mappings). |
| `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers |
| `plugin-sdk/inbound-event-delivery` | Process-local correlation between active inbound events and successful channel sends |
| `plugin-sdk/inbound-reply-dispatch` | Deprecated compatibility shim for `dispatchInboundReplyWithBase`; its compatibility-ledger gate is the next Plugin SDK major, not a calendar date. Use `plugin-sdk/channel-inbound` for inbound runners and `plugin-sdk/channel-outbound` for message delivery helpers. |
@ -425,7 +427,6 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, advertised LAN host resolution, and channel-status patch helpers |
| `plugin-sdk/websocket-runtime` | Node-compatible `WebSocket`, `WebSocketServer`, and `createWebSocketStream` exports backed by the installed `ws` package, plus one-time observer tickets, WebSocket keepalive, and upgrade rejection helpers. Use this subpath when a plugin needs the full Node `ws` option and event contract across supported runtimes. |
| `plugin-sdk/config-contracts` | Focused config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types, plus the dependency-light runtime helper `resolveGatewayPublicOrigin(cfg)` which returns the normalized `gateway.publicOrigin` (bare http(s) origin, optional reverse-proxy path, no query/hash) or `undefined` when unset, for building links back to the Gateway |
| `plugin-sdk/config-runtime` | Retained broad config compatibility facade; prefer passed config, `api.pluginConfig`, `config-contracts`, `config-mutation`, and `runtime-config-snapshot` where they cover the needed contract. Named types and private-runtime helpers are not blanket replacements; see [migration guidance](/plugins/sdk-migration#how-to-migrate). |
| `plugin-sdk/plugin-config-runtime` | Deprecated compatibility facade for runtime plugin-config helpers; new plugins use `api.pluginConfig` plus focused config contracts, snapshots, and mutation helpers |
| `plugin-sdk/config-mutation` | Transactional config mutation helpers such as `mutateConfigFile`, `replaceConfigFile`, and `logConfigUpdated` |
| `plugin-sdk/message-tool-delivery-hints` | Private-local after July 2026; Shared message-tool delivery metadata hint strings |
@ -489,10 +490,9 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/expect-runtime` | Private-local after July 2026; Required-value assertion helper for provable runtime invariants |
| `plugin-sdk/number-runtime` | Private-local after July 2026; Numeric coercion helper |
| `plugin-sdk/secure-random-runtime` | Private-local after July 2026; Secure token/UUID helpers |
| `plugin-sdk/system-event-runtime` | Private-local after July 2026; Narrow system event enqueue/peek helpers |
| `plugin-sdk/system-event-runtime` | Focused system event enqueue, snapshot inspection, and consumption helpers; preserve each snapshot's opaque event ID when consuming selected entries |
| `plugin-sdk/transport-ready-runtime` | Private-local after July 2026; Transport readiness wait helper |
| `plugin-sdk/exec-approvals-runtime` | Private-local after July 2026; Exec approval policy file helpers without the broad infra-runtime barrel |
| `plugin-sdk/infra-runtime` | Deprecated compatibility shim; use injected runtime APIs or documented typed-public subpaths |
| `plugin-sdk/collection-runtime` | Small bounded cache helpers |
| `plugin-sdk/diagnostic-flags` | `isDiagnosticFlagEnabled` for flag-only consumers without event, trace, or redaction initialization |
| `plugin-sdk/diagnostic-runtime` | Diagnostic flag, event, trace-context, and low-cardinality dimension normalization helpers |

View file

@ -4,7 +4,7 @@ import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helper
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import { createRequireRecord, typedCases } from "openclaw/plugin-sdk/test-fixtures";
import * as fixtures from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType, type Guild } from "./internal/discord.js";
import { mapGatewayDispatchData } from "./internal/gateway-dispatch.js";
@ -536,7 +536,7 @@ describe("discord autoThread name sanitization", () => {
describe("discord reaction notification gating", () => {
it("applies mode-specific reaction notification rules", () => {
const cases = typedCases<{
const cases = fixtures.typedCases<{
name: string;
input: Parameters<typeof shouldEmitDiscordReactionNotification>[0];
expected: boolean;
@ -718,7 +718,7 @@ const {
registerDiscordListener,
} = await import("./monitor/listeners.js");
const requireRecord = createRequireRecord("object", "expected-label-object");
const requireRecord = fixtures.createRequireRecord("object", "expected-label-object");
function makeReactionEvent(overrides?: {
guildId?: string;
@ -891,7 +891,7 @@ describe("discord DM reaction handling", () => {
Listener: DiscordReactionRemoveListener,
},
])("preserves distinct normal and super reactions when $action", async (testCase) => {
channelRuntimeModule.resetSystemEventsForTest();
fixtures.resetSystemEventsForTest();
enqueueSystemEventSpy.mockImplementation((text: string, options: { sessionKey: string }) =>
channelRuntimeModule.enqueueSystemEvent(text, options),
);
@ -955,7 +955,7 @@ describe("discord DM reaction handling", () => {
);
} finally {
enqueueSystemEventSpy.mockReset();
channelRuntimeModule.resetSystemEventsForTest();
fixtures.resetSystemEventsForTest();
}
});
@ -1175,7 +1175,7 @@ describe("discord reaction notification modes", () => {
const guild = fakeGuild(guildId, "Mode Guild");
it("applies message-fetch behavior across notification modes and channel types", async () => {
const cases = typedCases<{
const cases = fixtures.typedCases<{
name: string;
reactionNotifications: "off" | "all" | "allowlist" | "own";
users: string[] | undefined;

View file

@ -6,10 +6,8 @@ import {
registerSessionBindingAdapter,
testing as sessionBindingTesting,
} from "openclaw/plugin-sdk/session-binding-runtime";
import {
peekSystemEventEntries,
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { peekSystemEventEntries } from "openclaw/plugin-sdk/system-event-runtime";
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { IMessageRpcClient, type createIMessageRpcClient } from "./client.js";
import { monitorIMessageProvider } from "./monitor.js";

View file

@ -16,8 +16,8 @@ import {
import {
enqueueSystemEvent,
peekSystemEventEntries,
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
import {

View file

@ -13,13 +13,10 @@ import {
sessionDeliveryOrigin,
upsertSessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import {
peekSystemEventEntries,
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { peekSystemEventEntries } from "openclaw/plugin-sdk/system-event-runtime";
// Matrix tests cover handler plugin behavior.
import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { createRequireRecord, resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";

View file

@ -1,8 +1,8 @@
import {
enqueueSystemEvent,
peekSystemEventEntries,
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
registerMatrixApprovalReactionTarget as registerMatrixApprovalReactionTargetRaw,

View file

@ -7,10 +7,8 @@ import {
getRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import {
enqueueSystemEvent,
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { registerShortTermPromotionDreaming } from "./dreaming.js";

View file

@ -17,10 +17,8 @@ import {
} from "openclaw/plugin-sdk/memory-core-host-status";
import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import {
enqueueSystemEvent,
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { registerShortTermPromotionDreaming } from "./dreaming.js";
import { recordShortTermRecalls } from "./short-term-promotion.js";

View file

@ -4,8 +4,8 @@ import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import {
enqueueSystemEvent,
peekSystemEventEntries,
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../../runtime-api.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js";

View file

@ -315,7 +315,7 @@ export function installSignalToolResultTestHooks() {
beforeEach(async () => {
const [{ resetInboundDedupe }, { resetSystemEventsForTest }] = await Promise.all([
import("openclaw/plugin-sdk/reply-runtime"),
import("openclaw/plugin-sdk/system-event-runtime"),
import("openclaw/plugin-sdk/test-fixtures"),
]);
resetInboundDedupe();
signalToolResultState = await createOpenClawTestState({

View file

@ -746,7 +746,10 @@ describe("registerSlackMessageEvents", () => {
const actualSystemEvents = await vi.importActual<
typeof import("openclaw/plugin-sdk/system-event-runtime")
>("openclaw/plugin-sdk/system-event-runtime");
actualSystemEvents.resetSystemEventsForTest();
const { resetSystemEventsForTest } = await vi.importActual<
typeof import("openclaw/plugin-sdk/test-fixtures")
>("openclaw/plugin-sdk/test-fixtures");
resetSystemEventsForTest();
messageQueueMock.mockImplementation(
(text: string, { sessionKey, ...options }: { sessionKey: string }) =>
actualSystemEvents.enqueueRoutedSystemEvent(
@ -840,7 +843,7 @@ describe("registerSlackMessageEvents", () => {
expect(actualSystemEvents.peekSystemEventEntries(threadSessionKey)).toHaveLength(
threadSession ? 1 : 0,
);
actualSystemEvents.resetSystemEventsForTest();
resetSystemEventsForTest();
},
);

View file

@ -17,10 +17,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import type { PluginJsonValue } from "openclaw/plugin-sdk/plugin-entry";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
peekSystemEventEntries,
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { peekSystemEventEntries } from "openclaw/plugin-sdk/system-event-runtime";
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import { installSlackTestRuntime } from "../test-runtime.test-support.js";
import { createSlackMonitorContext } from "./context.js";

View file

@ -14,8 +14,8 @@ import {
import {
enqueueRoutedSystemEvent as enqueueActualSystemEvent,
peekSystemEventEntries,
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultTelegramBotDeps } from "./bot-deps.js";
import { createTelegramEventBindings } from "./bot-handlers.event-bindings.js";

View file

@ -5,11 +5,8 @@ import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { addChannelAllowFromStoreEntry } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import * as replyRuntime from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import {
peekSystemEventEntries,
resetSystemEventsForTest,
} from "openclaw/plugin-sdk/system-event-runtime";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { peekSystemEventEntries } from "openclaw/plugin-sdk/system-event-runtime";
import { createRequireRecord, resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { describe, expect, it, vi } from "vitest";
import { runWithTelegramSpooledReplayUpdate } from "./bot-processing-outcome.js";
import {

View file

@ -77,9 +77,6 @@
"openclaw/plugin-sdk/secure-random-runtime": [
"../packages/plugin-sdk/dist/src/plugin-sdk/secure-random-runtime.d.ts"
],
"openclaw/plugin-sdk/system-event-runtime": [
"../packages/plugin-sdk/dist/src/plugin-sdk/system-event-runtime.d.ts"
],
"openclaw/plugin-sdk/transport-ready-runtime": [
"../packages/plugin-sdk/dist/src/plugin-sdk/transport-ready-runtime.d.ts"
],

View file

@ -77,9 +77,6 @@
"openclaw/plugin-sdk/secure-random-runtime": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/secure-random-runtime.d.ts"
],
"openclaw/plugin-sdk/system-event-runtime": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/system-event-runtime.d.ts"
],
"openclaw/plugin-sdk/transport-ready-runtime": [
"../../packages/plugin-sdk/dist/src/plugin-sdk/transport-ready-runtime.d.ts"
],

View file

@ -241,7 +241,6 @@
"!dist/plugin-sdk/ssrf-dispatcher.d.ts",
"!dist/plugin-sdk/ssrf-runtime-internal.d.ts",
"!dist/plugin-sdk/string-normalization-runtime.d.ts",
"!dist/plugin-sdk/system-event-runtime.d.ts",
"!dist/plugin-sdk/talk-config-runtime.d.ts",
"!dist/plugin-sdk/target-resolver-runtime.d.ts",
"!dist/plugin-sdk/test-env.js",
@ -602,10 +601,6 @@
"types": "./dist/plugin-sdk/question-gateway-runtime.d.ts",
"default": "./dist/plugin-sdk/question-gateway-runtime.js"
},
"./plugin-sdk/config-runtime": {
"types": "./dist/plugin-sdk/config-runtime.d.ts",
"default": "./dist/plugin-sdk/config-runtime.js"
},
"./plugin-sdk/config-contracts": {
"types": "./dist/plugin-sdk/config-contracts.d.ts",
"default": "./dist/plugin-sdk/config-contracts.js"
@ -662,10 +657,6 @@
"types": "./dist/plugin-sdk/inbound-event-delivery.d.ts",
"default": "./dist/plugin-sdk/inbound-event-delivery.js"
},
"./plugin-sdk/channel-reply-pipeline": {
"types": "./dist/plugin-sdk/channel-reply-pipeline.d.ts",
"default": "./dist/plugin-sdk/channel-reply-pipeline.js"
},
"./plugin-sdk/interactive-runtime": {
"types": "./dist/plugin-sdk/interactive-runtime.d.ts",
"default": "./dist/plugin-sdk/interactive-runtime.js"
@ -717,6 +708,7 @@
"default": "./dist/plugin-sdk/secure-random-runtime.js"
},
"./plugin-sdk/system-event-runtime": {
"types": "./dist/plugin-sdk/system-event-runtime.d.ts",
"default": "./dist/plugin-sdk/system-event-runtime.js"
},
"./plugin-sdk/transport-ready-runtime": {
@ -725,10 +717,6 @@
"./plugin-sdk/exec-approvals-runtime": {
"default": "./dist/plugin-sdk/exec-approvals-runtime.js"
},
"./plugin-sdk/infra-runtime": {
"types": "./dist/plugin-sdk/infra-runtime.d.ts",
"default": "./dist/plugin-sdk/infra-runtime.js"
},
"./plugin-sdk/runtime-config-snapshot": {
"types": "./dist/plugin-sdk/runtime-config-snapshot.d.ts",
"default": "./dist/plugin-sdk/runtime-config-snapshot.js"
@ -1159,18 +1147,10 @@
"./plugin-sdk/channel-mention-gating": {
"default": "./dist/plugin-sdk/channel-mention-gating.js"
},
"./plugin-sdk/channel-lifecycle": {
"types": "./dist/plugin-sdk/channel-lifecycle.d.ts",
"default": "./dist/plugin-sdk/channel-lifecycle.js"
},
"./plugin-sdk/channel-ingress-runtime": {
"types": "./dist/plugin-sdk/channel-ingress-runtime.d.ts",
"default": "./dist/plugin-sdk/channel-ingress-runtime.js"
},
"./plugin-sdk/channel-message": {
"types": "./dist/plugin-sdk/channel-message.d.ts",
"default": "./dist/plugin-sdk/channel-message.js"
},
"./plugin-sdk/channel-outbound": {
"types": "./dist/plugin-sdk/channel-outbound.d.ts",
"default": "./dist/plugin-sdk/channel-outbound.js"

View file

@ -44,10 +44,6 @@
"types": "./dist/src/plugin-sdk/config-mutation.d.ts",
"default": "./src/config-mutation.ts"
},
"./config-runtime": {
"types": "./dist/src/plugin-sdk/config-runtime.d.ts",
"default": "./src/config-runtime.ts"
},
"./control-ui-link-reader": {
"types": "./dist/src/plugin-sdk/control-ui-link-reader.d.ts",
"default": "./src/control-ui-link-reader.ts"
@ -92,10 +88,6 @@
"types": "./dist/src/plugin-sdk/heartbeat-runtime.d.ts",
"default": "./src/heartbeat-runtime.ts"
},
"./infra-runtime": {
"types": "./dist/src/plugin-sdk/infra-runtime.d.ts",
"default": "./src/infra-runtime.ts"
},
"./model-session-runtime": {
"types": "./dist/src/plugin-sdk/model-session-runtime.d.ts",
"default": "./src/model-session-runtime.ts"

View file

@ -1 +0,0 @@
export * from "../../../src/plugin-sdk/config-runtime.js";

View file

@ -1 +0,0 @@
export * from "../../../src/plugin-sdk/infra-runtime.js";

View file

@ -174,8 +174,7 @@ const rules: Array<DeprecatedRule & { id: string }> = [
message: "extensions must use focused non-deprecated plugin SDK subpaths",
},
{
// Deprecated facades stay exported for third-party plugins, but internal code
// must not reach them via package specifier or relative import.
// Internal code must not reach deprecated or retired facades by any import form.
id: "facade-internal-imports",
roots: ["src", "extensions"],
collectFile: collectBannedInternalFacadeImportViolations,
@ -187,7 +186,6 @@ const rules: Array<DeprecatedRule & { id: string }> = [
allowedFiles: [
"src/infra/outbound/deliver-runtime.ts",
"src/infra/outbound/deliver.ts",
"src/plugin-sdk/channel-message.ts",
"src/plugin-sdk/inbound-reply-dispatch.ts",
],
message: "use sendDurableMessageBatch or deliverInboundReplyWithMessageSendContext",

View file

@ -12,7 +12,6 @@ const COMPAT_CONFIG_API_FILES = new Set([
"src/config/io.ts",
"src/config/mutate.ts",
"src/memory-host-sdk/runtime-core.ts",
"src/plugin-sdk/config-runtime.ts",
"src/plugin-sdk/memory-core-host-runtime-core.ts",
"src/plugins/compat/registry.ts",
"src/plugins/registry.runtime-config.test.ts",

View file

@ -25,10 +25,8 @@ export function buildDeprecatedPluginSdkModuleSpecifiers(
}
/**
* Deprecated facade modules that stay exported for third-party plugins until the
* documented break train, but must have zero internal importers (src/**,
* extensions/**) via package specifier or relative path. Table-driven and
* additive: future facade collapses (e.g. config-schema) append rows here.
* Deprecated and retired facades must have zero internal importers (src/**,
* extensions/**) via package specifier or relative path.
* `modulePath` is the extension-less repo path; `allowedImporters` lists the
* compat re-export chain that keeps the public subpath alive.
*/

View file

@ -1,4 +1 @@
[
"channel-lifecycle",
"infra-runtime"
]
[]

View file

@ -1,12 +1,7 @@
[
"agent-media-payload",
"channel-lifecycle",
"channel-message",
"channel-reply-pipeline",
"command-auth",
"config-runtime",
"discord",
"inbound-reply-dispatch",
"infra-runtime",
"telegram-account"
]

View file

@ -60,7 +60,7 @@ export const pluginSdkDocMetadata = {
"channel-ingress-runtime": {
category: "channel",
},
"channel-reply-pipeline": {
"channel-outbound": {
category: "channel",
},
"channel-setup": {

View file

@ -101,8 +101,9 @@ export const deprecatedPublicPluginSdkEntrypoints = publicPluginSdkSubpaths.filt
* Deprecated barrel entrypoints that should not be expanded further.
* @internal Shared repository-script contract.
*/
const deprecatedBarrelPluginSdkSubpaths = new Set<string>(deprecatedBarrelPluginSdkSubpathList);
export const deprecatedBarrelPluginSdkEntrypoints = pluginSdkSubpaths.filter((entry) =>
deprecatedBarrelPluginSdkSubpathList.includes(entry),
deprecatedBarrelPluginSdkSubpaths.has(entry),
);
/** Supported SDK facades backed by bundled plugins until generic contracts replace them. */

View file

@ -40,7 +40,6 @@
"approval-reply-runtime",
"approval-runtime",
"question-gateway-runtime",
"config-runtime",
"config-contracts",
"plugin-config-runtime",
"config-mutation",
@ -57,7 +56,6 @@
"inbound-reply-dispatch",
"inbound-envelope",
"inbound-event-delivery",
"channel-reply-pipeline",
"interactive-runtime",
"outbound-media",
"pair-loop-guard-runtime",
@ -77,7 +75,6 @@
"system-event-runtime",
"transport-ready-runtime",
"exec-approvals-runtime",
"infra-runtime",
"runtime-config-snapshot",
"runtime-group-policy",
"model-session-runtime",
@ -218,10 +215,8 @@
"channel-inbound",
"channel-inbound-debounce",
"channel-mention-gating",
"channel-lifecycle",
"channel-ingress-runtime",
"channel-ingress-test-runtime",
"channel-message",
"channel-outbound",
"channel-pairing",
"channel-policy",

View file

@ -190,7 +190,6 @@
"ssrf-dispatcher",
"ssrf-runtime-internal",
"string-normalization-runtime",
"system-event-runtime",
"talk-config-runtime",
"target-resolver-runtime",
"test-env",

View file

@ -136,13 +136,9 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"approval-gateway-runtime": 1,
"approval-handler-runtime": 1,
"approval-reply-runtime": 0,
"config-runtime": 115,
"config-contracts": 0,
"inbound-reply-dispatch": 24,
"channel-reply-pipeline": 12,
"inbound-reply-dispatch": 21,
"interactive-runtime": 11,
// +3: canonical incognito classifier projected through deprecated compatibility barrels.
"infra-runtime": 596,
"ssrf-policy": 1,
"ssrf-runtime": 1,
// +1: deprecated agent media projection re-export during the media migration window.
@ -163,12 +159,6 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
discord: 47,
// +4: deprecated media projection type, builder, and turn aliases.
"channel-inbound": 18,
"channel-lifecycle": 23,
// +1: shared ingress error factory projected through the deprecated message barrel.
// +1: shared ingress retention defaults projected through the deprecated message barrel.
// +1: WhatsApp ack-policy bridge counted through the channel-message legacy facade.
// Rendering helpers also remain available through this shipped legacy facade.
"channel-message": 136,
// +2: Slack progress-draft render bridge (function + mode type).
"channel-outbound": 2,
// +2: WhatsApp ack-policy bridge (function + mode type).
@ -190,28 +180,27 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
const budgets = {
publicEntrypoints: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS",
158,
154,
env,
),
// Includes four approved storage transport contract types on plugin-entry.
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
4591,
3757,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
2693,
2186,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
1139,
269,
env,
),
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
46,
0,
env,
),
};

View file

@ -855,7 +855,7 @@ const SOURCE_TEST_TARGETS = new Map([
],
],
[
"src/plugin-sdk/channel-reply-pipeline.ts",
"src/channels/message/reply-pipeline.ts",
["src/plugins/contracts/plugin-sdk-subpaths.test.ts", ...GROUP_VISIBLE_REPLY_TEST_TARGETS],
],
["src/plugin-sdk/reply-runtime.ts", ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"]],

View file

@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resetFileLockStateForTest } from "../infra/file-lock.js";
import { resetFileLockStateForTest } from "../plugin-sdk/file-lock.js";
import { isPluginRegistryLoadInFlight } from "../plugins/loader-cache.js";
import {
cleanupPluginLoaderFixturesForTest,

View file

@ -4,7 +4,10 @@
* original file-lock cause is preserved.
*/
import { describe, expect, it } from "vitest";
import { FILE_LOCK_TIMEOUT_ERROR_CODE, type FileLockTimeoutError } from "../../infra/file-lock.js";
import {
FILE_LOCK_TIMEOUT_ERROR_CODE,
type FileLockTimeoutError,
} from "../../plugin-sdk/file-lock.js";
import {
buildRefreshContentionError,
isGlobalRefreshLockTimeoutError,

View file

@ -2,11 +2,11 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resetFileLockStateForTest } from "../../infra/file-lock.js";
import {
detectSharedAuthStoreMigration,
migrateSharedAuthStore,
} from "../../infra/state-migrations.shared-auth-store.js";
import { resetFileLockStateForTest } from "../../plugin-sdk/file-lock.js";
import { resolveOpenAICodexAuthIdentity } from "../../plugin-sdk/provider-openai-chatgpt-auth.js";
import { captureEnv } from "../../test-utils/env.js";
import "./oauth-external-auth-passthrough.test-support.js";

View file

@ -1,7 +1,7 @@
/** Tests durable ownership after an OAuth refresh failure. */
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resetFileLockStateForTest } from "../../infra/file-lock.js";
import { resetFileLockStateForTest } from "../../plugin-sdk/file-lock.js";
import { AsyncWorkScope } from "../../shared/async-work-scope.js";
import { createDeferredCore } from "../../shared/deferred.js";
import { captureEnv } from "../../test-utils/env.js";

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetFileLockStateForTest } from "../../infra/file-lock.js";
import { resetFileLockStateForTest } from "../../plugin-sdk/file-lock.js";
import { createDeferredCore } from "../../shared/deferred.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import { captureEnv } from "../../test-utils/env.js";

View file

@ -3,7 +3,10 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatErrorMessage } from "../../infra/errors.js";
import { FILE_LOCK_TIMEOUT_ERROR_CODE, resetFileLockStateForTest } from "../../infra/file-lock.js";
import {
FILE_LOCK_TIMEOUT_ERROR_CODE,
resetFileLockStateForTest,
} from "../../plugin-sdk/file-lock.js";
import {
closeOpenClawAgentDatabasesForTest,
openOpenClawAgentDatabase,

View file

@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetFileLockStateForTest } from "../../infra/file-lock.js";
import { resetFileLockStateForTest } from "../../plugin-sdk/file-lock.js";
import { createDeferredCore } from "../../shared/deferred.js";
import { captureEnv } from "../../test-utils/env.js";
import "./oauth-external-auth-passthrough.test-support.js";

View file

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { resetFileLockStateForTest } from "../../infra/file-lock.js";
import { resetFileLockStateForTest } from "../../plugin-sdk/file-lock.js";
import { captureEnv } from "../../test-utils/env.js";
import "./oauth-external-auth-passthrough.test-support.js";
import {

View file

@ -3,7 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { resetFileLockStateForTest } from "../../infra/file-lock.js";
import { resetFileLockStateForTest } from "../../plugin-sdk/file-lock.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import {
closeOpenClawStateDatabaseAsync,

View file

@ -3,7 +3,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.test-support.js";
import { AsyncWorkScope } from "../shared/async-work-scope.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withEnvAsync } from "../test-utils/env.js";

View file

@ -3,7 +3,8 @@ import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { loadExecApprovalsReadOnly, saveExecApprovals } from "../infra/exec-approvals.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.test-support.js";
import { loadExecApprovalsReadOnly } from "../infra/exec-approvals.js";
import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
import { resolveExecutablePath } from "../infra/executable-path.js";
import { createProcessSupervisor } from "../process/supervisor/supervisor.js";

View file

@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { onAgentEvent } from "../infra/agent-events.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.test-support.js";
import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
import { resolveExecutablePath } from "../infra/executable-path.js";
import { pathLooksMutableForShellPayloadSync } from "../infra/system-run-mutable-file-policy.js";

View file

@ -27,7 +27,7 @@ import { registerCronRunExecSource } from "../infra/cron-run-exec-source.js";
import {
onInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
type DiagnosticSecurityEvent,
type DiagnosticEventPayload,
} from "../infra/diagnostic-events.js";
import type {
ExecAllowlistEntry,
@ -257,10 +257,10 @@ function captureProcessUnhandledRejections() {
}
function captureSecurityEvents(): {
events: DiagnosticSecurityEvent[];
events: Extract<DiagnosticEventPayload, { type: "security.event" }>[];
stop: () => void;
} {
const events: DiagnosticSecurityEvent[] = [];
const events: Extract<DiagnosticEventPayload, { type: "security.event" }>[] = [];
const stop = onInternalDiagnosticEvent((event, metadata) => {
if (metadata.trusted && event.type === "security.event") {
events.push(event);

View file

@ -5,7 +5,8 @@ import { createDeferred } from "../../test/helpers/promise.js";
import { quoteCliArg } from "../cli/quote-cli-arg.js";
import { setRuntimeConfigSnapshot } from "../config/config.js";
import { replaceSessionEntry } from "../config/sessions/session-accessor.js";
import { readExecApprovalsSnapshot, saveExecApprovals } from "../infra/exec-approvals.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.test-support.js";
import { readExecApprovalsSnapshot } from "../infra/exec-approvals.js";
import type { ExecAutoReviewer, ExecAutoReviewTranscript } from "../infra/exec-auto-review.js";
import { handleInvoke } from "../node-host/invoke.js";
import {

View file

@ -13,7 +13,6 @@ import {
onInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
type DiagnosticEventMetadata,
type DiagnosticExecProcessCompletedEvent,
type DiagnosticEventPayload,
} from "../infra/diagnostic-events.js";
import type { GatewayActiveWorkInspectors } from "../infra/gateway-active-work.js";
@ -466,7 +465,8 @@ describe("sandbox exec preparation failures", () => {
createDeferred<Awaited<ReturnType<NonNullable<BashSandboxConfig["buildExecSpec"]>>>>();
const finalizeExec = vi.fn<NonNullable<BashSandboxConfig["finalizeExec"]>>(async () => {});
const onSettledBeforeNotify = vi.fn();
const completionEvents: DiagnosticExecProcessCompletedEvent[] = [];
const completionEvents: Extract<DiagnosticEventPayload, { type: "exec.process.completed" }>[] =
[];
const unsubscribe = onInternalDiagnosticEvent((event) => {
if (
event.type === "exec.process.completed" &&
@ -744,7 +744,7 @@ describe("runExecProcess PTY fallback", () => {
});
const event = events.find(
(item): item is DiagnosticExecProcessCompletedEvent =>
(item): item is Extract<DiagnosticEventPayload, { type: "exec.process.completed" }> =>
item.type === "exec.process.completed",
);
if (!event) {

View file

@ -4,9 +4,9 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createRequireRecord } from "../../test/helpers/record.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.test-support.js";
import {
loadExecApprovals,
saveExecApprovals,
type ExecApprovalsFile,
type ExecApprovalsAgent,
} from "../infra/exec-approvals.js";

View file

@ -5,7 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { saveExecApprovals, type ExecApprovalsFile } from "../infra/exec-approvals.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.test-support.js";
import type { ExecApprovalsFile } from "../infra/exec-approvals.js";
import type { ExecAutoReviewer, ExecAutoReviewTranscript } from "../infra/exec-auto-review.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";

View file

@ -2,15 +2,13 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { saveExecApprovals } from "../../infra/exec-approvals-store.test-support.js";
import {
makeExecutable,
makeExecApprovalsTempDir,
} from "../../infra/exec-approvals-test-helpers.js";
import { loadExecApprovals, saveExecApprovals } from "../../infra/exec-approvals.js";
import {
DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
PLUGIN_APPROVAL_DETAIL_MAX_LENGTH,
} from "../../infra/plugin-approvals.js";
import { loadExecApprovals } from "../../infra/exec-approvals.js";
import { DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS } from "../../infra/plugin-approvals.js";
import { APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE } from "../../infra/system-run-approval-binding.js";
import { callGatewayTool } from "../tools/gateway.js";
import {
@ -424,7 +422,7 @@ describe("requestCliNativeToolApproval", () => {
await expect(
requestCliNativeToolApproval({
toolName: "Bash",
toolInput: { command: "x".repeat(PLUGIN_APPROVAL_DETAIL_MAX_LENGTH) },
toolInput: { command: "x".repeat(16_384) },
pluginId: "claude-cli",
ask: "on-miss",
}),

View file

@ -1,9 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { saveExecApprovals } from "../../infra/exec-approvals-store.test-support.js";
import {
makeExecutable,
makeExecApprovalsTempDir,
} from "../../infra/exec-approvals-test-helpers.js";
import { loadExecApprovals, saveExecApprovals } from "../../infra/exec-approvals.js";
import { loadExecApprovals } from "../../infra/exec-approvals.js";
import type { CliBackendToolPermissionResult } from "../../plugins/cli-backend.types.js";
import { callGatewayTool } from "../tools/gateway.js";
import {

View file

@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.test-support.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { resetProcessRegistryForTests } from "./bash-process-registry.test-support.js";
import { createCoreCodingTools } from "./core-coding-tools.js";

View file

@ -3,6 +3,7 @@ import { vi, type MockInstance } from "vitest";
import { writeSessionEntry } from "../../../config/sessions/session-accessor.sqlite-entry-store.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import * as approvals from "../../../infra/exec-approvals-store.js";
import { saveExecApprovals } from "../../../infra/exec-approvals-store.test-support.js";
import { openOpenClawAgentDatabase } from "../../../state/openclaw-agent-db.js";
import { withOpenClawTestState } from "../../../test-utils/openclaw-test-state.js";
import { prepareSystemAgentRunAdmission } from "../../admitted-run-context.js";
@ -45,7 +46,7 @@ export async function withPromptFixture(
updatedAt: 1,
...(testCase.required ? { sandbox: "required" } : {}),
});
approvals.saveExecApprovals({
saveExecApprovals({
version: 1,
defaults: { security: "allowlist", ask: "off" },
agents: {},

View file

@ -2,10 +2,7 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { clearAgentHarnesses } from "../../agents/harness/registry.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
onDiagnosticEvent,
type DiagnosticMessageProcessedEvent,
} from "../../infra/diagnostic-events.js";
import { onDiagnosticEvent, type DiagnosticEventPayload } from "../../infra/diagnostic-events.js";
import type { ReplyPayload } from "../types.js";
import {
createDispatcher,
@ -66,7 +63,7 @@ async function dispatchReplyFixture(params: {
}
describe("dispatchReplyFromConfig pre-run directive rejection", () => {
let processedEvents: DiagnosticMessageProcessedEvent[];
let processedEvents: Extract<DiagnosticEventPayload, { type: "message.processed" }>[];
let unsubscribe: () => void;
beforeAll(async () => {

View file

@ -1,99 +0,0 @@
// Draft preview finalizer tests cover final draft rendering and cleanup after streamed replies.
import { describe, expect, it, vi } from "vitest";
import { deliverFinalizableDraftPreview } from "./draft-preview-finalizer.js";
function createDraft(id: string | undefined = "preview-1") {
return {
flush: vi.fn(async () => {}),
id: vi.fn(() => id),
seal: vi.fn(async () => {}),
discardPending: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
};
}
describe("deliverFinalizableDraftPreview", () => {
it("does not flush non-finalizable finals before normal delivery", async () => {
const draft = createDraft("preview-1");
const deliverNormally = vi.fn(async () => {});
await deliverFinalizableDraftPreview({
kind: "final",
payload: { text: "image", mediaUrl: "https://example.com/a.png" },
draft,
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => {}),
deliverNormally,
});
expect(draft.flush).not.toHaveBeenCalled();
expect(draft.discardPending).toHaveBeenCalledTimes(1);
expect(deliverNormally).toHaveBeenCalledTimes(1);
expect(draft.clear).toHaveBeenCalledTimes(1);
});
it("flushes only eligible finals and edits the preview in place", async () => {
const draft = createDraft("preview-1");
const editFinal = vi.fn(async () => {});
const deliverNormally = vi.fn(async () => {});
const result = await deliverFinalizableDraftPreview({
kind: "final",
payload: { text: "final" },
draft,
buildFinalEdit: (payload) => payload.text,
editFinal,
deliverNormally,
});
expect(result).toBe("preview-finalized");
expect(draft.flush).toHaveBeenCalledTimes(1);
expect(draft.seal).toHaveBeenCalledTimes(1);
expect(editFinal).toHaveBeenCalledWith("preview-1", "final");
expect(deliverNormally).not.toHaveBeenCalled();
expect(draft.clear).not.toHaveBeenCalled();
});
it("falls back to normal delivery and clears only after success when edit fails", async () => {
const draft = createDraft("preview-1");
const editFinal = vi.fn(async () => {
throw new Error("gone");
});
const deliverNormally = vi.fn(async () => {});
await deliverFinalizableDraftPreview({
kind: "final",
payload: { text: "final" },
draft,
buildFinalEdit: (payload) => payload.text,
editFinal,
deliverNormally,
logPreviewEditFailure: vi.fn(),
});
expect(draft.flush).toHaveBeenCalledTimes(1);
expect(draft.discardPending).toHaveBeenCalledTimes(1);
expect(deliverNormally).toHaveBeenCalledTimes(1);
expect(draft.clear).toHaveBeenCalledTimes(1);
});
it("keeps an existing preview if normal fallback delivery throws", async () => {
const draft = createDraft("preview-1");
await expect(
deliverFinalizableDraftPreview({
kind: "final",
payload: { text: "image" },
draft,
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => {}),
deliverNormally: vi.fn(async () => {
throw new Error("send failed");
}),
}),
).rejects.toThrow("send failed");
expect(draft.discardPending).toHaveBeenCalledTimes(1);
expect(draft.clear).not.toHaveBeenCalled();
});
});

View file

@ -1,56 +0,0 @@
/**
* Deprecated draft preview finalizer facade.
*
* Forwards legacy draft-preview callers to live preview finalization helpers.
*/
import {
deliverFinalizableLivePreview,
type LivePreviewFinalizerDraft,
type LivePreviewFinalizerResultKind,
} from "./message/live.js";
/**
* @deprecated Use `LivePreviewFinalizerDraft` from `openclaw/plugin-sdk/channel-outbound`.
*/
export type DraftPreviewFinalizerDraft<TId> = LivePreviewFinalizerDraft<TId>;
/**
* @deprecated Use `LivePreviewFinalizerResult` from `openclaw/plugin-sdk/channel-outbound`.
*/
export type DraftPreviewFinalizerResult = Exclude<
LivePreviewFinalizerResultKind,
"preview-retained"
>;
/**
* @deprecated Use `deliverFinalizableLivePreview` from `openclaw/plugin-sdk/channel-outbound`.
*/
export async function deliverFinalizableDraftPreview<TPayload, TId, TEdit>(params: {
kind: "tool" | "block" | "final";
payload: TPayload;
draft?: DraftPreviewFinalizerDraft<TId>;
buildFinalEdit: (payload: TPayload) => TEdit | undefined;
editFinal: (id: TId, edit: TEdit) => Promise<void>;
deliverNormally: (payload: TPayload) => Promise<boolean | void>;
onPreviewFinalized?: (id: TId) => Promise<void> | void;
onNormalDelivered?: () => Promise<void> | void;
logPreviewEditFailure?: (error: unknown) => void;
}): Promise<DraftPreviewFinalizerResult> {
const result = await deliverFinalizableLivePreview({
kind: params.kind,
payload: params.payload,
...(params.draft ? { draft: params.draft } : {}),
buildFinalEdit: params.buildFinalEdit,
editFinal: params.editFinal,
deliverNormally: params.deliverNormally,
onPreviewFinalized: async (id) => {
await params.onPreviewFinalized?.(id);
},
...(params.onNormalDelivered ? { onNormalDelivered: params.onNormalDelivered } : {}),
...(params.logPreviewEditFailure
? { logPreviewEditFailure: params.logPreviewEditFailure }
: {}),
});
return result.kind === "preview-retained" ? "normal-skipped" : result.kind;
}

View file

@ -2,20 +2,18 @@ import { describe, expect, it, vi } from "vitest";
import {
createLiveMessageState,
defineFinalizableLivePreviewAdapter,
deliverFinalizableLivePreview,
deliverWithFinalizableLivePreviewAdapter,
markLiveMessagePreviewUpdated,
type LivePreviewFinalizerDraft,
} from "./live.js";
import { createMessageReceiveContext } from "./receive.js";
type LivePreviewMediaPayload = { text?: string; mediaUrl: string };
type LivePreviewMediaEdit = { text?: string };
type PreviewDraft = NonNullable<
Parameters<typeof defineFinalizableLivePreviewAdapter<unknown, string, unknown>>[0]["draft"]
>;
function createDraft(
id: string,
overrides: Partial<LivePreviewFinalizerDraft<string>> = {},
): LivePreviewFinalizerDraft<string> {
function createDraft(id: string, overrides: Partial<PreviewDraft> = {}): PreviewDraft {
return {
flush: vi.fn(async () => undefined),
id: () => id,
@ -50,20 +48,26 @@ describe("message lifecycle primitives", () => {
const editFinal = vi.fn(async () => undefined);
const deliverNormally = vi.fn(async () => undefined);
const onPreviewFinalized = vi.fn(async () => undefined);
const draft = createDraft("preview-1", { seal: vi.fn(async () => undefined) });
const result = await deliverFinalizableLivePreview({
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "done" },
draft: createDraft("preview-1", { seal: vi.fn(async () => undefined) }),
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal,
adapter: {
draft,
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal,
onPreviewFinalized,
},
deliverNormally,
onPreviewFinalized,
});
expect(result.kind).toBe("preview-finalized");
expect(draft.flush).toHaveBeenCalledTimes(1);
expect(draft.seal).toHaveBeenCalledTimes(1);
expect(editFinal).toHaveBeenCalledWith("preview-1", { text: "done" });
expect(deliverNormally).not.toHaveBeenCalled();
expect(draft.clear).not.toHaveBeenCalled();
const liveState = result.liveState;
if (!liveState) {
throw new Error("expected finalized live state");
@ -84,19 +88,21 @@ describe("message lifecycle primitives", () => {
const deliverNormally = vi.fn(async () => undefined);
const deliverSupplemental = vi.fn(async () => true);
const result = await deliverFinalizableLivePreview<
const result = await deliverWithFinalizableLivePreviewAdapter<
LivePreviewMediaPayload,
string,
LivePreviewMediaEdit
>({
kind: "final",
payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" },
draft: createDraft("preview-1", { seal: vi.fn(async () => undefined) }),
buildFinalEdit: (payload) => ({ text: payload.text }),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
editFinal,
adapter: {
draft: createDraft("preview-1", { seal: vi.fn(async () => undefined) }),
buildFinalEdit: (payload) => ({ text: payload.text }),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
editFinal,
deliverSupplemental,
},
deliverNormally,
deliverSupplemental,
});
expect(result.kind).toBe("preview-finalized");
@ -109,18 +115,20 @@ describe("message lifecycle primitives", () => {
const deliverNormally = vi.fn(async () => true);
const deliverSupplemental = vi.fn(async () => false);
const result = await deliverFinalizableLivePreview<
const result = await deliverWithFinalizableLivePreviewAdapter<
LivePreviewMediaPayload,
string,
LivePreviewMediaEdit
>({
kind: "final",
payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" },
draft: createDraft("preview-supplement-fallback"),
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
deliverSupplemental,
adapter: {
draft: createDraft("preview-supplement-fallback"),
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
deliverSupplemental,
},
deliverNormally,
});
@ -132,17 +140,19 @@ describe("message lifecycle primitives", () => {
it("uses normal delivery when a finalized preview has no supplemental sender", async () => {
const deliverNormally = vi.fn(async () => true);
const result = await deliverFinalizableLivePreview<
const result = await deliverWithFinalizableLivePreviewAdapter<
LivePreviewMediaPayload,
string,
LivePreviewMediaEdit
>({
kind: "final",
payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" },
draft: createDraft("preview-no-supplement-sender"),
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
adapter: {
draft: createDraft("preview-no-supplement-sender"),
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
},
deliverNormally,
});
@ -152,47 +162,65 @@ describe("message lifecycle primitives", () => {
it("surfaces supplemental delivery failure after both sender paths report no send", async () => {
await expect(
deliverFinalizableLivePreview<LivePreviewMediaPayload, string, LivePreviewMediaEdit>({
deliverWithFinalizableLivePreviewAdapter<
LivePreviewMediaPayload,
string,
LivePreviewMediaEdit
>({
kind: "final",
payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" },
draft: createDraft("preview-supplement-unsent"),
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
deliverSupplemental: vi.fn(async () => false),
adapter: {
draft: createDraft("preview-supplement-unsent"),
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
deliverSupplemental: vi.fn(async () => false),
},
deliverNormally: vi.fn(async () => false),
}),
).rejects.toThrow("Live preview supplemental payload was not delivered");
});
it("treats live preview fallback delivery as terminal state", async () => {
const discardPending = vi.fn(async () => undefined);
const clear = vi.fn(async () => undefined);
const deliverNormally = vi.fn(async () => true);
const onNormalDelivered = vi.fn(async () => undefined);
it.each(["non-finalizable payload", "failed preview edit"] as const)(
"treats live preview fallback delivery as terminal state after a %s",
async (reason) => {
const discardPending = vi.fn(async () => undefined);
const clear = vi.fn(async () => undefined);
const deliverNormally = vi.fn(async () => true);
const onNormalDelivered = vi.fn(async () => undefined);
const draft = createDraft("preview-2", { discardPending, clear });
const editFails = reason === "failed preview edit";
const editFinal = vi.fn(async () => {
throw new Error("preview edit failed");
});
const result = await deliverFinalizableLivePreview({
kind: "final",
payload: { text: "with media" },
draft: createDraft("preview-2", { discardPending, clear }),
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally,
onNormalDelivered,
});
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "with media" },
adapter: {
draft,
buildFinalEdit: (payload) => (editFails ? { text: payload.text } : undefined),
editFinal,
},
deliverNormally,
onNormalDelivered,
});
expect(result.kind).toBe("normal-delivered");
expect(discardPending).toHaveBeenCalledTimes(1);
expect(deliverNormally).toHaveBeenCalledWith({ text: "with media" });
expect(onNormalDelivered).toHaveBeenCalledTimes(1);
expect(clear).toHaveBeenCalledTimes(1);
const liveState = result.liveState;
if (!liveState) {
throw new Error("expected fallback live state");
}
expect(liveState.phase).toBe("cancelled");
expect(liveState.canFinalizeInPlace).toBe(false);
});
expect(result.kind).toBe("normal-delivered");
expect(draft.flush).toHaveBeenCalledTimes(editFails ? 1 : 0);
expect(editFinal).toHaveBeenCalledTimes(editFails ? 1 : 0);
expect(discardPending).toHaveBeenCalledTimes(1);
expect(deliverNormally).toHaveBeenCalledWith({ text: "with media" });
expect(onNormalDelivered).toHaveBeenCalledTimes(1);
expect(clear).toHaveBeenCalledTimes(1);
const liveState = result.liveState;
if (!liveState) {
throw new Error("expected fallback live state");
}
expect(liveState.phase).toBe("cancelled");
expect(liveState.canFinalizeInPlace).toBe(false);
},
);
it("preserves committed normal delivery when preview cleanup fails through the adapter", async () => {
const events: string[] = [];
@ -248,17 +276,19 @@ describe("message lifecycle primitives", () => {
});
const onNormalDelivered = vi.fn(async () => undefined);
const result = await deliverFinalizableLivePreview({
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "suppressed" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "suppressed-preview",
discardPending: vi.fn(async () => undefined),
clear,
adapter: {
draft: {
flush: vi.fn(async () => undefined),
id: () => "suppressed-preview",
discardPending: vi.fn(async () => undefined),
clear,
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally: vi.fn(async () => false),
onNormalDelivered,
});
@ -275,18 +305,20 @@ describe("message lifecycle primitives", () => {
try {
await expect(
deliverFinalizableLivePreview({
deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "not delivered" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "pre-delivery-preview",
clear: vi.fn(async () => {
throw new Error("pre-delivery cleanup failed");
}),
adapter: {
draft: {
flush: vi.fn(async () => undefined),
id: () => "pre-delivery-preview",
clear: vi.fn(async () => {
throw new Error("pre-delivery cleanup failed");
}),
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally,
onNormalDelivered,
}),
@ -305,19 +337,21 @@ describe("message lifecycle primitives", () => {
try {
await expect(
deliverFinalizableLivePreview({
deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "already accepted" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "commit-failure-preview",
discardPending: vi.fn(async () => undefined),
clear: vi.fn(async () => {
throw new Error("preview cleanup must not replace delivery failure");
}),
adapter: {
draft: {
flush: vi.fn(async () => undefined),
id: () => "commit-failure-preview",
discardPending: vi.fn(async () => undefined),
clear: vi.fn(async () => {
throw new Error("preview cleanup must not replace delivery failure");
}),
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally: vi.fn(async () => true),
onNormalDelivered: vi.fn(async () => {
throw new Error("delivery commit failed");
@ -337,12 +371,14 @@ describe("message lifecycle primitives", () => {
const onNormalDelivered = vi.fn(async () => undefined);
await expect(
deliverFinalizableLivePreview({
deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "with media" },
draft: createDraft("preview-2", { discardPending, clear }),
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
adapter: {
draft: createDraft("preview-2", { discardPending, clear }),
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
},
deliverNormally: vi.fn(async () => {
throw new Error("send failed");
}),
@ -446,14 +482,18 @@ describe("message lifecycle primitives", () => {
const editFinal = vi.fn(async () => undefined);
await expect(
deliverFinalizableLivePreview({
deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "done" },
draft: createDraft("preview-finalized-before-hook", { seal: vi.fn(async () => undefined) }),
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal,
adapter: {
draft: createDraft("preview-finalized-before-hook", {
seal: vi.fn(async () => undefined),
}),
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal,
onPreviewFinalized,
},
deliverNormally,
onPreviewFinalized,
}),
).rejects.toThrow("receipt side effect failed");

View file

@ -8,7 +8,7 @@ import {
import type { LiveMessageState, MessageReceipt, RenderedMessageBatch } from "./types.js";
/** A transport-owned preview. discardPending must stop new work before awaiting in-flight work. */
export type LivePreviewFinalizerDraft<TId> = {
type LivePreviewFinalizerDraft<TId> = {
flush: () => Promise<void>;
id: () => TId | undefined;
seal?: () => Promise<void>;
@ -23,7 +23,7 @@ export type LivePreviewDraft<TId> = Omit<LivePreviewFinalizerDraft<TId>, "clear"
export type LivePreviewDeliveryResult = ChannelDeliveryResult & { visibleReplySent: boolean };
type PreviewSendResult = LivePreviewDeliveryResult | boolean | void;
export type LivePreviewFinalizerResultKind =
type LivePreviewFinalizerResultKind =
| "normal-delivered"
| "normal-skipped"
| "preview-finalized"
@ -82,18 +82,6 @@ type FinalizableLivePreviewAdapter<TPayload, TId, TEdit> = Omit<
deliverSupplemental?: (payload: TPayload) => Promise<PreviewSendResult>;
};
type PublishedPreviewDeliveryParams<TPayload, TId, TEdit> = PublishedPreviewAdapter<
TPayload,
TId,
TEdit
> & {
kind: "tool" | "block" | "final";
payload: TPayload;
liveState?: LiveMessageState<TPayload>;
deliverNormally: (payload: TPayload) => Promise<boolean | void>;
onNormalDelivered?: () => Promise<void> | void;
};
type PreviewDeliveryOwner<TPayload> = {
isCurrent: () => boolean;
update: (state: LiveMessageState<TPayload>) => void;
@ -362,13 +350,6 @@ async function deliverPreview<TPayload, TId, TEdit>(
}
}
/** Published stateless contract. Bundled channels use createLivePreviewLifecycle. */
export async function deliverFinalizableLivePreview<TPayload, TId, TEdit>(
params: PublishedPreviewDeliveryParams<TPayload, TId, TEdit>,
): Promise<LivePreviewFinalizerResult<TPayload>> {
return await deliverPreview(params);
}
/** Published adapter contract; shares the stateful owner's delivery implementation. */
export async function deliverWithFinalizableLivePreviewAdapter<TPayload, TId, TEdit>(params: {
kind: "tool" | "block" | "final";

View file

@ -16,7 +16,8 @@ import {
import { replaceSessionEntrySync } from "../config/sessions/session-accessor.sqlite-entry.js";
import { withTempHomeConfig, writeOpenClawConfig } from "../config/test-helpers.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadExecApprovals, saveExecApprovals } from "../infra/exec-approvals.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.test-support.js";
import { loadExecApprovals } from "../infra/exec-approvals.js";
import { resolveRuntimeWorkerArgv, resolveRuntimeWorkerUrl } from "../infra/runtime-worker-url.js";
import { onSessionIdentityMutation } from "../sessions/session-lifecycle-events.js";
import {

View file

@ -11,7 +11,8 @@ import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { makeCronJob } from "../cron/delivery.test-helpers.js";
import { loadCronStore, resolveCronJobsStorePath, saveCronStore } from "../cron/store.js";
import { readExecApprovalsSnapshot, saveExecApprovals } from "../infra/exec-approvals.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.test-support.js";
import { readExecApprovalsSnapshot } from "../infra/exec-approvals.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { readAgentDeletionJournal } from "../state/agent-deletion-journal.js";
import { readAgentProvenance, recordAgentProvenance } from "../state/agent-provenance.js";

View file

@ -7,8 +7,10 @@ import type { OpenClawConfig } from "../config/config.js";
import { REDACTED_SENTINEL } from "../config/redact-snapshot.js";
import type { ModelProviderConfig } from "../config/types.models.js";
import type { ExecApprovalsFile } from "../infra/exec-approvals-core.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.js";
import { testing as execApprovalsStoreTesting } from "../infra/exec-approvals-store.test-support.js";
import {
saveExecApprovals,
testing as execApprovalsStoreTesting,
} from "../infra/exec-approvals-store.test-support.js";
import * as auditStore from "../secrets/audit-store.js";
import { runSecretsAudit } from "../secrets/audit.js";
import { readSecretStoreValue, writeSecretStoreEntry } from "../secrets/store/secret-store.js";

View file

@ -4,7 +4,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { hasErrnoCode } from "../infra/errno.js";
import * as fileLocks from "../infra/file-lock.js";
import * as fileLocks from "../plugin-sdk/file-lock.js";
import {
captureConfigWriteLockGuard,
markActiveConfigMutationPath,

View file

@ -3,8 +3,10 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExecApprovalsFile } from "../infra/exec-approvals-core.js";
import { saveExecApprovals } from "../infra/exec-approvals-store.js";
import { testing as execApprovalsStoreTesting } from "../infra/exec-approvals-store.test-support.js";
import {
saveExecApprovals,
testing as execApprovalsStoreTesting,
} from "../infra/exec-approvals-store.test-support.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";

View file

@ -1,13 +1,10 @@
import { describe, expect, it } from "vitest";
import {
onDiagnosticEvent,
type DiagnosticPayloadLargeEvent,
} from "../../infra/diagnostic-events.js";
import { onDiagnosticEvent, type DiagnosticEventPayload } from "../../infra/diagnostic-events.js";
import { reportOmittedChatHistory } from "./chat-history-budget.js";
import { prepareChatHistoryResponsePage } from "./chat-history-response-page.js";
function runHistoryBudgetPipeline(messages: unknown[], maxHistoryBytes: number) {
const events: DiagnosticPayloadLargeEvent[] = [];
const events: Extract<DiagnosticEventPayload, { type: "payload.large" }>[] = [];
const unsubscribe = onDiagnosticEvent((event) => {
if (event.type === "payload.large") {
events.push(event);

View file

@ -5,10 +5,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, test } from "vitest";
import type { WebSocket } from "ws";
import { appendTranscriptMessageSync } from "../../config/sessions/session-accessor.js";
import {
onDiagnosticEvent,
type DiagnosticPayloadLargeEvent,
} from "../../infra/diagnostic-events.js";
import { onDiagnosticEvent, type DiagnosticEventPayload } from "../../infra/diagnostic-events.js";
import { getMaxChatHistoryMessagesBytes } from "../server-constants.js";
import { installGatewayTestHooks, rpcReq, testState, writeSessionStore } from "../test-helpers.js";
import { installConnectedControlUiServerSuite } from "../test-with-server.js";
@ -28,7 +25,7 @@ describe("chat.history request truncation diagnostic", () => {
const textBytes = 100_000;
const budgetBytes = getMaxChatHistoryMessagesBytes();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chat-history-omit-"));
const captured: DiagnosticPayloadLargeEvent[] = [];
const captured: Extract<DiagnosticEventPayload, { type: "payload.large" }>[] = [];
const unsubscribe = onDiagnosticEvent((event) => {
if (event.type === "payload.large" && event.surface === "gateway.chat.history") {
captured.push(event);

View file

@ -17,10 +17,7 @@ import type {
SessionHistoryWorkerRequest,
} from "../../config/sessions/session-history-types.js";
import * as historyWorker from "../../config/sessions/session-history-worker-runtime.js";
import {
onDiagnosticEvent,
type DiagnosticPayloadLargeEvent,
} from "../../infra/diagnostic-events.js";
import { onDiagnosticEvent, type DiagnosticEventPayload } from "../../infra/diagnostic-events.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import { SerializedJsonArray, serializeGatewayFrame } from "../serialized-json.js";
import {
@ -348,7 +345,7 @@ it("forwards large worker history as text JSON while preserving object callers a
materialize.mockRestore();
}
expect(Array.isArray((await request(true, 200_000, "cron.history")).messages)).toBe(true);
const omissions: DiagnosticPayloadLargeEvent[] = [];
const omissions: Extract<DiagnosticEventPayload, { type: "payload.large" }>[] = [];
const stop = onDiagnosticEvent((event) => {
if (event.type === "payload.large" && event.surface === "gateway.chat.history") {
omissions.push(event);

View file

@ -2,7 +2,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
onInternalDiagnosticEvent,
type DiagnosticSecurityEvent,
type DiagnosticEventPayload,
} from "../../infra/diagnostic-events.js";
import { createDeferredCore } from "../../shared/deferred.js";
import { drainNodePendingWork, enqueueNodePendingWork } from "../node-pending-work.js";
@ -50,7 +50,7 @@ describe("device lifecycle", () => {
: "caller-missing-scope";
const wording = operation === "rotate" ? "rotation" : "revocation";
const order: string[] = [];
const events: DiagnosticSecurityEvent[] = [];
const events: Extract<DiagnosticEventPayload, { type: "security.event" }>[] = [];
const trusted: boolean[] = [];
const mutation = operation === "rotate" ? rotateDeviceTokenMock : revokeDeviceTokenMock;
if (denial === "service") {

View file

@ -2,7 +2,7 @@ import { expect, vi } from "vitest";
import {
onInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
type DiagnosticSecurityEvent,
type DiagnosticEventPayload,
} from "../../infra/diagnostic-events.js";
import { runNodeWakeAttempt, runNodeWakeNudgeAttempt } from "../node-wake-state.js";
import { resetNodeWakeStateForTest } from "../node-wake-state.test-support.js";
@ -163,10 +163,10 @@ export function expectRespondedErrorMessage(
}
export function captureSecurityEvents(): {
events: DiagnosticSecurityEvent[];
events: Extract<DiagnosticEventPayload, { type: "security.event" }>[];
stop: () => void;
} {
const events: DiagnosticSecurityEvent[] = [];
const events: Extract<DiagnosticEventPayload, { type: "security.event" }>[] = [];
const stop = onInternalDiagnosticEvent((event, metadata) => {
if (metadata.trusted && event.type === "security.event") {
events.push(event);

View file

@ -1,8 +1,7 @@
import fs from "node:fs";
/**
* Tests for relaying native hook events through gateway request handlers.
*/
import fs from "node:fs";
import { Server } from "node:http";
import path from "node:path";
import { setImmediate } from "node:timers/promises";
@ -17,7 +16,7 @@ import {
import { resolveExecApprovalsPath } from "../../infra/exec-approvals-config.js";
import * as mcpGrants from "../../infra/exec-approvals-mcp.js";
import { ExecApprovalsMigrationRequiredError } from "../../infra/exec-approvals-migration-gate.js";
import { saveExecApprovals } from "../../infra/exec-approvals-store.js";
import { saveExecApprovals } from "../../infra/exec-approvals-store.test-support.js";
import { createDeferredCore } from "../../shared/deferred.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";

View file

@ -21,7 +21,7 @@ import {
import {
onInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
type DiagnosticSecurityEvent,
type DiagnosticEventPayload,
} from "../../infra/diagnostic-events.js";
import { NODE_WORKER_SUPERVISOR_PROTOCOL_FEATURE } from "../../infra/node-runner-inventory.js";
import { loadApnsRegistration, registerApnsRegistration } from "../../infra/push-apns.js";
@ -122,10 +122,10 @@ afterEach(async () => {
});
function captureSecurityEvents(): {
events: DiagnosticSecurityEvent[];
events: Extract<DiagnosticEventPayload, { type: "security.event" }>[];
stop: () => void;
} {
const events: DiagnosticSecurityEvent[] = [];
const events: Extract<DiagnosticEventPayload, { type: "security.event" }>[] = [];
const stop = onInternalDiagnosticEvent((event, metadata) => {
if (metadata.trusted && event.type === "security.event") {
events.push(event);

View file

@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import { resetAgentEventsForTest } from "../infra/agent-events.js";
import { PROXY_ENV_KEYS } from "../infra/net/proxy-env.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { startGatewayServer } from "./server.js";
import { getGatewayE2ePortBlock } from "./test-helpers.e2e.js";
@ -27,7 +26,12 @@ const NETWORK_GATEWAY_ENV_KEYS = [
"OPENCLAW_SKIP_PROVIDERS",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_TEST_MINIMAL_GATEWAY",
...PROXY_ENV_KEYS,
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"NO_PROXY",
"no_proxy",
] as const;

View file

@ -47,7 +47,7 @@ import { waitForSessionTranscriptIndexReconcile } from "../config/sessions/sessi
import type { AgentModelConfig } from "../config/types.agents-shared.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { rotateAgentEventLifecycleGeneration } from "../infra/agent-events.js";
import { onDiagnosticEvent, type DiagnosticPayloadLargeEvent } from "../infra/diagnostic-events.js";
import { onDiagnosticEvent, type DiagnosticEventPayload } from "../infra/diagnostic-events.js";
import { readPersistedMediaFacts } from "../media/media-facts.js";
import { resolveMediaReferenceLocalPath } from "../media/media-reference.js";
import { getMediaDir } from "../media/store.js";
@ -6012,7 +6012,7 @@ describe("gateway server chat", () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await prepareMainHistoryHarness({ ws, createSessionDir });
const projectedSiblingCount = 70;
const captured: DiagnosticPayloadLargeEvent[] = [];
const captured: Extract<DiagnosticEventPayload, { type: "payload.large" }>[] = [];
const unsubscribe = onDiagnosticEvent((event) => {
if (event.type === "payload.large" && event.surface === "gateway.chat.history") {
captured.push(event);

View file

@ -6,7 +6,7 @@ import { createDeferred } from "../../../../test/helpers/promise.js";
import { prepareSystemAgentRunAdmission } from "../../../agents/admitted-run-context.js";
import {
onInternalDiagnosticEvent,
type DiagnosticSecurityEvent,
type DiagnosticEventPayload,
} from "../../../infra/diagnostic-events.js";
import { withOpenClawTestState } from "../../../test-utils/openclaw-test-state.js";
import { mintAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js";
@ -48,10 +48,10 @@ export const BACKEND_CONNECT_PARAMS = {
} as const satisfies Record<string, unknown>;
export function captureSecurityEvents(): {
events: DiagnosticSecurityEvent[];
events: Extract<DiagnosticEventPayload, { type: "security.event" }>[];
stop: () => void;
} {
const events: DiagnosticSecurityEvent[] = [];
const events: Extract<DiagnosticEventPayload, { type: "security.event" }>[] = [];
const stop = onInternalDiagnosticEvent((event, metadata) => {
if (metadata.trusted && event.type === "security.event") {
events.push(event);

View file

@ -1,4 +1,4 @@
import { afterAll } from "vitest";
import { afterAll, expect } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import * as execApprovalsStore from "../infra/exec-approvals-store.js";
import { withEnvAsync } from "../test-utils/env.js";
@ -21,8 +21,14 @@ export async function withAgentPermissionState<T>(fn: () => Promise<T>): Promise
// Permission rows share empty provenance; only their persisted approval policy varies.
agentPermissionStateDir ??= agentPermissionDirs.make("openclaw-agent-permission-");
return withEnvAsync({ OPENCLAW_STATE_DIR: agentPermissionStateDir }, async () => {
pristineAgentApprovals ??= execApprovalsStore.readExecApprovalsSnapshot();
execApprovalsStore.restoreExecApprovalsSnapshot(pristineAgentApprovals);
const current = execApprovalsStore.readExecApprovalsSnapshot();
pristineAgentApprovals ??= current;
expect(
await execApprovalsStore.restoreExecApprovalsSnapshotLocked(
pristineAgentApprovals,
current.hash,
),
).toBe(true);
return fn();
});
}

View file

@ -1,7 +1,7 @@
import fs from "node:fs";
// Session utility tests cover key parsing, store migration, agent/default rows,
// model identity resolution, title derivation, and byte-capped row payloads.
import "./session-utils-provider.test-support.js";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
@ -2440,7 +2440,7 @@ describe("gateway session utils", () => {
},
])("listAgentsForGateway never overstates $name", async ({ cfg, approvals, expected }) => {
await withAgentPermissionState(async () => {
execApprovalsStore.saveExecApprovals(approvals);
execApprovalsStore.updateExecApprovalsSync({ update: () => approvals });
const agent = (await listAgentsForGateway(cfg)).agents.find((entry) => entry.id === "main");
expect(agent).toBeDefined();
if (expected === undefined) {

View file

@ -2,10 +2,7 @@
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ChannelApprovalNativeAdapter } from "../channels/plugins/types.adapters.js";
import {
createChannelNativeApprovalRuntime as createChannelNativeApprovalRuntimeRaw,
deliverApprovalRequestViaChannelNativePlan,
} from "./approval-native-runtime.js";
import { createChannelNativeApprovalRuntime as createChannelNativeApprovalRuntimeRaw } from "./approval-native-runtime.js";
const hoisted = vi.hoisted(() => ({
callGatewayLeastPrivilege: vi.fn(async () => ({ ok: true })),
@ -70,8 +67,9 @@ function mockCallArg(mock: ReturnType<typeof vi.fn>, index = 0): Record<string,
return requireRecord(arg);
}
describe("deliverApprovalRequestViaChannelNativePlan", () => {
describe("createChannelNativeApprovalRuntime", () => {
it("dedupes converged prepared targets", async () => {
vi.useFakeTimers();
const adapter: ChannelApprovalNativeAdapter = {
describeDeliveryCapabilities: () => ({
enabled: true,
@ -105,25 +103,35 @@ describe("deliverApprovalRequestViaChannelNativePlan", () => {
}),
);
const onDuplicateSkipped = vi.fn();
const finalizeResolved = vi.fn(async () => undefined);
const result = await deliverApprovalRequestViaChannelNativePlan({
const runtime = createChannelNativeApprovalRuntime({
label: "test/native-runtime-dedupe",
clientDisplayName: "Test",
cfg: {} as never,
approvalKind: "exec",
request: execRequest,
adapter,
nowMs: () => 0,
nativeAdapter: adapter,
isConfigured: () => true,
shouldHandle: () => true,
buildPendingContent: () => "pending exec",
prepareTarget,
deliverTarget,
onDuplicateSkipped,
finalizeResolved,
});
await runtime.handleRequested(execRequest);
await runtime.handleResolved({ id: execRequest.id, decision: "allow-once", ts: 1 });
expect(prepareTarget).toHaveBeenCalledTimes(2);
expect(deliverTarget).toHaveBeenCalledTimes(1);
expect(onDuplicateSkipped).toHaveBeenCalledTimes(1);
expect(result.entries).toEqual([{ channelId: "shared-dm" }]);
expect(result.deliveryPlan.notifyOriginWhenDmOnly).toBe(true);
expect(finalizeResolved).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ entries: [{ channelId: "shared-dm" }] }),
);
});
it("continues after per-target delivery failures", async () => {
vi.useFakeTimers();
const adapter: ChannelApprovalNativeAdapter = {
describeDeliveryCapabilities: () => ({
enabled: true,
@ -134,31 +142,42 @@ describe("deliverApprovalRequestViaChannelNativePlan", () => {
resolveApproverDmTargets: async () => [{ to: "approver-1" }, { to: "approver-2" }],
};
const onDeliveryError = vi.fn();
const result = await deliverApprovalRequestViaChannelNativePlan({
cfg: {} as never,
approvalKind: "exec",
request: execRequest,
adapter,
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: plannedTarget.target.to,
target: { channelId: plannedTarget.target.to },
}),
deliverTarget: async ({ preparedTarget }) => {
const finalizeResolved = vi.fn(async () => undefined);
const deliverTarget = vi
.fn()
.mockImplementation(async ({ preparedTarget }: { preparedTarget: { channelId: string } }) => {
if (preparedTarget.channelId === "approver-1") {
throw new Error("boom");
}
return { channelId: preparedTarget.channelId };
},
});
const runtime = createChannelNativeApprovalRuntime({
label: "test/native-runtime-delivery-failure",
clientDisplayName: "Test",
cfg: {} as never,
nowMs: () => 0,
nativeAdapter: adapter,
isConfigured: () => true,
shouldHandle: () => true,
buildPendingContent: () => "pending exec",
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: plannedTarget.target.to,
target: { channelId: plannedTarget.target.to },
}),
deliverTarget,
onDeliveryError,
finalizeResolved,
});
await runtime.handleRequested(execRequest);
await runtime.handleResolved({ id: execRequest.id, decision: "allow-once", ts: 1 });
expect(onDeliveryError).toHaveBeenCalledTimes(1);
expect(result.entries).toEqual([{ channelId: "approver-2" }]);
expect(finalizeResolved).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ entries: [{ channelId: "approver-2" }] }),
);
});
});
describe("createChannelNativeApprovalRuntime", () => {
it("selects and expires system-agent approval targets through the native lifecycle", async () => {
const deliverTarget = vi.fn().mockResolvedValue({ chatId: "123", messageId: "m1" });
const finalizeExpired = vi.fn().mockResolvedValue(undefined);

View file

@ -29,8 +29,6 @@ import {
type ApprovalRequest = ApprovalRequestInput;
export type { PreparedChannelNativeApprovalTarget } from "./approval-native-runtime-types.js";
type ChannelNativeApprovalPlanDeliveryResult<TPendingEntry> = {
entries: TPendingEntry[];
deliveryPlan: ChannelApprovalNativeDeliveryPlan;
@ -38,7 +36,7 @@ type ChannelNativeApprovalPlanDeliveryResult<TPendingEntry> = {
};
/** Delivers an approval request to the adapter-planned native targets and returns pending entries. */
export async function deliverApprovalRequestViaChannelNativePlan<
async function deliverApprovalRequestViaChannelNativePlan<
TPreparedTarget,
TPendingEntry,
TRequest extends ApprovalRequest = ApprovalRequest,

View file

@ -1,7 +1,6 @@
// Canonical durable approval presentation safety tests.
import { describe, expect, it } from "vitest";
import { buildApprovalPresentation } from "./approval-presentation.js";
import { PLUGIN_APPROVAL_DETAIL_MAX_LENGTH } from "./plugin-approvals.js";
const allowedDecisions = ["allow-once", "deny"] as const;
@ -158,7 +157,7 @@ describe("buildApprovalPresentation", () => {
const presentation = buildPluginPresentation({
title: "Review tool input",
description: "Bounded channel summary",
detail: "x".repeat(PLUGIN_APPROVAL_DETAIL_MAX_LENGTH + 1),
detail: "x".repeat(16_385),
});
expect(presentation).toMatchObject({
@ -168,7 +167,7 @@ describe("buildApprovalPresentation", () => {
if (presentation?.kind !== "plugin" || !presentation.detail) {
throw new Error("expected plugin detail");
}
expect(Array.from(presentation.detail)).toHaveLength(PLUGIN_APPROVAL_DETAIL_MAX_LENGTH);
expect(Array.from(presentation.detail)).toHaveLength(16_384);
});
it("projects only bounded reviewer-safe external verification metadata", () => {

View file

@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import { resolveArchiveKind, resolvePackedRootDir } from "./archive.js";
import { pathExists, withTimeout } from "./fs-safe.js";
import { JsonFileReadError, readJsonFileStrict } from "./json-files.js";
import { JsonFileReadError, readJson } from "./json-files.js";
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-archive-helper-test-");
@ -100,8 +100,8 @@ describe("archive helpers", () => {
await fs.writeFile(jsonPath, '{"ok":true}', "utf8");
await fs.writeFile(badPath, "{not json", "utf8");
await expect(readJsonFileStrict<{ ok: boolean }>(jsonPath)).resolves.toEqual({ ok: true });
await expect(readJsonFileStrict(badPath)).rejects.toBeInstanceOf(JsonFileReadError);
await expect(readJson<{ ok: boolean }>(jsonPath)).resolves.toEqual({ ok: true });
await expect(readJson(badPath)).rejects.toBeInstanceOf(JsonFileReadError);
await expect(pathExists(jsonPath)).resolves.toBe(true);
await expect(pathExists(path.join(dir, "missing.json"))).resolves.toBe(false);
});

View file

@ -1,7 +1,7 @@
// Covers platform browser-open command resolution.
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SpawnResult } from "../process/exec-result.js";
import { mockProcessPlatform } from "../test-utils/vitest-spies.js";
@ -45,11 +45,17 @@ vi.mock("node:fs/promises", async () => {
};
});
import { detectBrowserOpenSupport, openUrl, resolveBrowserOpenCommand } from "./browser-open.js";
import { resetWSLStateForTests } from "./wsl.js";
let detectBrowserOpenSupport: typeof import("./browser-open.js").detectBrowserOpenSupport;
let openUrl: typeof import("./browser-open.js").openUrl;
let resolveBrowserOpenCommand: typeof import("./browser-open.js").resolveBrowserOpenCommand;
beforeEach(async () => {
vi.resetModules();
({ detectBrowserOpenSupport, openUrl, resolveBrowserOpenCommand } =
await import("./browser-open.js"));
});
afterEach(() => {
resetWSLStateForTests();
vi.restoreAllMocks();
vi.unstubAllEnvs();
detectBinaryMock.mockReset().mockResolvedValue(false);

View file

@ -20,9 +20,6 @@ export type DedupeCacheOptions = {
maxSize: number;
};
/** @deprecated Use resolveNonNegativeIntegerOption for new internal numeric option normalization. */
export { resolveNonNegativeIntegerOption as resolveDedupeNonNegativeInteger };
/** Creates a bounded in-memory dedupe cache with optional TTL expiry. */
export function createDedupeCache(options: DedupeCacheOptions): DedupeCache {
const ttlMs = resolveNonNegativeIntegerOption(options.ttlMs, 0);

View file

@ -90,7 +90,7 @@ type DiagnosticGatewayRpcEvent = DiagnosticBaseEvent & {
}
);
export type DiagnosticUsageEvent = DiagnosticSessionEvent & {
type DiagnosticUsageEvent = DiagnosticSessionEvent & {
type: "model.usage";
channel?: string;
agentId?: string;
@ -131,7 +131,7 @@ export type DiagnosticFailoverEvent = DiagnosticSessionEvent & {
suspended?: boolean;
};
export type DiagnosticSecurityEventActor = {
type DiagnosticSecurityEventActor = {
kind: "operator" | "node" | "agent" | "plugin" | "channel_sender" | "system";
idHash?: string;
deviceIdHash?: string;
@ -140,7 +140,7 @@ export type DiagnosticSecurityEventActor = {
scopes?: string[];
};
export type DiagnosticSecurityEventTarget = {
type DiagnosticSecurityEventTarget = {
kind:
| "gateway"
| "device"
@ -156,18 +156,18 @@ export type DiagnosticSecurityEventTarget = {
owner?: string;
};
export type DiagnosticSecurityEventPolicy = {
type DiagnosticSecurityEventPolicy = {
id?: string;
decision?: "allow" | "deny" | "ask" | "auto" | "full" | "not_applicable";
reason?: string;
};
export type DiagnosticSecurityEventControl = {
type DiagnosticSecurityEventControl = {
id?: string;
family?: "auth" | "authorization" | "approval" | "sandbox" | "secret" | "supply_chain";
};
export type DiagnosticSecurityEvent = DiagnosticBaseEvent & {
type DiagnosticSecurityEvent = DiagnosticBaseEvent & {
type: "security.event";
eventId: string;
category:
@ -198,14 +198,14 @@ export type DiagnosticSecurityEventInput = Omit<
eventId?: string;
};
export type DiagnosticWebhookReceivedEvent = DiagnosticBaseEvent & {
type DiagnosticWebhookReceivedEvent = DiagnosticBaseEvent & {
type: "webhook.received";
channel: string;
updateType?: string;
chatId?: number | string;
};
export type DiagnosticWebhookProcessedEvent = DiagnosticBaseEvent & {
type DiagnosticWebhookProcessedEvent = DiagnosticBaseEvent & {
type: "webhook.processed";
channel: string;
updateType?: string;
@ -213,7 +213,7 @@ export type DiagnosticWebhookProcessedEvent = DiagnosticBaseEvent & {
durationMs?: number;
};
export type DiagnosticWebhookErrorEvent = DiagnosticBaseEvent & {
type DiagnosticWebhookErrorEvent = DiagnosticBaseEvent & {
type: "webhook.error";
channel: string;
updateType?: string;
@ -221,14 +221,14 @@ export type DiagnosticWebhookErrorEvent = DiagnosticBaseEvent & {
error: string;
};
export type DiagnosticMessageQueuedEvent = DiagnosticSessionEvent & {
type DiagnosticMessageQueuedEvent = DiagnosticSessionEvent & {
type: "message.queued";
channel?: string;
source: string;
queueDepth?: number;
};
export type DiagnosticMessageReceivedEvent = DiagnosticSessionEvent & {
type DiagnosticMessageReceivedEvent = DiagnosticSessionEvent & {
type: "message.received";
channel?: string;
messageId?: number | string;
@ -236,13 +236,13 @@ export type DiagnosticMessageReceivedEvent = DiagnosticSessionEvent & {
source: string;
};
export type DiagnosticMessageDispatchStartedEvent = DiagnosticSessionEvent & {
type DiagnosticMessageDispatchStartedEvent = DiagnosticSessionEvent & {
type: "message.dispatch.started";
channel?: string;
source: string;
};
export type DiagnosticMessageDispatchCompletedEvent = DiagnosticSessionEvent & {
type DiagnosticMessageDispatchCompletedEvent = DiagnosticSessionEvent & {
type: "message.dispatch.completed";
channel?: string;
source: string;
@ -252,7 +252,7 @@ export type DiagnosticMessageDispatchCompletedEvent = DiagnosticSessionEvent & {
error?: string;
};
export type DiagnosticMessageProcessedEvent = DiagnosticSessionEvent & {
type DiagnosticMessageProcessedEvent = DiagnosticSessionEvent & {
type: "message.processed";
channel: string;
messageId?: number | string;
@ -272,23 +272,23 @@ type DiagnosticMessageDeliveryBaseEvent = DiagnosticBaseEvent & {
deliveryKind: DiagnosticMessageDeliveryKind;
};
export type DiagnosticMessageDeliveryStartedEvent = DiagnosticMessageDeliveryBaseEvent & {
type DiagnosticMessageDeliveryStartedEvent = DiagnosticMessageDeliveryBaseEvent & {
type: "message.delivery.started";
};
export type DiagnosticMessageDeliveryCompletedEvent = DiagnosticMessageDeliveryBaseEvent & {
type DiagnosticMessageDeliveryCompletedEvent = DiagnosticMessageDeliveryBaseEvent & {
type: "message.delivery.completed";
durationMs: number;
resultCount: number;
};
export type DiagnosticMessageDeliveryErrorEvent = DiagnosticMessageDeliveryBaseEvent & {
type DiagnosticMessageDeliveryErrorEvent = DiagnosticMessageDeliveryBaseEvent & {
type: "message.delivery.error";
durationMs: number;
errorCategory: string;
};
export type DiagnosticTalkEvent = DiagnosticBaseEvent & {
type DiagnosticTalkEvent = DiagnosticBaseEvent & {
type: "talk.event";
sessionId?: string;
turnId?: string;
@ -303,7 +303,7 @@ export type DiagnosticTalkEvent = DiagnosticBaseEvent & {
byteLength?: number;
};
export type DiagnosticSessionStateEvent = DiagnosticSessionEvent & {
type DiagnosticSessionStateEvent = DiagnosticSessionEvent & {
type: "session.state";
prevState?: DiagnosticSessionState;
state: DiagnosticSessionState;
@ -313,7 +313,7 @@ export type DiagnosticSessionStateEvent = DiagnosticSessionEvent & {
export type DiagnosticSessionActiveWorkKind = "embedded_run" | "model_call" | "tool_call";
export type DiagnosticSessionAttentionClassification =
type DiagnosticSessionAttentionClassification =
| "long_running"
| "blocked_tool_call"
| "stalled_agent_run"
@ -335,27 +335,22 @@ type DiagnosticSessionAttentionBaseEvent = DiagnosticSessionEvent & {
terminalProgressStale?: boolean;
};
export type DiagnosticSessionLongRunningEvent = DiagnosticSessionAttentionBaseEvent & {
type DiagnosticSessionLongRunningEvent = DiagnosticSessionAttentionBaseEvent & {
type: "session.long_running";
classification: "long_running";
};
export type DiagnosticSessionStalledEvent = DiagnosticSessionAttentionBaseEvent & {
type DiagnosticSessionStalledEvent = DiagnosticSessionAttentionBaseEvent & {
type: "session.stalled";
classification: "blocked_tool_call" | "stalled_agent_run";
};
export type DiagnosticSessionStuckEvent = DiagnosticSessionAttentionBaseEvent & {
type DiagnosticSessionStuckEvent = DiagnosticSessionAttentionBaseEvent & {
type: "session.stuck";
classification: "stale_session_state";
};
export type DiagnosticSessionRecoveryStatus =
| "aborted"
| "released"
| "skipped"
| "noop"
| "failed";
type DiagnosticSessionRecoveryStatus = "aborted" | "released" | "skipped" | "noop" | "failed";
type DiagnosticSessionRecoveryBaseEvent = DiagnosticSessionEvent & {
state: DiagnosticSessionState;
@ -367,11 +362,11 @@ type DiagnosticSessionRecoveryBaseEvent = DiagnosticSessionEvent & {
allowActiveAbort?: boolean;
};
export type DiagnosticSessionRecoveryRequestedEvent = DiagnosticSessionRecoveryBaseEvent & {
type DiagnosticSessionRecoveryRequestedEvent = DiagnosticSessionRecoveryBaseEvent & {
type: "session.recovery.requested";
};
export type DiagnosticSessionRecoveryCompletedEvent = DiagnosticSessionRecoveryBaseEvent & {
type DiagnosticSessionRecoveryCompletedEvent = DiagnosticSessionRecoveryBaseEvent & {
type: "session.recovery.completed";
status: DiagnosticSessionRecoveryStatus;
action: string;
@ -380,7 +375,7 @@ export type DiagnosticSessionRecoveryCompletedEvent = DiagnosticSessionRecoveryB
stale?: boolean;
};
export type DiagnosticSessionTurnCreatedEvent = DiagnosticSessionEvent & {
type DiagnosticSessionTurnCreatedEvent = DiagnosticSessionEvent & {
type: "session.turn.created";
runId: string;
agentId?: string;
@ -388,26 +383,26 @@ export type DiagnosticSessionTurnCreatedEvent = DiagnosticSessionEvent & {
trigger: "user" | "heartbeat";
};
export type DiagnosticLaneEnqueueEvent = DiagnosticBaseEvent & {
type DiagnosticLaneEnqueueEvent = DiagnosticBaseEvent & {
type: "queue.lane.enqueue";
lane: string;
queueSize: number;
};
export type DiagnosticLaneDequeueEvent = DiagnosticBaseEvent & {
type DiagnosticLaneDequeueEvent = DiagnosticBaseEvent & {
type: "queue.lane.dequeue";
lane: string;
queueSize: number;
waitMs: number;
};
export type DiagnosticRunAttemptEvent = DiagnosticSessionEvent & {
type DiagnosticRunAttemptEvent = DiagnosticSessionEvent & {
type: "run.attempt";
runId: string;
attempt: number;
};
export type DiagnosticRunProgressEvent = DiagnosticSessionEvent & {
type DiagnosticRunProgressEvent = DiagnosticSessionEvent & {
type: "run.progress";
runId?: string;
reason: string;
@ -447,7 +442,7 @@ type DiagnosticGcEvent = DiagnosticBaseEvent & {
durationMs: number;
};
export type DiagnosticHeartbeatEvent = DiagnosticBaseEvent & {
type DiagnosticHeartbeatEvent = DiagnosticBaseEvent & {
type: "diagnostic.heartbeat";
webhooks: {
received: number;
@ -475,7 +470,7 @@ export type DiagnosticPhaseSnapshot = {
details?: DiagnosticPhaseDetails;
};
export type DiagnosticLivenessWarningEvent = DiagnosticBaseEvent & {
type DiagnosticLivenessWarningEvent = DiagnosticBaseEvent & {
type: "diagnostic.liveness.warning";
reasons: DiagnosticLivenessWarningReason[];
intervalMs: number;
@ -497,7 +492,7 @@ export type DiagnosticLivenessWarningEvent = DiagnosticBaseEvent & {
queuedWorkLabels?: string[];
};
export type DiagnosticPhaseCompletedEvent = DiagnosticBaseEvent &
type DiagnosticPhaseCompletedEvent = DiagnosticBaseEvent &
DiagnosticPhaseSnapshot & {
type: "diagnostic.phase.completed";
};
@ -543,16 +538,16 @@ type DiagnosticToolExecutionBaseEvent = DiagnosticSessionEvent & {
mutatingAction?: boolean;
};
export type DiagnosticToolExecutionStartedEvent = DiagnosticToolExecutionBaseEvent & {
type DiagnosticToolExecutionStartedEvent = DiagnosticToolExecutionBaseEvent & {
type: "tool.execution.started";
};
export type DiagnosticToolExecutionCompletedEvent = DiagnosticToolExecutionBaseEvent & {
type DiagnosticToolExecutionCompletedEvent = DiagnosticToolExecutionBaseEvent & {
type: "tool.execution.completed";
durationMs: number;
};
export type DiagnosticToolExecutionErrorEvent = DiagnosticToolExecutionBaseEvent & {
type DiagnosticToolExecutionErrorEvent = DiagnosticToolExecutionBaseEvent & {
type: "tool.execution.error";
durationMs: number;
errorCategory: string;
@ -560,14 +555,14 @@ export type DiagnosticToolExecutionErrorEvent = DiagnosticToolExecutionBaseEvent
terminalReason?: DiagnosticToolTerminalReason;
};
export type DiagnosticToolExecutionBlockedEvent = DiagnosticToolExecutionBaseEvent & {
type DiagnosticToolExecutionBlockedEvent = DiagnosticToolExecutionBaseEvent & {
type: "tool.execution.blocked";
deniedReason: string;
reason: string;
};
export type DiagnosticSkillTelemetrySource = "bundled" | "unknown" | "workspace";
export type DiagnosticSkillActivation = "command" | "read";
type DiagnosticSkillTelemetrySource = "bundled" | "unknown" | "workspace";
type DiagnosticSkillActivation = "command" | "read";
export type DiagnosticSkillUsedEvent = DiagnosticSessionEvent & {
type: "skill.used";
@ -580,7 +575,7 @@ export type DiagnosticSkillUsedEvent = DiagnosticSessionEvent & {
toolCallId?: string;
};
export type DiagnosticExecProcessCompletedEvent = DiagnosticBaseEvent & {
type DiagnosticExecProcessCompletedEvent = DiagnosticBaseEvent & {
type: "exec.process.completed";
sessionKey?: string;
target: "host" | "sandbox";
@ -601,7 +596,7 @@ export type DiagnosticExecProcessCompletedEvent = DiagnosticBaseEvent & {
| "runtime-error";
};
export type DiagnosticExecApprovalFollowupSuppressedEvent = DiagnosticBaseEvent & {
type DiagnosticExecApprovalFollowupSuppressedEvent = DiagnosticBaseEvent & {
type: "exec.approval.followup_suppressed";
approvalId: string;
reason: "session_rebound";
@ -610,11 +605,11 @@ export type DiagnosticExecApprovalFollowupSuppressedEvent = DiagnosticBaseEvent
type DiagnosticRunBaseEvent = DiagnosticBaseEvent & DiagnosticRunScopeFields;
export type DiagnosticRunStartedEvent = DiagnosticRunBaseEvent & {
type DiagnosticRunStartedEvent = DiagnosticRunBaseEvent & {
type: "run.started";
};
export type DiagnosticRunCompletedEvent = DiagnosticRunBaseEvent & {
type DiagnosticRunCompletedEvent = DiagnosticRunBaseEvent & {
type: "run.completed";
durationMs: number;
outcome: "completed" | "aborted" | "blocked" | "error";
@ -622,7 +617,7 @@ export type DiagnosticRunCompletedEvent = DiagnosticRunBaseEvent & {
blockedBy?: string;
};
export type DiagnosticHarnessRunPhase = "prepare" | "start" | "send" | "resolve" | "cleanup";
type DiagnosticHarnessRunPhase = "prepare" | "start" | "send" | "resolve" | "cleanup";
export type DiagnosticHarnessRunOutcome = "completed" | "aborted" | "timed_out" | "error";
type DiagnosticHarnessRunBaseEvent = DiagnosticBaseEvent &
@ -637,7 +632,7 @@ export type DiagnosticHarnessRunStartedEvent = DiagnosticHarnessRunBaseEvent & {
type DiagnosticAgentCommentaryEvent = DiagnosticBaseEvent & DiagnosticAgentCommentaryFields;
export type DiagnosticHarnessRunCompletedEvent = DiagnosticHarnessRunBaseEvent & {
type DiagnosticHarnessRunCompletedEvent = DiagnosticHarnessRunBaseEvent & {
type: "harness.run.completed";
durationMs: number;
outcome: DiagnosticHarnessRunOutcome;
@ -675,11 +670,11 @@ type DiagnosticModelCallBaseEvent = DiagnosticSessionEvent & {
promptStats?: DiagnosticModelCallPromptStats;
};
export type DiagnosticModelCallStartedEvent = DiagnosticModelCallBaseEvent & {
type DiagnosticModelCallStartedEvent = DiagnosticModelCallBaseEvent & {
type: "model.call.started";
};
export type DiagnosticModelCallCompletedEvent = DiagnosticModelCallBaseEvent & {
type DiagnosticModelCallCompletedEvent = DiagnosticModelCallBaseEvent & {
type: "model.call.completed";
durationMs: number;
requestPayloadBytes?: number;
@ -688,7 +683,7 @@ export type DiagnosticModelCallCompletedEvent = DiagnosticModelCallBaseEvent & {
usage?: DiagnosticModelCallUsage;
};
export type DiagnosticModelCallErrorEvent = DiagnosticModelCallBaseEvent & {
type DiagnosticModelCallErrorEvent = DiagnosticModelCallBaseEvent & {
type: "model.call.error";
durationMs: number;
errorCategory: string;
@ -719,7 +714,7 @@ type DiagnosticModelCallUsage = Readonly<{
total?: number;
}>;
export type DiagnosticContextAssembledEvent = DiagnosticSessionEvent & {
type DiagnosticContextAssembledEvent = DiagnosticSessionEvent & {
type: "context.assembled";
runId: string;
provider: string;
@ -737,7 +732,7 @@ export type DiagnosticContextAssembledEvent = DiagnosticSessionEvent & {
reserveTokens?: number;
};
export type DiagnosticMemorySampleEvent = DiagnosticBaseEvent & {
type DiagnosticMemorySampleEvent = DiagnosticBaseEvent & {
type: "diagnostic.memory.sample";
memory: DiagnosticMemoryUsage;
uptimeMs?: number;
@ -753,7 +748,7 @@ export type DiagnosticMemoryPressureEvent = DiagnosticBaseEvent & {
windowMs?: number;
};
export type DiagnosticPayloadLargeEvent = DiagnosticBaseEvent & {
type DiagnosticPayloadLargeEvent = DiagnosticBaseEvent & {
type: "payload.large";
surface: string;
action: "rejected" | "truncated" | "chunked";
@ -765,7 +760,7 @@ export type DiagnosticPayloadLargeEvent = DiagnosticBaseEvent & {
reason?: string;
};
export type DiagnosticLogRecordEvent = DiagnosticBaseEvent & {
type DiagnosticLogRecordEvent = DiagnosticBaseEvent & {
type: "log.record";
level: string;
message: string;
@ -778,7 +773,7 @@ export type DiagnosticLogRecordEvent = DiagnosticBaseEvent & {
};
};
export type DiagnosticTelemetryExporterEvent = DiagnosticBaseEvent & {
type DiagnosticTelemetryExporterEvent = DiagnosticBaseEvent & {
type: "telemetry.exporter";
exporter: string;
signal: "traces" | "metrics" | "logs";
@ -794,7 +789,7 @@ export type DiagnosticTelemetryExporterEvent = DiagnosticBaseEvent & {
errorCategory?: string;
};
export type DiagnosticAsyncQueueDroppedEvent = DiagnosticBaseEvent & {
type DiagnosticAsyncQueueDroppedEvent = DiagnosticBaseEvent & {
type: "diagnostic.async_queue.dropped";
droppedEvents: number;
droppedTrustedEvents?: number;
@ -903,12 +898,12 @@ export type DiagnosticModelCallContent = Readonly<{
toolDefinitions?: unknown;
}>;
export type DiagnosticToolCallContent = Readonly<{
type DiagnosticToolCallContent = Readonly<{
toolInput?: unknown;
toolOutput?: unknown;
}>;
export type DiagnosticSkillUsagePrivateData = Readonly<{
type DiagnosticSkillUsagePrivateData = Readonly<{
skillFile: string;
}>;

View file

@ -1,14 +1,10 @@
// Covers diagnostic flag matching and normalization.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
isDiagnosticFlagEnabled,
matchesDiagnosticFlag,
resolveDiagnosticFlags,
} from "./diagnostic-flags.js";
import { isDiagnosticFlagEnabled } from "./diagnostic-flags.js";
describe("resolveDiagnosticFlags", () => {
it("normalizes and dedupes config and env flags", () => {
describe("isDiagnosticFlagEnabled", () => {
it("normalizes config and env flags", () => {
const cfg = {
diagnostics: { flags: [" Telegram.Http ", "cache.*", "CACHE.*"] },
} as OpenClawConfig;
@ -16,7 +12,10 @@ describe("resolveDiagnosticFlags", () => {
OPENCLAW_DIAGNOSTICS: " foo, Cache.* telegram.http ",
} as NodeJS.ProcessEnv;
expect(resolveDiagnosticFlags(cfg, env)).toEqual(["telegram.http", "cache.*", "foo"]);
for (const flag of ["telegram.http", "cache.hit", "foo"]) {
expect(isDiagnosticFlagEnabled(flag, cfg, env)).toBe(true);
}
expect(isDiagnosticFlagEnabled("missing", cfg, env)).toBe(false);
});
it("treats blank env values as no extra flags", () => {
@ -25,10 +24,10 @@ describe("resolveDiagnosticFlags", () => {
} as OpenClawConfig;
expect(
resolveDiagnosticFlags(cfg, {
isDiagnosticFlagEnabled("telegram.http", cfg, {
OPENCLAW_DIAGNOSTICS: " ",
} as NodeJS.ProcessEnv),
).toEqual(["telegram.http"]);
).toBe(true);
});
it("treats false-like env values as disable overrides", () => {
@ -38,27 +37,37 @@ describe("resolveDiagnosticFlags", () => {
for (const raw of ["0", "false", "off", "none"]) {
expect(
resolveDiagnosticFlags(cfg, {
isDiagnosticFlagEnabled("telegram.http", cfg, {
OPENCLAW_DIAGNOSTICS: raw,
} as NodeJS.ProcessEnv),
).toStrictEqual([]);
).toBe(false);
}
});
});
describe("matchesDiagnosticFlag", () => {
describe("diagnostic flag patterns", () => {
it("matches exact, namespace, prefix, and wildcard rules", () => {
expect(matchesDiagnosticFlag("telegram.http", ["telegram.http"])).toBe(true);
expect(matchesDiagnosticFlag("cache", ["cache.*"])).toBe(true);
expect(matchesDiagnosticFlag("cache.hit", ["cache.*"])).toBe(true);
expect(matchesDiagnosticFlag("tool.exec.fast", ["tool.exec*"])).toBe(true);
expect(matchesDiagnosticFlag("anything", ["all"])).toBe(true);
expect(matchesDiagnosticFlag("anything", ["*"])).toBe(true);
for (const [flag, pattern] of [
["telegram.http", "telegram.http"],
["cache", "cache.*"],
["cache.hit", "cache.*"],
["tool.exec.fast", "tool.exec*"],
["anything", "all"],
["anything", "*"],
] as const) {
expect(isDiagnosticFlagEnabled(flag, undefined, { OPENCLAW_DIAGNOSTICS: pattern })).toBe(
true,
);
}
});
it("rejects blank and non-matching flags", () => {
expect(matchesDiagnosticFlag(" ", ["*"])).toBe(false);
expect(matchesDiagnosticFlag("cache.hit", ["cache.miss", "tool.*"])).toBe(false);
expect(isDiagnosticFlagEnabled(" ", undefined, { OPENCLAW_DIAGNOSTICS: "*" })).toBe(false);
expect(
isDiagnosticFlagEnabled("cache.hit", undefined, {
OPENCLAW_DIAGNOSTICS: "cache.miss,tool.*",
}),
).toBe(false);
});
});

View file

@ -6,7 +6,7 @@ import { parseDiagnosticEnvFlags } from "./diagnostic-flags-env.js";
const DIAGNOSTICS_ENV = "OPENCLAW_DIAGNOSTICS";
/** Resolves enabled diagnostic flags from config plus `OPENCLAW_DIAGNOSTICS` overrides. */
export function resolveDiagnosticFlags(
function resolveDiagnosticFlags(
cfg?: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string[] {
@ -19,7 +19,7 @@ export function resolveDiagnosticFlags(
}
/** Matches one diagnostic flag against exact, wildcard, and namespace-enabled flags. */
export function matchesDiagnosticFlag(flag: string, enabledFlags: string[]): boolean {
function matchesDiagnosticFlag(flag: string, enabledFlags: string[]): boolean {
const target = normalizeLowercaseStringOrEmpty(flag);
if (!target) {
return false;

View file

@ -16,12 +16,11 @@ import {
evaluateShellAllowlistWithAuthorization,
requiresExecApproval,
resolveAllowAlwaysPersistenceDecision,
resolveAllowAlwaysPatterns,
resolveSafeBins,
} from "./exec-approvals.js";
import { buildCwdBoundHashedArgPattern, matchAllowlist } from "./exec-command-resolution.js";
describe("resolveAllowAlwaysPatterns", () => {
describe("allow-always pattern persistence", () => {
async function evaluateCommand(
command: string,
allowlist: Parameters<typeof evaluateShellAllowlistWithAuthorization>[0]["allowlist"],
@ -124,9 +123,9 @@ describe("resolveAllowAlwaysPatterns", () => {
const context = { cwd: dir, env, platform: process.platform };
const analysis = analyzeArgvCommand({ argv: command.split(" "), ...context });
expect(analysis.ok).toBe(true);
expect(resolveAllowAlwaysPatterns({ segments: analysis.segments, ...context })).toStrictEqual(
[],
);
expect(
resolveAllowAlwaysPatternEntries({ segments: analysis.segments, ...context }),
).toStrictEqual([]);
const { persisted } = await resolvePersistedPatterns(command, dir, env);
expect(persisted).toStrictEqual([]);
@ -342,9 +341,9 @@ describe("resolveAllowAlwaysPatterns", () => {
...context,
});
expect(analysis.ok).toBe(true);
expect(resolveAllowAlwaysPatterns({ segments: analysis.segments, ...context })).toStrictEqual(
[],
);
expect(
resolveAllowAlwaysPatternEntries({ segments: analysis.segments, ...context }),
).toStrictEqual([]);
});
it("matches persisted shell script paths through dispatch wrappers", async () => {
@ -555,9 +554,9 @@ $0 \\"$1\\"" touch {marker}`,
const context = { cwd: dir, env, platform: process.platform };
const analysis = analyzeArgvCommand({ argv: [busybox, "sed", "-n", "1p"], ...context });
expect(analysis.ok).toBe(true);
expect(resolveAllowAlwaysPatterns({ segments: analysis.segments, ...context })).toStrictEqual(
[],
);
expect(
resolveAllowAlwaysPatternEntries({ segments: analysis.segments, ...context }),
).toStrictEqual([]);
});
it("prevents opaque startup shells from reusing broad grants", async () => {

View file

@ -13,7 +13,6 @@ import type {
} from "./exec-approvals-contracts.js";
import type { ExecApprovalsFile } from "./exec-approvals-core.js";
import { resolveExecApprovalsFromFileInternal } from "./exec-approvals-resolver.js";
import { replaceExecApprovalsSnapshot, updateExecApprovalsSync } from "./exec-approvals-store.js";
import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
import type { ExecAuthorizationPlan } from "./exec-authorization-plan.js";
import { isCwdBoundHashedArgPattern } from "./exec-command-resolution.js";
@ -244,43 +243,6 @@ function applyAllowlistEntryUpdate(params: {
};
}
export function addAllowlistEntry(
approvals: ExecApprovalsFile,
agentId: string | undefined,
pattern: string,
options?: {
argPattern?: string;
source?: ExecAllowlistEntry["source"];
},
): void {
const snapshot = updateExecApprovalsSync({
update: (file) =>
applyAllowlistEntryUpdate({
file,
agentId,
pattern,
options,
}),
});
if (snapshot) {
replaceExecApprovalsSnapshot(approvals, snapshot.file);
}
}
export function addDurableCommandApproval(
approvals: ExecApprovalsFile,
agentId: string | undefined,
commandText: string,
): void {
const normalized = commandText.trim();
if (!normalized) {
return;
}
addAllowlistEntry(approvals, agentId, buildDurableCommandApprovalPattern(normalized), {
source: "allow-always",
});
}
export function resolveAllowAlwaysPatternCoverage(params: {
segments: ExecCommandSegment[];
cwd?: string;
@ -318,30 +280,6 @@ export function resolveAllowAlwaysPatternCoverage(params: {
};
}
export function persistAllowAlwaysPatterns(params: {
approvals: ExecApprovalsFile;
agentId: string | undefined;
segments: ExecCommandSegment[];
cwd?: string;
env?: NodeJS.ProcessEnv;
platform?: string | null;
commandText?: string;
strictInlineEval?: boolean;
}): ReturnType<typeof resolveAllowAlwaysPatternEntries> {
const coverage = resolveAllowAlwaysPatternCoverage(params);
const commandText = params.commandText?.trim();
persistAllowAlwaysDecision({
approvals: params.approvals,
agentId: params.agentId,
decision: {
kind: "patterns",
patterns: coverage.patterns,
...(commandText && coverage.complete && coverage.patterns.length > 0 ? { commandText } : {}),
},
});
return coverage.patterns;
}
function hasRuntimeShellPayload(argv: readonly string[]): boolean {
const inlineCommand = extractBindableShellWrapperInlineCommand([...argv]);
return Boolean(
@ -445,28 +383,6 @@ export function resolveAllowAlwaysPersistenceDecision(params: {
return { kind: "one-shot", reasons: [...reasons] };
}
export function persistAllowAlwaysDecision(params: {
approvals: ExecApprovalsFile;
agentId: string | undefined;
decision: AllowAlwaysPersistenceDecision;
}): void {
const decision = params.decision;
if (decision.kind === "one-shot") {
return;
}
const snapshot = updateExecApprovalsSync({
update: (file) =>
applyAllowAlwaysDecision({
file,
agentId: params.agentId,
decision,
}),
});
if (snapshot) {
replaceExecApprovalsSnapshot(params.approvals, snapshot.file);
}
}
export function applyAllowAlwaysDecision(params: {
file: ExecApprovalsFile;
agentId: string | undefined;

View file

@ -33,7 +33,6 @@ import {
import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
import {
canUseReusableWrapperPayloadCandidates,
planExecAuthorization,
planShellAuthorization,
type ExecAuthorizationCandidate,
type ExecAuthorizationPlan,
@ -109,7 +108,7 @@ async function explainShellPolicySegments(params: {
}
}
export function normalizeSafeBins(entries?: readonly string[]): Set<string> {
function normalizeSafeBins(entries?: readonly string[]): Set<string> {
if (!Array.isArray(entries)) {
return new Set();
}
@ -126,14 +125,13 @@ export function resolveSafeBins(entries?: readonly string[] | null): Set<string>
return normalizeSafeBins(entries ?? []);
}
export function isSafeBinUsage(params: {
function isSafeBinUsage(params: {
argv: string[];
resolution: ExecutableResolution | null;
safeBins: Set<string>;
platform?: string | null;
trustedSafeBinDirs?: ReadonlySet<string>;
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
isTrustedSafeBinPathFn?: typeof isTrustedSafeBinPath;
}): boolean {
// Windows host exec uses PowerShell, which has different parsing/expansion rules.
// Keep safeBins conservative there (require explicit allowlist entries).
@ -156,9 +154,8 @@ export function isSafeBinUsage(params: {
if (!trustPath) {
return false;
}
const isTrustedPath = params.isTrustedSafeBinPathFn ?? isTrustedSafeBinPath;
if (
!isTrustedPath({
!isTrustedSafeBinPath({
resolvedPath: trustPath,
trustedDirs: params.trustedSafeBinDirs,
})
@ -1276,20 +1273,10 @@ export function resolveAllowAlwaysPatternEntries(params: {
return patterns;
}
export function resolveAllowAlwaysPatterns(params: {
segments: ExecCommandSegment[];
cwd?: string;
env?: NodeJS.ProcessEnv;
platform?: string | null;
strictInlineEval?: boolean;
}): string[] {
return resolveAllowAlwaysPatternEntries(params).map((pattern) => pattern.pattern);
}
/**
* Evaluates allowlist for shell commands (including &&, ||, ;) and returns analysis metadata.
*/
export function evaluateShellAllowlist(
function evaluateShellAllowlist(
params: {
command: string;
env?: NodeJS.ProcessEnv;
@ -1343,32 +1330,4 @@ export async function evaluateShellAllowlistWithAuthorization(
});
}
export async function evaluateExecAllowlistWithAuthorization(
params: {
analysis: ExecCommandAnalysis;
command?: string;
} & ExecAllowlistContext,
): Promise<
ExecAllowlistEvaluation & {
segments?: ExecCommandSegment[];
authorizationPlan?: ExecAuthorizationPlan;
}
> {
if (isWindowsPlatform(params.platform)) {
return evaluateExecAllowlist(params);
}
const authorizationPlan = await planExecAuthorization({ ...params });
if (!authorizationPlan.ok) {
return {
...emptyExecAllowlistEvaluation(),
segments: params.analysis.segments,
authorizationPlan,
};
}
const { analysisOk: _analysisOk, ...result } = evaluateAuthorizationPlan({
plan: authorizationPlan,
context: params,
});
return result;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

View file

@ -5,16 +5,15 @@ import { describe, expect, it } from "vitest";
import { evaluateExecAllowlist } from "./exec-approvals-allowlist.js";
import {
analyzeArgvCommand,
analyzeWindowsShellCommand,
buildEnforcedShellCommand,
resolvePlannedSegmentArgv,
windowsEscapeArg,
} from "./exec-approvals-analysis.js";
import {
makeExecutable,
makePathEnv,
makeExecApprovalsTempDir,
} from "./exec-approvals-test-helpers.js";
import { analyzeWindowsShellCommand, windowsEscapeArg } from "./windows-shell-command.js";
describe("exec argv analysis", () => {
it("parses argv commands", () => {

View file

@ -6,32 +6,20 @@ export { analyzeArgvCommand } from "./exec-argv-analysis.js";
export {
matchAllowlist,
parseExecArgvToken,
buildCwdBoundHashedArgPattern,
resolveAllowlistCandidatePath,
resolveApprovalAuditCandidatePath,
resolveApprovalAuditTrustPath,
resolveCommandResolution,
resolveCommandResolutionFromArgv,
resolveExecutionTargetCandidatePath,
resolveExecutionTargetResolution,
resolveExecutionTargetTrustPath,
resolvePolicyAllowlistCandidatePath,
resolvePolicyTargetCandidatePath,
resolvePolicyTargetResolution,
resolvePolicyTargetTrustPath,
resolveExecutableTrustPath,
type CommandResolution,
type ExecutableResolution,
type ExecArgvToken,
} from "./exec-command-resolution.js";
export {
analyzeWindowsShellCommand,
isWindowsPlatform,
tokenizeWindowsSegment,
windowsEscapeArg,
} from "./windows-shell-command.js";
export { isWindowsPlatform } from "./windows-shell-command.js";
export type {
ExecCommandAnalysis,
ExecCommandSegment,

View file

@ -21,22 +21,6 @@ import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
export type { ExecApprovalUsageAuthorization } from "./exec-approvals-contracts.js";
export function recordAllowlistUse(
approvals: ExecApprovalsFile,
agentId: string | undefined,
entry: ExecAllowlistEntry,
command: string,
resolvedPath?: string,
): void {
recordAllowlistMatchesUse({
approvals,
agentId,
matches: [entry],
command,
resolvedPath,
});
}
export function recordAllowlistMatchesUse(params: {
approvals: ExecApprovalsFile;
agentId: string | undefined;

View file

@ -3,15 +3,15 @@ import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { tryParsePersistedExecApprovals } from "./exec-approvals-config.js";
import { saveExecApprovals } from "./exec-approvals-store.test-support.js";
import { makeExecApprovalsTempDir } from "./exec-approvals-test-helpers.js";
import {
isSafeBinUsage,
evaluateExecAllowlist,
matchAllowlist,
normalizeExecApprovals,
normalizeSafeBins,
resolveExecApprovals,
resolveSafeBins,
resolveExecApprovalsLocked,
resolveExecApprovalsFromFile,
saveExecApprovals,
type ExecApprovalsAgent,
type ExecAllowlistEntry,
type ExecApprovalsFile,
@ -31,7 +31,7 @@ describe("exec approval temp fixture cleanup", { concurrent: false }, () => {
});
describe("exec approvals wildcard agent", () => {
it("merges wildcard allowlist entries with agent entries", () => {
it("merges wildcard allowlist entries with agent entries", async () => {
const dir = makeExecApprovalsTempDir();
const prevOpenClawHome = process.env.OPENCLAW_HOME;
@ -45,7 +45,7 @@ describe("exec approvals wildcard agent", () => {
},
});
const resolved = resolveExecApprovals("main");
const resolved = await resolveExecApprovalsLocked("main");
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([
"/bin/hostname",
"/usr/bin/uname",
@ -62,10 +62,6 @@ describe("exec approvals wildcard agent", () => {
});
describe("exec approvals node host allowlist check", () => {
// These tests verify the allowlist satisfaction logic used by the node host path
// The node host checks: matchAllowlist() || isSafeBinUsage() for each command segment
// Using hardcoded resolution objects for cross-platform compatibility
it.each([
{
resolution: {
@ -115,12 +111,21 @@ describe("exec approvals node host allowlist check", () => {
resolvedPath: "/usr/local/bin/unknown-tool",
executableName: "unknown-tool",
};
const safe = isSafeBinUsage({
argv: ["unknown-tool", "--help"],
resolution,
safeBins: normalizeSafeBins(["jq", "curl"]),
const result = evaluateExecAllowlist({
analysis: {
ok: true,
segments: [
{
raw: "unknown-tool --help",
argv: ["unknown-tool", "--help"],
resolution: { kind: "command", execution: resolution, policy: resolution },
},
],
},
allowlist: [],
safeBins: resolveSafeBins(["jq", "curl"]),
});
expect(safe).toBe(false);
expect(result.allowlistSatisfied).toBe(false);
});
it("satisfies via safeBins even when not in allowlist", () => {
@ -137,17 +142,26 @@ describe("exec approvals node host allowlist check", () => {
expect(match).toBeNull();
// But is a safe bin with non-file args
const safe = isSafeBinUsage({
argv: ["head", "-n", "1"],
resolution,
safeBins: normalizeSafeBins(["head"]),
const result = evaluateExecAllowlist({
analysis: {
ok: true,
segments: [
{
raw: "head -n 1",
argv: ["head", "-n", "1"],
resolution: { kind: "command", execution: resolution, policy: resolution },
},
],
},
allowlist: entries,
safeBins: resolveSafeBins(["head"]),
});
// Safe bins are disabled on Windows (PowerShell parsing/expansion differences).
if (process.platform === "win32") {
expect(safe).toBe(false);
expect(result.allowlistSatisfied).toBe(false);
return;
}
expect(safe).toBe(true);
expect(result.allowlistSatisfied).toBe(true);
});
});

View file

@ -120,12 +120,6 @@ export function resolveExecApprovalsDisplayPath(env: NodeJS.ProcessEnv = process
: path.join(stateDir, locator);
}
export function resolveExecApprovalsTranscriptPath(): string {
return process.env.OPENCLAW_STATE_DIR?.trim()
? "$OPENCLAW_STATE_DIR/state/openclaw.sqlite#exec_approvals_config"
: `${DEFAULT_EXEC_APPROVALS_STATE_DIR}/state/openclaw.sqlite#exec_approvals_config`;
}
export function createFailClosedExecApprovalsFallback(): ExecApprovalsFile {
return normalizeExecApprovalsInternal({
version: 1,

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